import { retry, urlBase64Encode } from "./utils.ts"; import { DEFAULT_APP_USER_AGENT, NSOAPP_VERSION, USERAGENT, WEB_VIEW_VERSION, } from "./constant.ts"; import { APIError } from "./APIError.ts"; import { Env, Fetcher } from "./env.ts"; export async function loginSteps( env: Env, ): Promise< { authCodeVerifier: string; url: string; } >; export async function loginSteps( env: Env, step2: { authCodeVerifier: string; login: string; }, ): Promise< { sessionToken: string; } >; export async function loginSteps( { newFetcher }: Env, step2?: { authCodeVerifier: string; login: string; }, ): Promise< { authCodeVerifier: string; url: string; } | { sessionToken: string; } > { const fetch = newFetcher(); if (!step2) { 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 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.get( { url, headers: { "Host": "accounts.nintendo.com", "Connection": "keep-alive", "Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": DEFAULT_APP_USER_AGENT, "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", }, }, ); return { authCodeVerifier, url: res.url, }; } else { const { login, authCodeVerifier } = step2; 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({ fetch, sessionTokenCode, authCodeVerifier, }); if (!sessionToken) { throw new Error("No session token found"); } return { sessionToken }; } } export async function loginManually( env: Env, ): Promise { const { prompts: { promptLogin } } = env; const step1 = await loginSteps(env); const { url, authCodeVerifier } = step1; const login = (await promptLogin(url)).trim(); if (!login) { throw new Error("No login URL provided"); } const step2 = await loginSteps(env, { authCodeVerifier, login }); return step2.sessionToken; } export async function getGToken( { fApi, sessionToken, env }: { fApi: string; sessionToken: string; env: Env }, ) { const fetch = env.newFetcher(); const idResp = await fetch.post( { url: "https://accounts.nintendo.com/connect/1.0.0/api/token", 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 14; Pixel 7a Build/UQ1A.240105.004)", }, 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.get( { url: "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, id: userId } = uiRespJson; const getIdToken2 = async (idToken: string) => { const { f, request_id: requestId, timestamp } = await callImink({ fApi, step: 1, idToken, userId, env, }); const resp = await fetch.post( { url: "https://api-lp1.znc.srv.nintendo.net/v3/Account/Login", 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/14)`, }, body: JSON.stringify({ parameter: { "f": f, "language": language, "naBirthday": birthday, "naCountry": country, "naIdToken": idToken, "requestId": requestId, "timestamp": timestamp, }, }), }, ); const respJson = await resp.json(); const idToken2: string = respJson?.result?.webApiServerCredential ?.accessToken; const coralUserId: string = respJson?.result?.user?.id?.toString(); if (!idToken2 || !coralUserId) { throw new APIError({ response: resp, json: respJson, message: `No idToken2 or coralUserId found. Please try again later. (${idToken2.length}, ${coralUserId.length})`, }); } return [idToken2, coralUserId] as const; }; const getGToken = async (idToken: string, coralUserId: string) => { const { f, request_id: requestId, timestamp } = await callImink({ step: 2, idToken, fApi, userId, coralUserId, env, }); const resp = await fetch.post( { url: "https://api-lp1.znc.srv.nintendo.net/v2/Game/GetWebServiceToken", 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/14)`, }, 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, coralUserId] = await retry(() => getIdToken2(idToken)); const webServiceToken = await retry(() => getGToken(idToken2, coralUserId)); return { webServiceToken, nickname, userCountry: country, userLang: language, }; } export async function getBulletToken( { webServiceToken, appUserAgent = DEFAULT_APP_USER_AGENT, userLang, userCountry, env, }: { webServiceToken: string; appUserAgent?: string; userLang: string; userCountry: string; env: Env; }, ) { const { post } = env.newFetcher({ cookies: [{ name: "_gtoken", value: webServiceToken, domain: "api.lp1.av5ja.srv.nintendo.net", }], }); const resp = await post({ url: "https://api.lp1.av5ja.srv.nintendo.net/api/bullet_tokens", headers: { "Content-Type": "application/json", "Accept-Language": userLang, "User-Agent": appUserAgent, "X-Web-View-Ver": WEB_VIEW_VERSION, "X-NACOUNTRY": userCountry, "Accept": "*/*", "Origin": "https://api.lp1.av5ja.srv.nintendo.net", "X-Requested-With": "com.nintendo.znca", }, }); 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({ fetch, sessionTokenCode, authCodeVerifier, }: { fetch: Fetcher; sessionTokenCode: string; authCodeVerifier: string; }): Promise { const resp = await fetch.post( { url: "https://accounts.nintendo.com/connect/1.0.0/api/session_token", 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", }, body: new URLSearchParams({ "client_id": "71b963c1b7b6d119", "session_token_code": sessionTokenCode, "session_token_code_verifier": authCodeVerifier, }), }, ); const json = await resp.json(); if (json.error) { throw new APIError({ response: resp, json, message: "Error getting session token", }); } return json["session_token"]; } type IminkResponse = { f: string; request_id: string; timestamp: number; }; async function callImink( params: { fApi: string; step: number; idToken: string; userId: string; coralUserId?: string; env: Env; }, ): Promise { const { fApi, step, idToken, userId, coralUserId, env } = params; const { post } = env.newFetcher(); const resp = await post({ url: fApi, headers: { "User-Agent": USERAGENT, "Content-Type": "application/json", }, body: JSON.stringify({ "token": idToken, "hash_method": step, "na_id": userId, "coral_user_id": coralUserId, }), }); return await resp.json(); }