diff --git a/iksm.ts b/iksm.ts index 9d43cdc..ef9a9d9 100644 --- a/iksm.ts +++ b/iksm.ts @@ -1,53 +1,26 @@ import { CookieJar, wrapFetch } from "./deps.ts"; -import { LoginState } from "./state.ts"; -import { readline, urlBase64Encode } from "./utils.ts"; +import { readline, retry, urlBase64Encode } from "./utils.ts"; +import { S3SI_VERSION } from "./version.ts"; const NSOAPP_VERSION = "2.3.1"; +const USERAGENT = `s3si.ts/${S3SI_VERSION}`; -function random(size: number): ArrayBuffer { - return crypto.getRandomValues(new Uint8Array(size)).buffer; +export class APIError extends Error { + response: Response; + json: unknown; + constructor( + { response, message }: { + response: Response; + json?: unknown; + message?: string; + }, + ) { + super(message); + this.response = response; + } } -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 { +export async function loginManually(): Promise { const cookieJar = new CookieJar(); const fetch = wrapFetch({ cookieJar }); @@ -119,7 +92,325 @@ export async function loginManually(): Promise { throw new Error("No session token found"); } + return sessionToken; +} + +export async function getGToken( + { fApi, sessionToken }: { fApi: string; sessionToken: string }, +) { + const idResp = await fetch( + "https://accounts.nintendo.com/connect/1.0.0/api/token", + { + method: "POST", + headers: { + "Host": "accounts.nintendo.com", + "Accept-Encoding": "gzip", + "Content-Type": "application/json", + "Accept": "application/json", + "Connection": "Keep-Alive", + "User-Agent": "Dalvik/2.1.0 (Linux; U; Android 7.1.2)", + }, + body: JSON.stringify({ + "client_id": "71b963c1b7b6d119", + "session_token": sessionToken, + "grant_type": + "urn:ietf:params:oauth:grant-type:jwt-bearer-session-token", + }), + }, + ); + const idRespJson = await idResp.json(); + const { access_token: accessToken, id_token: idToken } = idRespJson; + if (!accessToken || !idToken) { + throw new APIError({ + response: idResp, + json: idRespJson, + message: "No access_token or id_token found", + }); + } + + const uiResp = await fetch( + "https://api.accounts.nintendo.com/2.0.0/users/me", + { + headers: { + "User-Agent": "NASDKAPI; Android", + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": `Bearer ${accessToken}`, + "Host": "api.accounts.nintendo.com", + "Connection": "Keep-Alive", + "Accept-Encoding": "gzip", + }, + }, + ); + const uiRespJson = await uiResp.json(); + const { nickname, birthday, language, country } = uiRespJson; + + const getIdToken2 = async (idToken: string) => { + const { f, request_id: requestId, timestamp } = await callImink({ + fApi, + step: 1, + idToken, + }); + const parameter = { + "f": f, + "language": language, + "naBirthday": birthday, + "naCountry": country, + "naIdToken": idToken, + "requestId": requestId, + "timestamp": timestamp, + }; + const resp = await fetch( + "https://api-lp1.znc.srv.nintendo.net/v3/Account/Login", + { + method: "POST", + headers: { + "X-Platform": "Android", + "X-ProductVersion": NSOAPP_VERSION, + "Content-Type": "application/json; charset=utf-8", + "Connection": "Keep-Alive", + "Accept-Encoding": "gzip", + "User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`, + }, + body: JSON.stringify({ + parameter, + }), + }, + ); + const respJson = await resp.json(); + + const idToken2 = respJson?.result?.webApiServerCredential?.accessToken; + + if (!idToken2) { + throw new APIError({ + response: resp, + json: respJson, + message: "No idToken2 found", + }); + } + + return idToken2 as string; + }; + const getGToken = async (idToken: string) => { + const { f, request_id: requestId, timestamp } = await callImink({ + step: 2, + idToken, + fApi, + }); + const resp = await fetch( + "https://api-lp1.znc.srv.nintendo.net/v2/Game/GetWebServiceToken", + { + method: "POST", + headers: { + "X-Platform": "Android", + "X-ProductVersion": NSOAPP_VERSION, + "Authorization": `Bearer ${idToken}`, + "Content-Type": "application/json; charset=utf-8", + "Accept-Encoding": "gzip", + "User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`, + }, + body: JSON.stringify({ + parameter: { + "f": f, + "id": 4834290508791808, + "registrationToken": idToken, + "requestId": requestId, + "timestamp": timestamp, + }, + }), + }, + ); + const respJson = await resp.json(); + + const webServiceToken = respJson?.result?.accessToken; + + if (!webServiceToken) { + throw new APIError({ + response: resp, + json: respJson, + message: "No webServiceToken found", + }); + } + + return webServiceToken as string; + }; + + const idToken2 = await retry(() => getIdToken2(idToken)); + const webServiceToken = await retry(() => getGToken(idToken2)); + return { - sessionToken, + webServiceToken, + nickname, + userCountry: country, + userLang: language, }; } + +const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; + +async function getWebViewVer(): Promise { + const splatnet3Home = await (await fetch(SPLATNET3_URL)).text(); + + const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1]; + + if (!mainJS) { + throw new Error("No main.js found"); + } + + const mainJSBody = await (await fetch(SPLATNET3_URL + mainJS)).text(); + + const revision = /"([0-9a-f]{40})"/.exec(mainJSBody)?.[1]; + const version = /revision_info_not_set.*?="(\d+\.\d+\.\d+)/.exec(mainJSBody) + ?.[1]; + + if (!version || !revision) { + throw new Error("No version and revision found"); + } + + return `${version}-${revision.substring(0, 8)}`; +} + +const DEFAULT_APP_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"; + +export async function getBulletToken( + { + webServiceToken, + appUserAgent = DEFAULT_APP_USER_AGENT, + userLang, + userCountry, + }: { + webServiceToken: string; + appUserAgent?: string; + userLang: string; + userCountry: string; + }, +) { + const webViewVer = await getWebViewVer(); + const headers = { + "Content-Type": "application/json", + "Accept-Language": userLang, + "User-Agent": appUserAgent, + "X-Web-View-Ver": webViewVer, + "X-NACOUNTRY": userCountry, + "Accept": "*/*", + "Origin": "https://api.lp1.av5ja.srv.nintendo.net", + "X-Requested-With": "com.nintendo.znca", + "Cookie": `_gtoken=${webServiceToken}`, + }; + const resp = await fetch( + "https://api.lp1.av5ja.srv.nintendo.net/api/bullet_tokens", + { + method: "POST", + headers, + }, + ); + + if (resp.status == 401) { + throw new APIError({ + response: resp, + message: + "Unauthorized error (ERROR_INVALID_GAME_WEB_TOKEN). Cannot fetch tokens at this time.", + }); + } + if (resp.status == 403) { + throw new APIError({ + response: resp, + message: + "Forbidden error (ERROR_OBSOLETE_VERSION). Cannot fetch tokens at this time.", + }); + } + if (resp.status == 204) { + throw new APIError({ + response: resp, + message: "Cannot access SplatNet 3 without having played online.", + }); + } + if (resp.status !== 201) { + throw new APIError({ + response: resp, + message: "Not 201", + }); + } + + const respJson = await resp.json(); + const { bulletToken } = respJson; + + if (typeof bulletToken !== "string") { + throw new APIError({ + response: resp, + json: respJson, + message: "No bulletToken found", + }); + } + + return bulletToken; +} + +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", + "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"]; +} + +type IminkResponse = { + f: string; + request_id: string; + timestamp: number; +}; +async function callImink( + { fApi, step, idToken }: { fApi: string; step: number; idToken: string }, +): Promise { + const headers = { + "User-Agent": USERAGENT, + "Content-Type": "application/json; charset=utf-8", + }; + const body = { + "token": idToken, + "hashMethod": step, + }; + const resp = await fetch(fApi, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + return await resp.json(); +} diff --git a/s3si.ts b/s3si.ts index 2f7feb1..d0f5ca4 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,4 +1,4 @@ -import { loginManually } from "./iksm.ts"; +import { APIError, getBulletToken, getGToken, loginManually } from "./iksm.ts"; import { flags } from "./deps.ts"; import { DEFAULT_STATE, State } from "./state.ts"; @@ -29,15 +29,19 @@ Options: async writeState() { const encoder = new TextEncoder(); const data = encoder.encode(JSON.stringify(this.state, undefined, 2)); - await Deno.writeFile(this.opts.configPath + ".swap", data); - await Deno.rename(this.opts.configPath + ".swap", this.opts.configPath); + const swapPath = `${this.opts.configPath}.swap`; + await Deno.writeFile(swapPath, data); + await Deno.rename(swapPath, this.opts.configPath); } async readState() { const decoder = new TextDecoder(); try { const data = await Deno.readFile(this.opts.configPath); const json = JSON.parse(decoder.decode(data)); - this.state = json; + this.state = { + ...DEFAULT_STATE, + ...json, + }; } catch (e) { console.warn( `Failed to read config file, create new config file. (${e})`, @@ -47,13 +51,45 @@ Options: } async run() { await this.readState(); - if (!this.state.loginState?.sessionToken) { - const { sessionToken } = await loginManually(); - this.state.loginState = { - ...this.state.loginState, - sessionToken, - }; - await this.writeState(); + + try { + if (!this.state.loginState?.sessionToken) { + const sessionToken = await loginManually(); + this.state.loginState = { + ...this.state.loginState, + sessionToken, + }; + await this.writeState(); + } + const sessionToken = this.state.loginState.sessionToken!; + + if (!this.state.loginState?.gToken) { + const { webServiceToken, userCountry, userLang } = await getGToken({ + fApi: this.state.fGen, + sessionToken, + }); + + const bulletToken = await getBulletToken({ + webServiceToken, + userLang, + userCountry, + appUserAgent: this.state.appUserAgent, + }); + + this.state.loginState = { + ...this.state.loginState, + gToken: webServiceToken, + bulletToken, + }; + + await this.writeState(); + } + } catch (e) { + if (e instanceof APIError) { + console.error(`APIError: ${e.message}`, e.response, e.json); + } else { + console.error(e); + } } } } diff --git a/state.ts b/state.ts index 6cc651b..a017180 100644 --- a/state.ts +++ b/state.ts @@ -1,8 +1,14 @@ export type LoginState = { - sessionToken: string; + sessionToken?: string; + gToken?: string; + bulletToken?: string; }; export type State = { loginState?: LoginState; + fGen: string; + appUserAgent?: string; }; -export const DEFAULT_STATE: State = {}; +export const DEFAULT_STATE: State = { + fGen: "https://api.imink.app/f", +}; diff --git a/utils.ts b/utils.ts index 3b315a0..d31a348 100644 --- a/utils.ts +++ b/utils.ts @@ -24,3 +24,19 @@ export async function readline() { } } } + +type PromiseReturnType = T extends () => Promise ? R : never; +export async function retry Promise>( + f: F, + times = 2, +): Promise> { + let lastError; + for (let i = 0; i < times; i++) { + try { + return await f() as PromiseReturnType; + } catch (e) { + lastError = e; + } + } + throw lastError; +} diff --git a/version.ts b/version.ts new file mode 100644 index 0000000..ad29498 --- /dev/null +++ b/version.ts @@ -0,0 +1 @@ +export const S3SI_VERSION = "0.1.0";