mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-25 07:27:19 -05:00
Add coral/SplatNet 3 client
https://github.com/samuelthomas2774/nxapi/issues/42
This commit is contained in:
parent
11077b02ee
commit
788083b7f6
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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? */
|
||||
|
|
|
|||
|
|
@ -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
294
src/client/coral.ts
Normal 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
106
src/client/na.ts
Normal 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
212
src/client/splatnet3.ts
Normal 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
159
src/client/storage/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
60
src/client/storage/local.ts
Normal file
60
src/client/storage/local.ts
Normal 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
43
src/client/users.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,3 +30,7 @@ export {
|
|||
AndroidZncaFResponse,
|
||||
AndroidZncaFError,
|
||||
} from '../api/f.js';
|
||||
|
||||
export {
|
||||
default as Coral,
|
||||
} from '../client/coral.js';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -12,3 +12,7 @@ export {
|
|||
} from '../api/splatnet3.js';
|
||||
|
||||
// export * from '../api/splatnet3-types.js';
|
||||
|
||||
export {
|
||||
default as SplatNet3,
|
||||
} from '../client/splatnet3.js';
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export interface JwtPayload {
|
|||
/** Issuer */
|
||||
iss: string;
|
||||
/** Token ID */
|
||||
jti: string;
|
||||
jti: string | number;
|
||||
/** Subject */
|
||||
sub: string | number;
|
||||
/** Token type */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user