From 788083b7f6b714f15ab321e0b0b05a0677ae7238 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Mon, 26 Dec 2022 20:51:30 +0000 Subject: [PATCH] Add coral/SplatNet 3 client https://github.com/samuelthomas2774/nxapi/issues/42 --- src/api/coral.ts | 23 ++- src/api/na.ts | 2 +- src/cli/util/storage.ts | 50 +++++- src/client/coral.ts | 294 ++++++++++++++++++++++++++++++++++++ src/client/na.ts | 106 +++++++++++++ src/client/splatnet3.ts | 212 ++++++++++++++++++++++++++ src/client/storage/index.ts | 159 +++++++++++++++++++ src/client/storage/local.ts | 60 ++++++++ src/client/users.ts | 43 ++++++ src/exports/coral.ts | 4 + src/exports/index.ts | 11 ++ src/exports/splatnet3.ts | 4 + src/util/jwt.ts | 2 +- 13 files changed, 965 insertions(+), 5 deletions(-) create mode 100644 src/client/coral.ts create mode 100644 src/client/na.ts create mode 100644 src/client/splatnet3.ts create mode 100644 src/client/storage/index.ts create mode 100644 src/client/storage/local.ts create mode 100644 src/client/users.ts diff --git a/src/api/coral.ts b/src/api/coral.ts index d3a7f8d..af2b3f7 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -280,6 +280,14 @@ export default class CoralApi { return {nso: this.createWithSavedToken(data, useragent), data}; } + static async createWithNintendoAccountToken( + token: NintendoAccountToken, user: NintendoAccountUser, + useragent = getAdditionalUserAgents() + ) { + const data = await this.loginWithNintendoAccountToken(token, user, useragent); + return {nso: this.createWithSavedToken(data, useragent), data}; + } + static createWithSavedToken(data: CoralAuthData, useragent = getAdditionalUserAgents()) { return new this( data.credential.accessToken, @@ -291,9 +299,7 @@ export default class CoralApi { static async loginWithSessionToken(token: string, useragent = getAdditionalUserAgents()): Promise { const { default: { coral: config } } = await import('../common/remote-config.js'); - if (!config) throw new Error('Remote configuration prevents Coral authentication'); - const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; // Nintendo Account token const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); @@ -301,6 +307,19 @@ export default class CoralApi { // Nintendo Account user data const user = await getNintendoAccountUser(nintendoAccountToken); + return this.loginWithNintendoAccountToken(nintendoAccountToken, user, useragent); + } + + static async loginWithNintendoAccountToken( + nintendoAccountToken: NintendoAccountToken, + user: NintendoAccountUser, + useragent = getAdditionalUserAgents() + ) { + const { default: { coral: config } } = await import('../common/remote-config.js'); + + if (!config) throw new Error('Remote configuration prevents Coral authentication'); + const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; + const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, useragent); debug('Getting Nintendo Switch Online app token'); diff --git a/src/api/na.ts b/src/api/na.ts index 0578219..5119b7b 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -116,7 +116,7 @@ export interface NintendoAccountSessionToken { code: string; } export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload { - jti: string; + jti: number; typ: 'session_token'; iss: 'https://accounts.nintendo.com'; /** Unknown - scopes the token is valid for? */ diff --git a/src/cli/util/storage.ts b/src/cli/util/storage.ts index b78d1a2..f6bdfe1 100644 --- a/src/cli/util/storage.ts +++ b/src/cli/util/storage.ts @@ -5,6 +5,12 @@ import { Argv } from '../../util/yargs.js'; import { initStorage, iterateLocalStorage } from '../../util/storage.js'; import Table from './table.js'; import { createHash } from 'node:crypto'; +import { Storage } from '../../client/storage/index.js'; +import { LocalStorageProvider } from '../../client/storage/local.js'; +import { Jwt } from '../../util/jwt.js'; +import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js'; +import { ZNCA_CLIENT_ID } from '../../api/coral.js'; +import { ZNMA_CLIENT_ID } from '../../api/moon.js'; const debug = createDebug('cli:util:storage'); @@ -12,7 +18,7 @@ export const command = 'storage'; export const desc = 'Manage node-persist data'; export function builder(yargs: Argv) { - return yargs.demandCommand().command('list', 'List all object', yargs => {}, async argv => { + return yargs.demandCommand().command('list', 'List all objects', yargs => {}, async argv => { const storage = await initStorage(argv.dataPath); const table = new Table({ @@ -39,5 +45,47 @@ export function builder(yargs: Argv) { table.sort((a, b) => a[1] > b[1] ? 1 : b[1] > a[1] ? -1 : 0); console.log(table.toString()); + }).command('migrate', 'Migrate to LocalStorageProvider', yargs => {}, async argv => { + const storage = await Storage.create(LocalStorageProvider, argv.dataPath); + const persist = await initStorage(argv.dataPath); + + for await (const data of iterateLocalStorage(persist)) { + const json = JSON.stringify(data.value, null, 4) + '\n'; + + let match; + + if (match = data.key.match(/^NintendoAccountToken\.(.*)$/)) { + const na_id = match[1]; + + await storage.provider.setSessionToken(na_id, ZNCA_CLIENT_ID, data.value); + } else if (match = data.key.match(/^NintendoAccountToken-pctl\.(.*)$/)) { + const na_id = match[1]; + + await storage.provider.setSessionToken(na_id, ZNMA_CLIENT_ID, data.value); + } else if (match = data.key.match(/^(NsoToken|MoonToken)\.(.*)$/)) { + const token = match[2]; + const [jwt, sig] = Jwt.decode(token); + + await storage.provider.setSessionItem(jwt.payload.sub, '' + jwt.payload.jti, + 'AuthenticationData.json', json); + } else if (match = data.key.match(/^(IksmToken|BulletToken|NookToken|NookUsers)\.(.*)$/)) { + const key = match[1]; + const token = match[2]; + const [jwt, sig] = Jwt.decode(token); + + await storage.provider.setSessionItem(jwt.payload.sub, '' + jwt.payload.jti, + key + '.json', json); + } else if (match = data.key.match(/^(NookAuthToken)\.(.*)\.([^.]*)$/)) { + const key = match[1]; + const token = match[2]; + const nooklink_user_id = match[3]; + const [jwt, sig] = Jwt.decode(token); + + await storage.provider.setSessionItem(jwt.payload.sub, '' + jwt.payload.jti, + key + '-' + nooklink_user_id + '.json', json); + } else { + debug('Unknown key %s', data.key.substr(0, 20) + '...'); + } + } }); } diff --git a/src/client/coral.ts b/src/client/coral.ts new file mode 100644 index 0000000..1a65220 --- /dev/null +++ b/src/client/coral.ts @@ -0,0 +1,294 @@ +import createDebug from 'debug'; +import { Response } from 'node-fetch'; +import CoralApi, { CoralAuthData, Result, ZNCA_CLIENT_ID } from '../api/coral.js'; +import { Announcements, Friends, Friend, GetActiveEventResult, WebServices, CoralErrorResponse } from '../api/coral-types.js'; +import { NintendoAccountSession, Storage } from './storage/index.js'; +import { checkUseLimit } from '../common/auth/util.js'; +import ZncProxyApi from '../api/znc-proxy.js'; +import { ArgumentsCamelCase } from '../util/yargs.js'; +import { initStorage } from '../util/storage.js'; +import NintendoAccountOIDC from './na.js'; +import Users from './users.js'; + +const debug = createDebug('nxapi:client:coral'); + +export interface SavedToken extends CoralAuthData { + expires_at: number; + proxy_url?: string; +} + +export default class Coral { + created_at = Date.now(); + expires_at = Date.now() + (2 * 60 * 60 * 1000); + + promise = new Map>(); + + updated = { + announcements: Date.now(), + friends: Date.now(), + webservices: Date.now(), + active_event: Date.now(), + }; + update_interval = 10 * 1000; // 10 seconds + update_interval_announcements = 30 * 60 * 1000; // 30 minutes + + onUpdatedWebServices: ((webservices: Result) => void) | null = null; + + constructor( + public api: CoralApi, + public data: CoralAuthData, + public announcements: Result, + public friends: Result, + public webservices: Result, + public active_event: Result, + ) {} + + private async update(key: keyof Coral['updated'], callback: () => Promise, ttl: number) { + if ((this.updated[key] + ttl) < Date.now()) { + const promise = this.promise.get(key) ?? callback.call(null).then(() => { + this.updated[key] = Date.now(); + this.promise.delete(key); + }).catch(err => { + this.promise.delete(key); + throw err; + }); + + this.promise.set(key, promise); + + await promise; + } else { + debug('Not updating %s data for coral user %s', key, this.data.nsoAccount.user.name); + } + } + + get user() { + return this.data.nsoAccount.user; + } + + async getAnnouncements() { + await this.update('announcements', async () => { + this.announcements = await this.api.getAnnouncements(); + }, this.update_interval_announcements); + + return this.announcements; + } + + async getFriends() { + await this.update('friends', async () => { + this.friends = await this.api.getFriendList(); + }, this.update_interval); + + return this.friends.friends; + } + + async getWebServices() { + await this.update('webservices', async () => { + const webservices = this.webservices = await this.api.getWebServices(); + + this.onUpdatedWebServices?.call(null, webservices); + }, this.update_interval); + + return this.webservices; + } + + async getActiveEvent() { + await this.update('active_event', async () => { + this.active_event = await this.api.getActiveEvent(); + }, this.update_interval); + + return 'id' in this.active_event ? this.active_event : null; + } + + async addFriend(nsa_id: string) { + if (nsa_id === this.data.nsoAccount.user.nsaId) { + throw new Error('Cannot add self as a friend'); + } + + const result = await this.api.sendFriendRequest(nsa_id); + + // Check if the user is now friends + // The Nintendo Switch Online app doesn't do this, but if the other user already sent a friend request to + // this user, they will be added as friends immediately. If the user is now friends we can show a message + // saying that, instead of saying that a friend request was sent when the user actually just accepted the + // other user's friend request. + let friend: Friend | null = null; + + try { + // Clear the last updated timestamp to force updating the friend list + this.updated.friends = 0; + + const friends = await this.getFriends(); + friend = friends.find(f => f.nsaId === nsa_id) ?? null; + } catch (err) { + debug('Error updating friend list for %s to check if a friend request was accepted', + this.data.nsoAccount.user.name, err); + } + + return {result, friend}; + } + + static async create(storage: Storage, na_id: string, proxy_url?: string) { + const session = await storage.getSession(na_id, ZNCA_CLIENT_ID); + if (!session) throw new Error('Unknown user'); + + if (proxy_url) { + return this.createWithProxy(session, proxy_url); + } + + const oidc = await NintendoAccountOIDC.createWithSession(session, false); + + return this.createWithSession(session, oidc); + } + + static async createWithSession(session: NintendoAccountSession, oidc: NintendoAccountOIDC) { + const cached_auth_data = await session.getAuthenticationData(); + + const [coral, auth_data] = cached_auth_data && cached_auth_data.expires_at > Date.now() ? + [CoralApi.createWithSavedToken(cached_auth_data), cached_auth_data] : + await this.createWithSessionAuthenticate(session, oidc); + + return this.createWithCoralApi(coral, auth_data); + } + + private static async createWithSessionAuthenticate( + session: NintendoAccountSession, oidc: NintendoAccountOIDC + ) { + // await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit); + + console.warn('Authenticating to Nintendo Switch Online app'); + debug('Authenticating to znc with session token'); + + const [token, user] = await Promise.all([ + oidc.getToken(), + oidc.getUser(), + ]); + + const {nso, data} = await CoralApi.createWithNintendoAccountToken(token, user); + + const auth_data: SavedToken = { + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; + + await session.setAuthenticationData(auth_data); + + return [nso, auth_data] as const; + } + + static async createWithProxy(session: NintendoAccountSession, proxy_url: string) { + const cached_auth_data = await session.getAuthenticationData(); + + const [coral, auth_data] = cached_auth_data && cached_auth_data.expires_at > Date.now() ? + [new ZncProxyApi(proxy_url, session.token), cached_auth_data] : + await this.createWithProxyAuthenticate(session, proxy_url); + + return this.createWithCoralApi(coral, auth_data); + } + + private static async createWithProxyAuthenticate( + session: NintendoAccountSession, proxy_url: string + ) { + // await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit); + + console.warn('Authenticating to Nintendo Switch Online app'); + debug('Authenticating to znc with session token'); + + const {nso, data} = await ZncProxyApi.createWithSessionToken(proxy_url, session.token); + + const auth_data: SavedToken = { + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; + + await session.setAuthenticationData(auth_data); + + return [nso, auth_data] as const; + } + + static async createWithCoralApi(coral: CoralApi, data: SavedToken) { + const [announcements, friends, webservices, active_event] = await Promise.all([ + coral.getAnnouncements(), + coral.getFriendList(), + coral.getWebServices(), + coral.getActiveEvent(), + ]); + + return new Coral(coral, data, announcements, friends, webservices, active_event); + } + + static async createWithUserStore(users: Users, id: string) { + const session = await users.storage.getSession(id, ZNCA_CLIENT_ID); + + if (!session) { + throw new Error('Unknown user'); + } + + if (users.znc_proxy_url) { + return Coral.createWithProxy(session, users.znc_proxy_url); + } + + // const oidc = await users.get(NintendoAccountOIDC, id, false); + const oidc = await NintendoAccountOIDC.createWithSession(session, false); + + return Coral.createWithSession(session, oidc); + } +} + +function createTokenExpiredHandler( + session: NintendoAccountSession, coral: CoralApi, + renew_token_data: {auth_data: SavedToken}, ratelimit = true +) { + return (data: CoralErrorResponse, response: Response) => { + debug('Token expired', renew_token_data.auth_data.user.id, data); + return renewToken(session, coral, renew_token_data, ratelimit); + }; +} + +async function renewToken( + session: NintendoAccountSession, coral: CoralApi, + renew_token_data: {auth_data: SavedToken}, ratelimit = true +) { + // if (ratelimit) { + // const [jwt, sig] = Jwt.decode(token); + // await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit); + // } + + const data = await coral.renewToken(session.token, renew_token_data.auth_data.user); + + const auth_data: SavedToken = { + ...renew_token_data.auth_data, + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; + + await session.setAuthenticationData(auth_data); + renew_token_data.auth_data = auth_data; +} + +export async function getCoralClientFromArgv(storage: Storage, argv: ArgumentsCamelCase<{ + 'data-path': string; + user?: string; + token?: string; + 'znc-proxy-url'?: string; +}>) { + // const storage = await Storage.create(LocalStorageProvider, argv.dataPath); + + if (argv.token) { + const session = new NintendoAccountSession(storage, argv.token, undefined, ZNCA_CLIENT_ID); + return argv.zncProxyUrl ? + Coral.createWithProxy(session, argv.zncProxyUrl) : + Coral.createWithSession(session, await NintendoAccountOIDC.createWithSession(session, false)); + } + if (argv.user) { + return Coral.create(storage, argv.user, argv.zncProxyUrl); + } + + const persist = await initStorage(argv.dataPath); + const user = await persist.getItem('SelectedUser'); + + if (!user) { + throw new Error('No user selected'); + } + + return Coral.create(storage, user, argv.zncProxyUrl); +} diff --git a/src/client/na.ts b/src/client/na.ts new file mode 100644 index 0000000..677618b --- /dev/null +++ b/src/client/na.ts @@ -0,0 +1,106 @@ +import createDebug from 'debug'; +import { NintendoAccountSession } from './storage/index.js'; +import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from '../api/na.js'; +import Users from './users.js'; + +const debug = createDebug('nxapi:client:na'); + +export interface SavedToken { + token: NintendoAccountToken; + created_at: number; + expires_at: number; +} + +export default class NintendoAccountOIDC { + created_at = Date.now(); + expires_at = Infinity; + + promise = new Map>(); + + updated = { + token: null as number | null, + user: null as number | null, + }; + update_interval = 10 * 1000; // 10 seconds + + user: NintendoAccountUser | null = null; + + onUpdateSavedToken: ((data: SavedToken) => Promise) | null = null; + + constructor( + readonly token: string, + readonly client_id: string, + public data: SavedToken, + ) { + this.updated.token = data.created_at; + } + + private async update(key: keyof NintendoAccountOIDC['updated'], callback: () => Promise, ttl: number) { + if (((this.updated[key] ?? 0) + ttl) < Date.now()) { + const promise = this.promise.get(key) ?? callback.call(null).then(() => { + this.updated[key] = Date.now(); + this.promise.delete(key); + }).catch(err => { + this.promise.delete(key); + throw err; + }); + + this.promise.set(key, promise); + + await promise; + } else { + debug('Not updating %s data for Nintendo Account user %s', key, this.user); + } + } + + async getToken() { + if (this.data.expires_at > Date.now()) return this.data.token; + + await this.update('token', async () => { + const token = await getNintendoAccountToken(this.token, this.client_id); + + this.data = { + token, + created_at: Date.now(), + expires_at: Date.now() + (token.expires_in * 1000), + }; + + await this.onUpdateSavedToken?.(this.data); + }, 0); + + return this.data.token; + } + + async getUser() { + await this.update('user', async () => { + const token = await this.getToken(); + this.user = await getNintendoAccountUser(token); + }, this.update_interval); + + return this.user!; + } + + static async createWithSession(session: NintendoAccountSession, renew_token = true) { + const cached_auth_data = await session.getNintendoAccountToken(); + + if (cached_auth_data && (cached_auth_data.expires_at > Date.now() || !renew_token)) { + const client = new NintendoAccountOIDC(session.token, session.client_id, cached_auth_data); + client.onUpdateSavedToken = data => session.setNintendoAccountToken(data); + return client; + } + + const token = await getNintendoAccountToken(session.token, session.client_id); + + const auth_data: SavedToken = { + token, + created_at: Date.now(), + expires_at: Date.now() + (token.expires_in * 1000), + }; + + await session.setNintendoAccountToken(auth_data); + + const client = new NintendoAccountOIDC(session.token, session.client_id, auth_data); + client.onUpdateSavedToken = data => session.setNintendoAccountToken(data); + return client; + } +} diff --git a/src/client/splatnet3.ts b/src/client/splatnet3.ts new file mode 100644 index 0000000..6dab986 --- /dev/null +++ b/src/client/splatnet3.ts @@ -0,0 +1,212 @@ +import createDebug from 'debug'; +import { Response } from 'node-fetch'; +import { ConfigureAnalyticsResult, CurrentFestResult, DetailVotingStatusResult, FriendListResult, Friend_friendList, HomeResult, StageScheduleResult } from 'splatnet3-types/splatnet3'; +import { ZNCA_CLIENT_ID } from '../api/coral.js'; +import { NintendoAccountSession, Storage } from './storage/index.js'; +import SplatNet3Api, { PersistedQueryResult, SplatNet3AuthData } from '../api/splatnet3.js'; +import Coral, { SavedToken as SavedCoralToken } from './coral.js'; +import { ErrorResponse } from '../api/util.js'; +import Users from './users.js'; + +const debug = createDebug('nxapi:client:splatnet3'); + +export interface SavedToken extends SplatNet3AuthData { + // expires_at: number; +} + +export default class SplatNet3 { + created_at = Date.now(); + expires_at = Infinity; + + friends: PersistedQueryResult | null = null; + // schedules: PersistedQueryResult | null = null; + + promise = new Map>(); + + updated = { + configure_analytics: null as number | null, + current_fest: null as number | null, + home: Date.now(), + friends: null as number | null, + schedules: null as number | null, + }; + update_interval = 10 * 1000; // 10 seconds + update_interval_schedules = 60 * 60 * 1000; // 60 minutes + + constructor( + public api: SplatNet3Api, + public data: SplatNet3AuthData, + public configure_analytics: PersistedQueryResult | null = null, + public current_fest: PersistedQueryResult | null = null, + public home: PersistedQueryResult, + ) { + if (configure_analytics) this.updated.configure_analytics = Date.now(); + if (current_fest) this.updated.current_fest = Date.now(); + } + + protected async update(key: keyof SplatNet3['updated'], callback: () => Promise, ttl: number) { + if (((this.updated[key] ?? 0) + ttl) < Date.now()) { + const promise = this.promise.get(key) ?? callback.call(null).then(() => { + this.updated[key] = Date.now(); + this.promise.delete(key); + }).catch(err => { + this.promise.delete(key); + throw err; + }); + + this.promise.set(key, promise); + + await promise; + } else { + debug('Not updating %s data for SplatNet 3 user', key); + } + } + + async getHome(): Promise { + await this.update('home', async () => { + this.home = await this.api.getHome(); + }, this.update_interval); + + return this.home.data; + } + + async getFriends(): Promise { + await this.update('friends', async () => { + this.friends = this.friends ? + await this.api.getFriendsRefetch() : + await this.api.getFriends(); + }, this.update_interval); + + return this.friends!.data.friends.nodes; + } + + static async create(storage: Storage, coral: Coral) { + const session = await storage.getSession(coral.data.user.id, ZNCA_CLIENT_ID); + if (!session) throw new Error('Unknown user'); + + return this.createWithSession(session, coral); + } + + static async createWithSession(session: NintendoAccountSession, coral: Coral) { + const cached_auth_data = await session.getItem('BulletToken'); + + const [splatnet, auth_data] = cached_auth_data && cached_auth_data.expires_at > Date.now() ? + [SplatNet3Api.createWithSavedToken(cached_auth_data), cached_auth_data] : + await this.createWithSessionAuthenticate(session, coral); + + const renew_token_data = {coral, auth_data}; + splatnet.onTokenExpired = createTokenExpiredHandler(session, splatnet, renew_token_data); + splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(session, splatnet, renew_token_data); + + return this.createWithSplatNet3Api(splatnet, auth_data); + } + + private static async createWithSessionAuthenticate(session: NintendoAccountSession, coral: Coral) { + // + + const {splatnet, data} = await SplatNet3Api.createWithCoral(coral.api, coral.data.user); + + await session.setItem('BulletToken', data); + + return [splatnet, data] as const; + } + + static async createWithSplatNet3Api(splatnet: SplatNet3Api, data: SavedToken) { + const home = await splatnet.getHome(); + + const [configure_analytics, current_fest] = await Promise.all([ + splatnet.getConfigureAnalytics().catch(err => { + debug('Error in ConfigureAnalyticsQuery request', err); + }), + splatnet.getCurrentFest().catch(err => { + debug('Error in useCurrentFest request', err); + }), + ]); + + return new SplatNet3(splatnet, data, configure_analytics ?? null, current_fest ?? null, home); + } + + static async createWithUserStore(users: Users, id: string) { + const session = await users.storage.getSession(id, ZNCA_CLIENT_ID); + + if (!session) { + throw new Error('Unknown user'); + } + + const coral = await users.get(Coral, id); + + return SplatNet3.createWithSession(session, coral); + } +} + +function createTokenExpiredHandler( + session: NintendoAccountSession, splatnet: SplatNet3Api, + data: {coral: Coral; auth_data: SavedToken; znc_proxy_url?: string}, + ratelimit = true +) { + return (response: Response) => { + debug('Token expired, renewing'); + return renewToken(session, splatnet, data, ratelimit); + }; +} + +function createTokenShouldRenewHandler( + session: NintendoAccountSession, splatnet: SplatNet3Api, + data: {coral: Coral; auth_data: SavedToken; znc_proxy_url?: string}, + ratelimit = true +) { + return (remaining: number, response: Response) => { + debug('Token will expire in %d seconds, renewing', remaining); + return renewToken(session, splatnet, data, ratelimit); + }; +} + +async function renewToken( + session: NintendoAccountSession, splatnet: SplatNet3Api, + renew_token_data: {coral: Coral; auth_data: SavedToken; znc_proxy_url?: string}, ratelimit = true +) { + // if (ratelimit) { + // const [jwt, sig] = Jwt.decode(token); + // await checkUseLimit(storage, 'splatnet3', jwt.payload.sub); + // } + + try { + const coral_auth_data = renew_token_data.coral.data ?? await session.getAuthenticationData(); + + if (coral_auth_data) { + const data = await splatnet.renewTokenWithWebServiceToken( + renew_token_data.auth_data.webserviceToken, coral_auth_data.user); + + const auth_data: SavedToken = { + ...renew_token_data.auth_data, + ...data, + }; + + await session.setItem('BulletToken', auth_data); + renew_token_data.auth_data = auth_data; + + return; + } else { + debug('Unable to renew bullet token with saved web services token - cached data for this session token doesn\'t exist??'); + } + } catch (err) { + if (err instanceof ErrorResponse && err.response.status === 401) { + // Web service token invalid/expired... + debug('Web service token expired, authenticating with new token', err); + } else { + throw err; + } + } + + const coral = renew_token_data.coral; + + const data = await splatnet.renewTokenWithCoral(coral.api, coral.data.user); + + const auth_data: SavedToken = { + ...renew_token_data.auth_data, + ...data, + }; + + await session.setItem('BulletToken', auth_data); + renew_token_data.auth_data = auth_data; +} diff --git a/src/client/storage/index.ts b/src/client/storage/index.ts new file mode 100644 index 0000000..02ee5ca --- /dev/null +++ b/src/client/storage/index.ts @@ -0,0 +1,159 @@ +import createDebug from 'debug'; +import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js'; +import { Jwt } from '../../util/jwt.js'; +import { SavedToken as SavedNaToken } from '../na.js'; + +const debug = createDebug('nxapi:client:storage'); + +export interface StorageProvider { + getSessionToken(na_id: string, client_id: string): Promise; + getSessionItem(na_id: string, session_id: string, key: string): Promise; + setSessionItem(na_id: string, session_id: string, key: string, value: string): Promise; +} + +type PromiseType> = T extends Promise ? R : never; +type ConstructorType any> = T extends new (...args: any) => infer R ? R : never; + +export class Storage { + constructor(readonly provider: T) {} + + async getSessionToken(na_id: string, client_id: string) { + return this.provider.getSessionToken(na_id, client_id); + } + + async getSession(na_id: string, client_id: string): Promise | null> { + const token = await this.provider.getSessionToken(na_id, client_id); + if (!token) return null; + + const session = new NintendoAccountSession(this, token, na_id, client_id); + return session; + } + + async getJsonSessionItem(na_id: string, session_id: string, key: string) { + const value = await this.provider.getSessionItem(na_id, session_id, key + '.json'); + if (!value) return null; + + const data = JSON.parse(value) as T; + return data; + } + + async setJsonSessionItem(na_id: string, session_id: string, key: string, data: T) { + const value = JSON.stringify(data, null, 4) + '\n'; + await this.provider.setSessionItem(na_id, session_id, key + '.json', value); + } + + static create< + C extends { + create(args: any): StorageProvider | Promise; + } | { + new (args: any): StorageProvider; + }, + R extends + C extends { create(args: any): Promise; } ? + Promise extends Promise ? ReturnType : never>>> : + C extends { create(args: any): StorageProvider; } ? + Storage extends StorageProvider ? ReturnType : never> : + C extends new (args: any) => StorageProvider ? Storage> : + never, + >( + constructor: C, + ...args: + C extends { create(args: any): any; } ? Parameters : + C extends new (args: any) => any ? ConstructorParameters : + never + ): R { + if ('create' in constructor) { + const provider = constructor.create.apply(constructor, args); + + return provider instanceof Promise ? + provider.then(provider => new Storage(provider)) as R : + new Storage(provider) as R; + } + + const provider = new (constructor as new (...args: any) => StorageProvider)(...args); + return new Storage(provider) as R; + } +} + +export class NintendoAccountSession< + T +> { + readonly na_id: string; + readonly client_id: string; + readonly jwt: Jwt; + // private readonly jwt_sig: Buffer; + + constructor( + readonly storage: Storage, + readonly token: string, + na_id?: string, + client_id?: string, + ) { + const [jwt, jwt_sig] = Jwt.decode(token); + + if (jwt.payload.iss !== 'https://accounts.nintendo.com') { + throw new Error('Invalid Nintendo Account session token issuer'); + } + if (jwt.payload.typ !== 'session_token') { + throw new Error('Invalid Nintendo Account session token type'); + } + // if (jwt.payload.aud !== ZNCA_CLIENT_ID) { + // throw new Error('Invalid Nintendo Account session token audience'); + // } + if (client_id && jwt.payload.aud !== client_id) { + throw new Error('Invalid Nintendo Account session token audience'); + } + if (na_id && jwt.payload.sub !== na_id) { + throw new Error('Invalid Nintendo Account session token subject'); + } + if (jwt.payload.exp <= (Date.now() / 1000)) { + throw new Error('Nintendo Account session token expired'); + } + + this.jwt = jwt; + this.na_id = na_id ?? jwt.payload.sub; + this.client_id = client_id ?? jwt.payload.aud; + } + + get user_id() { + return this.jwt.payload.sub; + } + + get session_id() { + return '' + this.jwt.payload.jti; + } + + async getItem(key: string) { + return this.storage.getJsonSessionItem(this.na_id, this.session_id, key); + } + + async setItem(key: string, data: T) { + return this.storage.setJsonSessionItem(this.na_id, this.session_id, key, data); + } + + async getNintendoAccountToken() { + return this.storage.getJsonSessionItem(this.na_id, this.session_id, 'NintendoAccountToken'); + } + + async setNintendoAccountToken(data: SavedNaToken) { + return this.storage.setJsonSessionItem(this.na_id, this.session_id, 'NintendoAccountToken', data); + } + + async getAuthenticationData() { + return this.storage.getJsonSessionItem(this.na_id, this.session_id, 'AuthenticationData'); + } + + async setAuthenticationData(data: T) { + return this.storage.setJsonSessionItem(this.na_id, this.session_id, 'AuthenticationData', data); + } + + async getRateLimitAttempts(key: string) { + const attempts = + await this.storage.getJsonSessionItem(this.na_id, this.session_id, 'RateLimitAttempts-' + key); + return attempts ?? []; + } + + async setRateLimitAttempts(key: string, attempts: number[]) { + await this.storage.setJsonSessionItem(this.na_id, this.session_id, 'RateLimitAttempts-' + key, attempts); + } +} diff --git a/src/client/storage/local.ts b/src/client/storage/local.ts new file mode 100644 index 0000000..546de93 --- /dev/null +++ b/src/client/storage/local.ts @@ -0,0 +1,60 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs/promises'; +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import { StorageProvider } from './index.js'; + +const debug = createDebug('nxapi:client:storage:local'); + +export class LocalStorageProvider implements StorageProvider { + protected constructor(readonly path: string) {} + + async getSessionToken(na_id: string, client_id: string) { + await mkdirp(path.join(this.path, 'users', na_id)); + + try { + debug('read', path.join('users', na_id, 'session-' + client_id)); + const token = await fs.readFile(path.join(this.path, 'users', na_id, 'session-' + client_id), 'utf-8'); + + return token; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + } + + async setSessionToken(na_id: string, client_id: string, token: string) { + await mkdirp(path.join(this.path, 'users', na_id)); + + debug('write', path.join('users', na_id, 'session-' + client_id)); + await fs.writeFile(path.join(this.path, 'users', na_id, 'session-' + client_id), token, 'utf-8'); + } + + async getSessionItem(na_id: string, session_id: string, key: string) { + await mkdirp(path.join(this.path, 'sessions', na_id, session_id)); + + try { + debug('read', path.join('sessions', na_id, session_id, key)); + return await fs.readFile(path.join(this.path, 'sessions', na_id, session_id, key), 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } + } + + async setSessionItem(na_id: string, session_id: string, key: string, value: string) { + await mkdirp(path.join(this.path, 'sessions', na_id, session_id)); + + debug('write', path.join('sessions', na_id, session_id, key)); + await fs.writeFile(path.join(this.path, 'sessions', na_id, session_id, key), value, 'utf-8'); + } + + static async create(path: string | URL) { + if (path instanceof URL) path = fileURLToPath(path); + + await mkdirp(path); + + return new LocalStorageProvider(path); + } +} diff --git a/src/client/users.ts b/src/client/users.ts new file mode 100644 index 0000000..c105274 --- /dev/null +++ b/src/client/users.ts @@ -0,0 +1,43 @@ +import { NintendoAccountSession, Storage } from './storage/index.js'; + +interface UserConstructor { + createWithUserStore(users: Users, id: string, ...args: A): Promise; +} + +interface User { + expires_at: number; +} + +export default class Users { + private users = new Map, Map>(); + private user_promise = new Map, Map>>(); + + constructor( + readonly storage: Storage, + readonly znc_proxy_url?: string, + ) {} + + async get(type: UserConstructor, id: string, ...args: A): Promise { + const existing = this.users.get(type)?.get(id); + + if (existing && existing.expires_at >= Date.now()) { + return existing as T; + } + + const promises = this.user_promise.get(type) ?? new Map>(); + + const promise = promises.get(id) ?? type.createWithUserStore(this, id, ...args).then(client => { + const users = this.users.get(type) ?? new Map(); + users.set(id, client); + return client; + }).finally(() => { + promises.delete(id); + if (!promises.size) this.user_promise.delete(type); + }); + + this.user_promise.set(type, promises); + promises.set(id, promise); + + return promise as Promise; + } +} diff --git a/src/exports/coral.ts b/src/exports/coral.ts index 923534b..51657d2 100644 --- a/src/exports/coral.ts +++ b/src/exports/coral.ts @@ -30,3 +30,7 @@ export { AndroidZncaFResponse, AndroidZncaFError, } from '../api/f.js'; + +export { + default as Coral, +} from '../client/coral.js'; diff --git a/src/exports/index.ts b/src/exports/index.ts index 3c4c994..6e120fd 100644 --- a/src/exports/index.ts +++ b/src/exports/index.ts @@ -3,3 +3,14 @@ export { ErrorResponse, ResponseSymbol } from '../api/util.js'; export { addUserAgent } from '../util/useragent.js'; export { version } from '../util/product.js'; + +export { + default as Users, +} from '../client/users.js'; +export { + Storage, + StorageProvider, +} from '../client/storage/index.js'; +export { + LocalStorageProvider, +} from '../client/storage/local.js'; diff --git a/src/exports/splatnet3.ts b/src/exports/splatnet3.ts index 148fbb0..9f28950 100644 --- a/src/exports/splatnet3.ts +++ b/src/exports/splatnet3.ts @@ -12,3 +12,7 @@ export { } from '../api/splatnet3.js'; // export * from '../api/splatnet3-types.js'; + +export { + default as SplatNet3, +} from '../client/splatnet3.js'; diff --git a/src/util/jwt.ts b/src/util/jwt.ts index 2399fb8..c912991 100644 --- a/src/util/jwt.ts +++ b/src/util/jwt.ts @@ -33,7 +33,7 @@ export interface JwtPayload { /** Issuer */ iss: string; /** Token ID */ - jti: string; + jti: string | number; /** Subject */ sub: string | number; /** Token type */