diff --git a/README.md b/README.md index d44f66a..5cca9c0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ # s3si.ts + Export your battles from SplatNet to stat.ink + +## Usage + +1. Install deno + +2. Run + `deno run --allow-net --allow-read --allow-write --allow-env https://raw.githubusercontent.com/spacemeowx2/s3si.ts/master/s3si.ts` diff --git a/deps.ts b/deps.ts new file mode 100644 index 0000000..62d8cee --- /dev/null +++ b/deps.ts @@ -0,0 +1,6 @@ +export { + Cookie, + CookieJar, + wrapFetch, +} from "https://deno.land/x/another_cookiejar@v4.1.4/mod.ts"; +export * as base64 from "https://deno.land/std@0.95.0/encoding/base64.ts"; diff --git a/iksm.ts b/iksm.ts new file mode 100644 index 0000000..4307caf --- /dev/null +++ b/iksm.ts @@ -0,0 +1,124 @@ +import { CookieJar, wrapFetch } from "./deps.ts"; +import { LoginState } from "./state.ts"; +import { urlBase64Encode } from "./utils.ts"; + +const NSOAPP_VERSION = "2.3.1"; + +function random(size: number): ArrayBuffer { + return crypto.getRandomValues(new Uint8Array(size)).buffer; +} + +async function getSessionToken({ + cookieJar, + sessionTokenCode, + authCodeVerifier, +}: { + cookieJar: CookieJar; + sessionTokenCode: string; + authCodeVerifier: string; +}): Promise { + const fetch = wrapFetch({ cookieJar }); + + const headers = { + "User-Agent": `OnlineLounge/${NSOAPP_VERSION} NASDKAPI Android`, + "Accept-Language": "en-US", + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": "540", + "Host": "accounts.nintendo.com", + "Connection": "Keep-Alive", + "Accept-Encoding": "gzip", + }; + + const body = { + "client_id": "71b963c1b7b6d119", + "session_token_code": sessionTokenCode, + "session_token_code_verifier": authCodeVerifier, + }; + + const url = "https://accounts.nintendo.com/connect/1.0.0/api/session_token"; + + const res = await fetch(url, { + method: "POST", + headers: headers, + body: new URLSearchParams(body), + }); + const resBody = await res.json(); + return resBody["session_token"]; +} + +export async function loginManually(): Promise { + const cookieJar = new CookieJar(); + const fetch = wrapFetch({ cookieJar }); + + const state = urlBase64Encode(random(36)); + const authCodeVerifier = urlBase64Encode(random(32)); + const authCvHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(authCodeVerifier), + ); + const authCodeChallenge = urlBase64Encode(authCvHash); + + const headers = { + "Host": "accounts.nintendo.com", + "Connection": "keep-alive", + "Cache-Control": "max-age=0", + "Upgrade-Insecure-Requests": "1", + "User-Agent": + "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Mobile Safari/537.36", + "Accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8n", + "DNT": "1", + "Accept-Encoding": "gzip,deflate,br", + }; + + const body = { + "state": state, + "redirect_uri": "npf71b963c1b7b6d119://auth", + "client_id": "71b963c1b7b6d119", + "scope": "openid user user.birthday user.mii user.screenName", + "response_type": "session_token_code", + "session_token_code_challenge": authCodeChallenge, + "session_token_code_challenge_method": "S256", + "theme": "login_form", + }; + const url = "https://accounts.nintendo.com/connect/1.0.0/authorize?" + + new URLSearchParams(body); + + const res = await fetch( + url, + { + method: "GET", + headers: headers, + }, + ); + + console.log("Navigate to this URL in your browser:"); + console.log(res.url); + + const login = prompt( + 'Log in, right click the "Select this account" button, copy the link address, and paste it below:\n', + ); + if (!login) { + throw new Error("No login URL provided"); + } + const loginURL = new URL(login); + const params = new URLSearchParams(loginURL.hash.substring(1)); + const sessionTokenCode = params.get("session_token_code"); + if (!sessionTokenCode) { + throw new Error("No session token code provided"); + } + + const sessionToken = await getSessionToken({ + cookieJar, + sessionTokenCode, + authCodeVerifier, + }); + if (!sessionToken) { + throw new Error("No session token found"); + } + + return { + sessionToken, + }; +} diff --git a/s3si.ts b/s3si.ts new file mode 100644 index 0000000..528cd7e --- /dev/null +++ b/s3si.ts @@ -0,0 +1,4 @@ +import { loginManually } from "./iksm.ts"; + +const state = await loginManually(); +console.log(state); diff --git a/state.ts b/state.ts new file mode 100644 index 0000000..94112ab --- /dev/null +++ b/state.ts @@ -0,0 +1,3 @@ +export type LoginState = { + sessionToken: string; +}; diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..53545cb --- /dev/null +++ b/utils.ts @@ -0,0 +1,16 @@ +import { base64 } from "./deps.ts"; + +export function urlBase64Encode(data: ArrayBuffer) { + return base64.encode(data) + .replaceAll("+", "_") + .replaceAll("/", "-") + .replaceAll("=", ""); +} + +export function urlBase64Decode(data: string) { + return base64.decode( + data + .replaceAll("_", "+") + .replaceAll("-", "/"), + ); +}