diff --git a/README.md b/README.md index 0e59666..6958add 100644 --- a/README.md +++ b/README.md @@ -758,29 +758,29 @@ nxapi exports it's API library and types. [See src/exports.](src/exports) Example authenticating to the Nintendo Switch Online app: -> This is a simplified example of authenticating to the Coral API and using cached tokens. More logic is required to ensure you are using these APIs properly - [see src/common/auth/coral.ts for the authentication functions used in nxapi's CLI and Electron app](src/common/auth/coral.ts). +> This is a simplified example of authenticating to the Coral API and using cached tokens. More logic is required to ensure you are using these APIs properly, and to renew expired tokens - [see src/common/auth/coral.ts for the authentication functions used in nxapi's CLI and Electron app](src/common/auth/coral.ts). ```ts import { addUserAgent } from 'nxapi'; -import CoralApi from 'nxapi/coral'; +import CoralApi, { CoralAuthData } from 'nxapi/coral'; addUserAgent('your-script/1.0.0 (+https://github.com/...)'); -declare function getCachedCoralToken(): [string, Date]; -declare function setCachedCoralToken(token: string, expires_at: Date): void; +declare function getCachedCoralToken(): [CoralAuthData, Date]; +declare function setCachedCoralToken(auth_data: CoralAuthData, expires_at: Date): void; declare function getNintendoAccountSessionToken(): string; let coral; try { - const [token, expires_at] = getCachedCoralToken(); + const [auth_data, expires_at] = getCachedCoralToken(); if (expires_at.getTime() > Date.now()) throw new Error('Token expired'); - coral = new CoralApi(token); + coral = CoralApi.createWithSavedToken(auth_data); } catch (err) { const na_session_token = getNintendoAccountSessionToken(); const {nso, data} = await CoralApi.createWithSessionToken(na_session_token); - setCachedCoralToken(data.credential.accessToken, Date.now() + (data.credential.expiresIn * 1000)); + setCachedCoralToken(data, Date.now() + (data.credential.expiresIn * 1000)); coral = nso; } @@ -792,7 +792,8 @@ Example getting SplatNet 2 records: > This example does not include authenticating to SplatNet 2. To benefit from the caching in the nxapi command, the `nxapi splatnet2 token --json` command can be used in most scripts. For example: > > ```sh -> # your-script.js can then read the iksm_session, unique player ID and region from `JSON.parse(process.env.SPLATNET_TOKEN)` +> # your-script.js can then read the iksm_session, unique player ID and region like this: +> # SplatNet2Api.createWithCliTokenData(JSON.parse(process.env.SPLATNET_TOKEN)) > SPLATNET_TOKEN=`nxapi splatnet2 token --json` node your-script.js > ``` @@ -800,7 +801,8 @@ Example getting SplatNet 2 records: import SplatNet2Api from 'nxapi/splatnet2'; const iksm_session = '...'; -const splatnet2 = new SplatNet2Api(iksm_session); +const unique_id = '...'; +const splatnet2 = SplatNet2Api.createWithIksmSession(iksm_session, unique_id); const records = await splatnet2.getRecords(); ``` diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index d8da0ed..b016ad8 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -25,9 +25,10 @@ export const updateIksmSessionLastUsed: { } = {}; export default class SplatNet2Api { - constructor( + protected constructor( public iksm_session: string, - public useragent: string + public unique_id: string, + public useragent: string, ) {} async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { @@ -35,13 +36,15 @@ export default class SplatNet2Api { const response = await fetch(SPLATNET2_URL + url, { method, headers: Object.assign({ - 'Upgrade-Insecure-Requests': '1', 'User-Agent': this.useragent, 'Cookie': 'iksm_session=' + encodeURIComponent(this.iksm_session), - 'dnt': '1', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept': '*/*', 'Accept-Language': 'en-GB,en-US;q=0.8', - 'X-Requested-With': 'com.nintendo.znca', + 'Referrer': 'https://app.splatoon2.nintendo.net/home', + 'X-Requested-With': 'XMLHttpRequest', + // 'X-Timezone-Offset': (new Date()).getTimezoneOffset().toString(), + 'X-Timezone-Offset': '0', + 'X-Unique-Id': this.unique_id, }, headers), body, signal, @@ -229,11 +232,31 @@ ${colour} static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) { const data = await this.loginWithCoral(nso, user); + return {splatnet: this.createWithSavedToken(data), data}; + } - return { - splatnet: new this(data.iksm_session, data.useragent), - data, - }; + static createWithSavedToken(data: SplatNet2AuthData) { + return new this( + data.iksm_session, + data.user_id, + data.useragent, + ); + } + + static createWithCliTokenData(data: SplatNet2CliTokenData) { + return new this( + data.iksm_session, + data.user_id, + SPLATNET2_WEBSERVICE_USERAGENT, + ); + } + + static createWithIksmSession(iksm_session: string, unique_id: string) { + return new this( + iksm_session, + unique_id, + SPLATNET2_WEBSERVICE_USERAGENT, + ); } static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) { @@ -242,7 +265,9 @@ ${colour} return this.loginWithWebServiceToken(webserviceToken.result, user); } - static async loginWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) { + static async loginWithWebServiceToken( + webserviceToken: WebServiceToken, user: NintendoAccountUser + ): Promise { const url = new URL(SPLATNET2_WEBSERVICE_URL); url.search = new URLSearchParams({ lang: user.language, @@ -295,6 +320,11 @@ ${colour} const mn = body.match(/]*))?)*\s+data-nsa-id=(?:"([^"]*)"|([^\s>]*))/i); const [language, region, user_id, nsa_id] = [ml, mr, mu, mn].map(m => m?.[1] || m?.[2] || null); + if (!language) throw new Error('[splatnet2] Invalid language in response'); + if (!region) throw new Error('[splatnet2] Invalid region in response'); + if (!user_id) throw new Error('[splatnet2] Invalid unique player ID in response'); + if (!nsa_id) throw new Error('[splatnet2] Invalid NSA ID in response'); + debug('SplatNet 2 user', { language, region, @@ -319,6 +349,31 @@ ${colour} } } +export interface SplatNet2AuthData { + webserviceToken: WebServiceToken; + url: string; + cookies: string; + body: string; + + language: string; + region: string; + /** Splatoon 2 player ID aka. unique_id */ + user_id: string; + nsa_id: string; + + iksm_session: string; + expires_at: number; + useragent: string; +} + +export interface SplatNet2CliTokenData { + iksm_session: string; + language: string; + region: string; + user_id: string; + nsa_id: string; +} + export function toLeagueId(date: Date, type: LeagueType) { const year = date.getUTCFullYear(); const month = date.getUTCMonth() + 1; diff --git a/src/cli/splatnet2/token.ts b/src/cli/splatnet2/token.ts index c073fc7..ec81aac 100644 --- a/src/cli/splatnet2/token.ts +++ b/src/cli/splatnet2/token.ts @@ -3,6 +3,7 @@ import type { Arguments as ParentArguments } from '../splatnet2.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; import { getIksmToken } from '../../common/auth/splatnet2.js'; +import { SplatNet2CliTokenData } from '../../api/splatnet2.js'; const debug = createDebug('cli:splatnet2:token'); @@ -35,7 +36,7 @@ export async function handler(argv: ArgumentsCamelCase) { const {splatnet, data} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); if (argv.json || argv.jsonPrettyPrint) { - const result = { + const result: SplatNet2CliTokenData = { iksm_session: data.iksm_session, language: data.language, region: data.region, diff --git a/src/common/auth/splatnet2.ts b/src/common/auth/splatnet2.ts index 7b989b9..d24d63f 100644 --- a/src/common/auth/splatnet2.ts +++ b/src/common/auth/splatnet2.ts @@ -3,30 +3,14 @@ import * as fs from 'node:fs'; import createDebug from 'debug'; import persist from 'node-persist'; import { getToken } from './coral.js'; -import SplatNet2Api, { updateIksmSessionLastUsed } from '../../api/splatnet2.js'; -import { WebServiceToken } from '../../api/coral-types.js'; +import SplatNet2Api, { SplatNet2AuthData, updateIksmSessionLastUsed } from '../../api/splatnet2.js'; import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js'; import { Jwt } from '../../util/jwt.js'; import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js'; const debug = createDebug('nxapi:auth:splatnet2'); -export interface SavedIksmSessionToken { - webserviceToken: WebServiceToken; - url: string; - cookies: string; - - body: string; - language: string | null; - region: string | null; - /** Splatoon 2 player ID aka. unique_id */ - user_id: string | null; - nsa_id: string | null; - - iksm_session: string; - expires_at: number; - useragent: string; - +export interface SavedIksmSessionToken extends SplatNet2AuthData { last_used?: number; } @@ -69,7 +53,7 @@ export async function getIksmToken( } return { - splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent), + splatnet: SplatNet2Api.createWithSavedToken(existingToken), data: existingToken, }; } @@ -81,7 +65,7 @@ export async function getIksmToken( } return { - splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent), + splatnet: SplatNet2Api.createWithSavedToken(existingToken), data: existingToken, }; } diff --git a/src/exports/splatnet2.ts b/src/exports/splatnet2.ts index 1742a6c..39ade33 100644 --- a/src/exports/splatnet2.ts +++ b/src/exports/splatnet2.ts @@ -1,5 +1,7 @@ export { default, + SplatNet2AuthData, + LeagueType, LeagueRegion, ShareColour as ShareProfileColour,