Add coral/SplatNet 3 client

https://github.com/samuelthomas2774/nxapi/issues/42
This commit is contained in:
Samuel Elliott 2022-12-26 20:51:30 +00:00
parent 11077b02ee
commit 788083b7f6
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
13 changed files with 965 additions and 5 deletions

View File

@ -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<CoralAuthData> {
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');

View File

@ -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? */

View File

@ -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<ParentArguments>) {
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<ParentArguments>) {
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<NintendoAccountSessionTokenJwtPayload>(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<NintendoAccountSessionTokenJwtPayload>(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<NintendoAccountSessionTokenJwtPayload>(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) + '...');
}
}
});
}

294
src/client/coral.ts Normal file
View File

@ -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<string, Promise<void>>();
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<WebServices>) => void) | null = null;
constructor(
public api: CoralApi,
public data: CoralAuthData,
public announcements: Result<Announcements>,
public friends: Result<Friends>,
public webservices: Result<WebServices>,
public active_event: Result<GetActiveEventResult>,
) {}
private async update(key: keyof Coral['updated'], callback: () => Promise<void>, 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<SavedToken>(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<SavedToken>, 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<SavedToken>, 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<SavedToken>, 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<SavedToken>, 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<SavedToken>(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<SavedToken>, 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<SavedToken>, coral: CoralApi,
renew_token_data: {auth_data: SavedToken}, ratelimit = true
) {
// if (ratelimit) {
// const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(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<SavedToken>(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);
}

106
src/client/na.ts Normal file
View File

@ -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<string, Promise<void>>();
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<void>) | 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<void>, 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<unknown>, 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;
}
}

212
src/client/splatnet3.ts Normal file
View File

@ -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<FriendListResult> | null = null;
// schedules: PersistedQueryResult<StageScheduleResult> | null = null;
promise = new Map<string, Promise<void>>();
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<ConfigureAnalyticsResult> | null = null,
public current_fest: PersistedQueryResult<CurrentFestResult> | null = null,
public home: PersistedQueryResult<HomeResult>,
) {
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<void>, 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<HomeResult> {
await this.update('home', async () => {
this.home = await this.api.getHome();
}, this.update_interval);
return this.home.data;
}
async getFriends(): Promise<Friend_friendList[]> {
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<SavedCoralToken>(coral.data.user.id, ZNCA_CLIENT_ID);
if (!session) throw new Error('Unknown user');
return this.createWithSession(session, coral);
}
static async createWithSession(session: NintendoAccountSession<SavedCoralToken>, coral: Coral) {
const cached_auth_data = await session.getItem<SavedToken>('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<SavedCoralToken>, coral: Coral) {
//
const {splatnet, data} = await SplatNet3Api.createWithCoral(coral.api, coral.data.user);
await session.setItem<SavedToken>('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<SavedCoralToken>(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<SavedCoralToken>, 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<SavedCoralToken>, 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<SavedCoralToken>, splatnet: SplatNet3Api,
renew_token_data: {coral: Coral; auth_data: SavedToken; znc_proxy_url?: string}, ratelimit = true
) {
// if (ratelimit) {
// const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(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<SavedToken>('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<SavedToken>('BulletToken', auth_data);
renew_token_data.auth_data = auth_data;
}

159
src/client/storage/index.ts Normal file
View File

@ -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<string | null>;
getSessionItem(na_id: string, session_id: string, key: string): Promise<string | null>;
setSessionItem(na_id: string, session_id: string, key: string, value: string): Promise<void>;
}
type PromiseType<T extends Promise<any>> = T extends Promise<infer R> ? R : never;
type ConstructorType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : never;
export class Storage<T extends StorageProvider = StorageProvider> {
constructor(readonly provider: T) {}
async getSessionToken(na_id: string, client_id: string) {
return this.provider.getSessionToken(na_id, client_id);
}
async getSession<T>(na_id: string, client_id: string): Promise<NintendoAccountSession<T> | null> {
const token = await this.provider.getSessionToken(na_id, client_id);
if (!token) return null;
const session = new NintendoAccountSession<T>(this, token, na_id, client_id);
return session;
}
async getJsonSessionItem<T>(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<T>(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<StorageProvider>;
} | {
new (args: any): StorageProvider;
},
R extends
C extends { create(args: any): Promise<StorageProvider>; } ?
Promise<Storage<PromiseType<ReturnType<C['create']> extends Promise<any> ? ReturnType<C['create']> : never>>> :
C extends { create(args: any): StorageProvider; } ?
Storage<ReturnType<C['create']> extends StorageProvider ? ReturnType<C['create']> : never> :
C extends new (args: any) => StorageProvider ? Storage<ConstructorType<C>> :
never,
>(
constructor: C,
...args:
C extends { create(args: any): any; } ? Parameters<C['create']> :
C extends new (args: any) => any ? ConstructorParameters<C> :
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<NintendoAccountSessionTokenJwtPayload>;
// private readonly jwt_sig: Buffer;
constructor(
readonly storage: Storage,
readonly token: string,
na_id?: string,
client_id?: string,
) {
const [jwt, jwt_sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(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<T>(key: string) {
return this.storage.getJsonSessionItem<T>(this.na_id, this.session_id, key);
}
async setItem<T>(key: string, data: T) {
return this.storage.setJsonSessionItem(this.na_id, this.session_id, key, data);
}
async getNintendoAccountToken() {
return this.storage.getJsonSessionItem<SavedNaToken>(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<T>(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<number[]>(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);
}
}

View File

@ -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);
}
}

43
src/client/users.ts Normal file
View File

@ -0,0 +1,43 @@
import { NintendoAccountSession, Storage } from './storage/index.js';
interface UserConstructor<T extends User, A extends any[] = unknown[]> {
createWithUserStore(users: Users, id: string, ...args: A): Promise<T>;
}
interface User {
expires_at: number;
}
export default class Users {
private users = new Map<UserConstructor<User>, Map<string, User>>();
private user_promise = new Map<UserConstructor<User>, Map<string, Promise<User>>>();
constructor(
readonly storage: Storage,
readonly znc_proxy_url?: string,
) {}
async get<T extends User, A extends any[]>(type: UserConstructor<T, A>, id: string, ...args: A): Promise<T> {
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<string, Promise<T>>();
const promise = promises.get(id) ?? type.createWithUserStore(this, id, ...args).then(client => {
const users = this.users.get(type) ?? new Map<string, T>();
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<T>;
}
}

View File

@ -30,3 +30,7 @@ export {
AndroidZncaFResponse,
AndroidZncaFError,
} from '../api/f.js';
export {
default as Coral,
} from '../client/coral.js';

View File

@ -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';

View File

@ -12,3 +12,7 @@ export {
} from '../api/splatnet3.js';
// export * from '../api/splatnet3-types.js';
export {
default as SplatNet3,
} from '../client/splatnet3.js';

View File

@ -33,7 +33,7 @@ export interface JwtPayload {
/** Issuer */
iss: string;
/** Token ID */
jti: string;
jti: string | number;
/** Subject */
sub: string | number;
/** Token type */