Improve error messages, record error history for use limits

This commit is contained in:
Samuel Elliott 2023-07-05 00:48:14 +01:00
parent 826ee51a68
commit 3e1bd2621a
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
19 changed files with 446 additions and 176 deletions

View File

@ -9,6 +9,7 @@ import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParamete
import { f, FResult, HashMethod } from './f.js';
import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { ErrorResponse, ResponseSymbol } from './util.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
const debug = createDebug('nxapi:api:coral');
@ -474,10 +475,21 @@ export default class CoralApi implements CoralApiInterface {
}
}
export class CoralErrorResponse extends ErrorResponse<CoralError> {
export class CoralErrorResponse extends ErrorResponse<CoralError> implements HasErrorDescription {
get status(): CoralStatus | null {
return this.data?.status ?? null;
}
get [ErrorDescriptionSymbol]() {
if (this.status === CoralStatus.NSA_NOT_LINKED) {
return new ErrorDescription('coral.nsa_not_linked', 'Your Nintendo Account is not linked to a Network Service Account (Nintendo Switch user).\n\nMake sure you are using the Nintendo Account linked to your Nintendo Switch console.');
}
if (this.status === CoralStatus.UPGRADE_REQUIRED) {
return new ErrorDescription('coral.upgrade_required', 'The Coral (Nintendo Switch Online app) version used by nxapi is no longer supported by the Coral API.\n\nTry restarting nxapi and make sure nxapi is up to date.');
}
return null;
}
}
const na_client_settings = {

View File

@ -4,6 +4,7 @@ import { defineResponse, ErrorResponse, HasResponse } from './util.js';
import createDebug from '../util/debug.js';
import { JwtPayload } from '../util/jwt.js';
import { timeoutSignal } from '../util/misc.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
const debug = createDebug('nxapi:api:na');
@ -408,6 +409,14 @@ export interface NintendoAccountError {
type: string;
}
export class NintendoAccountAuthErrorResponse extends ErrorResponse<NintendoAccountAuthError> {}
export class NintendoAccountAuthErrorResponse extends ErrorResponse<NintendoAccountAuthError> implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
if (this.data?.error === 'invalid_grant') {
return new ErrorDescription('na.invalid_grant', 'Your Nintendo Account session token has expired or was revoked.\n\nYou need to sign in again.');
}
return null;
}
}
export class NintendoAccountErrorResponse extends ErrorResponse<NintendoAccountError> {}

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions } from './electron.js';
import { app, BrowserWindow, ipcMain, LoginItemSettingsOptions } from './electron.js';
import process from 'node:process';
import * as path from 'node:path';
import { EventEmitter } from 'node:events';
@ -8,7 +8,7 @@ import { handleOpenWebServiceUri } from './webservices.js';
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
import { createModalWindow, createWindow } from './windows.js';
import { setupIpc } from './ipc.js';
import { askUserForUri } from './util.js';
import { askUserForUri, showErrorDialog } from './util.js';
import { setAppInstance } from './app-menu.js';
import { handleAuthUri } from './na-auth.js';
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
@ -20,6 +20,7 @@ import createDebug from '../../util/debug.js';
import { dev, dir, git, release, version } from '../../util/product.js';
import { addUserAgent } from '../../util/useragent.js';
import { initStorage, paths } from '../../util/storage.js';
import { CoralApiInterface } from '../../api/coral.js';
const debug = createDebug('app:main');
@ -271,7 +272,7 @@ interface SavedMonitorState {
}
export class Store extends EventEmitter {
readonly users: Users<CoralUser>;
readonly users: Users<CoralUser<CoralApiInterface>>;
constructor(
readonly app: App,
@ -423,10 +424,9 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for user %s', user.id, err);
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for user ' + user.id,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});
@ -452,10 +452,10 @@ export class Store extends EventEmitter {
} catch (err) {
debug('Error restoring monitor for presence URL %s', state.discord_presence.source.url, err);
const {response} = await dialog.showMessageBox({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' + state.discord_presence.source.url,
detail: err instanceof Error ? err.stack ?? err.message : err as any,
type: 'error',
const {response} = await showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' +
state.discord_presence.source.url,
error: err,
buttons: ['OK', 'Retry'],
defaultId: 1,
});

View File

@ -16,6 +16,7 @@ import { defaultTitle } from '../../discord/titles.js';
import type { FriendProps } from '../browser/friend/index.js';
import type { DiscordSetupProps } from '../browser/discord/index.js';
import type { AddFriendProps } from '../browser/add-friend/index.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
const debug = createDebug('app:main:ipc');
@ -59,7 +60,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
ipcMain.handle('nxapi:coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
ipcMain.handle('nxapi:coral:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
store.users.get(token).then(u => openWebService(store, token, u.nso, u.data, webservice, qs)
.catch(err => err instanceof WebServiceValidationError ?
.catch(err => err instanceof WebServiceValidationError || err instanceof MembershipRequiredError ?
handleOpenWebServiceError(err, webservice, qs, u.data, BrowserWindow.fromWebContents(e.sender)!) :
null)));
ipcMain.handle('nxapi:coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));

View File

@ -13,6 +13,7 @@ import { SavedMoonToken } from '../../common/auth/moon.js';
import { CachedWebServicesList } from '../../common/users.js';
import createDebug from '../../util/debug.js';
import { dev, dir } from '../../util/product.js';
import { MembershipRequiredError } from '../../common/auth/util.js';
const debug = createDebug('app:main:menu');
@ -167,7 +168,7 @@ export default class MenuApp {
try {
await openWebService(this.app.store, token, coral, data, webservice);
} catch (err) {
if (!(err instanceof WebServiceValidationError)) return;
if (!(err instanceof WebServiceValidationError) && !(err instanceof MembershipRequiredError)) return;
handleOpenWebServiceError(err, webservice, undefined, data);
}

View File

@ -1,6 +1,6 @@
import { dialog, Notification } from './electron.js';
import { Notification } from './electron.js';
import { App } from './index.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { showErrorDialog, tryGetNativeImageFromUrl } from './util.js';
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.js';
import { CurrentUser, Friend, Game, CoralError } from '../../api/coral-types.js';
import { ErrorResponse } from '../../api/util.js';
@ -51,10 +51,9 @@ export class PresenceMonitorManager {
this.app.store.emit('update-discord-user', client?.user ?? null);
};
i.discord.onMonitorError = async (monitor, instance, err) => {
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: err.name + ' in external monitor ' + monitor.name,
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry', 'Stop'],
defaultId: 0,
});
@ -348,10 +347,9 @@ export class PresenceMonitorManager {
monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor,
err: ErrorResponse<CoralError> | NodeJS.ErrnoException
): Promise<LoopResult> {
const {response} = await dialog.showMessageBox({
const {response} = await showErrorDialog({
message: err.name + ' updating presence monitor',
detail: err.stack ?? err.message,
type: 'error',
error: err,
buttons: ['OK', 'Retry'],
defaultId: 0,
});

View File

@ -5,15 +5,15 @@ import { protocol_registration_options } from './index.js';
import { createModalWindow } from './windows.js';
import { tryGetNativeImageFromUrl } from './util.js';
import { WindowType } from '../common/types.js';
import { NintendoAccountAuthError, NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
import { NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
import { ErrorResponse } from '../../api/util.js';
import { getToken } from '../../common/auth/coral.js';
import { getPctlToken } from '../../common/auth/moon.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { ZNCA_API_USE_URL } from '../../common/constants.js';
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
const debug = createDebug('app:main:na-auth');
@ -329,7 +329,10 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br
return {nso, data};
} catch (err) {
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateCoralSessionToken(storage, authenticator, code, true);
}
@ -490,7 +493,10 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b
return {moon, data};
} catch (err) {
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
if (
(err instanceof InvalidNintendoAccountTokenError) ||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
) {
// The session token has expired/was revoked
return authenticateMoonSessionToken(storage, authenticator, code, true);
}

View File

@ -1,10 +1,11 @@
import { BrowserWindow, Menu, MenuItem, nativeImage } from './electron.js';
import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage } from './electron.js';
import path from 'node:path';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import { dir } from '../../util/product.js';
import { Store } from './index.js';
import { App, Store } from './index.js';
import { SavedToken } from '../../common/auth/coral.js';
import { ErrorDescription } from '../../util/errors.js';
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
@ -65,3 +66,24 @@ export async function askUserForUri(store: Store, uri: string, prompt: string):
return selected_user;
}
interface ErrorBoxOptions extends MessageBoxOptions {
error: Error | unknown;
app?: App;
window?: BrowserWindow;
}
export function showErrorDialog(options: ErrorBoxOptions) {
const {error, app, window, ...message_box_options} = options;
const detail = ErrorDescription.getErrorDescription(error);
message_box_options.detail = message_box_options.detail ?
detail + '\n\n' + message_box_options.detail :
detail;
if (!message_box_options.type) message_box_options.type = 'error';
return window ?
dialog.showMessageBox(window, message_box_options) :
dialog.showMessageBox(message_box_options);
}

View File

@ -7,13 +7,13 @@ import * as util from 'node:util';
import fetch from 'node-fetch';
import { Store } from './index.js';
import { createWebServiceWindow } from './windows.js';
import { askUserForUri } from './util.js';
import { askUserForUri, showErrorDialog } from './util.js';
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
import createDebug from '../../util/debug.js';
import { CoralApiInterface, CoralAuthData } from '../../api/coral.js';
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
import { NintendoAccountUser } from '../../api/na.js';
import { WebService, WebServiceToken } from '../../api/coral-types.js';
import { SavedToken } from '../../common/auth/coral.js';
import { checkMembershipActive } from '../../common/auth/util.js';
const debug = createDebug('app:main:webservices');
@ -41,12 +41,7 @@ export default async function openWebService(
}
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true') {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new WebServiceValidationError('Nintendo Switch Online membership required');
}
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
const user_title_prefix = '[' + data.user.nickname +
(data.nsoAccount.user.name !== data.user.nickname ? '/' + data.nsoAccount.user.name : '') + '] ';
@ -202,19 +197,13 @@ export async function handleOpenWebServiceError(
user_coral_id: auth_data?.nsoAccount.user.id,
};
const options: MessageBoxOptions = {
type: 'error',
return showErrorDialog({
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
detail: (err instanceof Error ? err.stack ?? err.message : err) +
'\n\n' + util.inspect(data, {compact: true}),
error: err,
detail: util.inspect(data, {compact: true}),
buttons,
};
const result = window ?
await dialog.showMessageBox(window, options) :
await dialog.showMessageBox(options);
return result;
window,
});
}
export interface WebServiceData {

View File

@ -3,6 +3,7 @@ import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getToken, Login } from '../../common/auth/coral.js';
import { checkMembershipActive } from '../../common/auth/util.js';
const debug = createDebug('cli:nso:webservicetoken');
@ -54,12 +55,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true') {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new Error('Nintendo Switch Online membership required');
}
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
const webserviceToken = await nso.getWebServiceToken(webservice.id);

View File

@ -22,6 +22,7 @@ import { EventStreamResponse, HttpServer, ResponseError } from './util/http-serv
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
import { getTitleIdFromEcUrl } from '../util/misc.js';
import { getSettingForCoopRule, getSettingForVsMode } from '../discord/monitor/splatoon3.js';
import { CoralApiInterface } from '../api/coral.js';
const debug = createDebug('cli:presence-server');
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
@ -524,7 +525,7 @@ class Server extends HttpServer {
constructor(
readonly storage: persist.LocalStorage,
readonly coral_users: Users<CoralUser>,
readonly coral_users: Users<CoralUser<CoralApiInterface>>,
readonly splatnet3_users: Users<SplatNet3User> | null,
readonly user_ids: string[],
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
@ -736,7 +737,7 @@ class Server extends HttpServer {
const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1';
let match: [CoralUser, Friend, string] | null = null;
let match: [CoralUser<CoralApiInterface>, Friend, string] | null = null;
for (const user_naid of this.user_ids) {
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);

View File

@ -66,29 +66,35 @@ export async function getToken(
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
const attempt = await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
console.warn('Authenticating to Nintendo Switch Online app');
const {nso, data} = proxy_url ?
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
await createWithSessionToken(storage, token, ratelimit);
try {
const {nso, data} = proxy_url ?
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
await createWithSessionToken(storage, token, ratelimit);
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
}
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
existingToken[Login] = true;
return {nso, data: existingToken};
} catch (err) {
await attempt.recordError(err);
throw err;
}
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
existingToken[Login] = true;
return {nso, data: existingToken};
}
debug('Using existing token');
@ -130,23 +136,30 @@ async function renewToken(
storage: persist.LocalStorage, na_session_token: string, nso: CoralApi,
renew_token_data: {existingToken: SavedToken}, ratelimit = true
) {
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(na_session_token);
await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
attempt = await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
}
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
try {
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
debug('Reauthenticating to coral');
debug('Reauthenticating to coral');
const data = await nso.renewTokenWithNintendoAccountToken(na_token.token, renew_token_data.existingToken.user);
const data = await nso.renewTokenWithNintendoAccountToken(na_token.token, renew_token_data.existingToken.user);
const existingToken: SavedToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
const existingToken: SavedToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await storage.setItem('NsoToken.' + na_session_token, existingToken);
renew_token_data.existingToken = existingToken;
await storage.setItem('NsoToken.' + na_session_token, existingToken);
renew_token_data.existingToken = existingToken;
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}

View File

@ -43,24 +43,31 @@ export async function getPctlToken(storage: persist.LocalStorage, token: string,
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
const attempt = await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit,
[LIMIT_REQUESTS, LIMIT_PERIOD]);
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');
try {
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');
const {moon, data} = await MoonApi.createWithSessionToken(token);
const {moon, data} = await MoonApi.createWithSessionToken(token);
const existingToken: SavedMoonToken = {
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
const existingToken: SavedMoonToken = {
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
moon.onTokenExpired = createTokenExpiredHandler(storage, token, moon, {existingToken});
moon.onTokenExpired = createTokenExpiredHandler(storage, token, moon, {existingToken});
await storage.setItem('MoonToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
await storage.setItem('MoonToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
return {moon, data: existingToken};
return {moon, data: existingToken};
} catch (err) {
await attempt.recordError(err);
throw err;
}
}
debug('Using existing token');
@ -86,19 +93,26 @@ async function renewToken(
storage: persist.LocalStorage, token: string, moon: MoonApi,
renew_token_data: {existingToken: SavedMoonToken}, ratelimit = true
) {
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
attempt = await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
}
const data = await moon.renewToken(token);
try {
const data = await moon.renewToken(token);
const existingToken: SavedMoonToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
const existingToken: SavedMoonToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
await storage.setItem('MoonToken.' + token, existingToken);
renew_token_data.existingToken = existingToken;
await storage.setItem('MoonToken.' + token, existingToken);
renew_token_data.existingToken = existingToken;
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}

View File

@ -1,6 +1,7 @@
import * as persist from 'node-persist';
import { getNintendoAccountToken, NintendoAccountSessionTokenJwtPayload, NintendoAccountToken } from '../../api/na.js';
import { getNintendoAccountToken, NintendoAccountAuthError, NintendoAccountAuthErrorResponse, NintendoAccountSessionTokenJwtPayload, NintendoAccountToken } from '../../api/na.js';
import createDebug from '../../util/debug.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
import { Jwt } from '../../util/jwt.js';
import { checkUseLimit, LIMIT_REQUESTS, SHOULD_LIMIT_USE } from './util.js';
@ -14,22 +15,27 @@ export interface SavedNintendoAccountToken {
expires_at: number;
}
export interface SavedNintendoAccountTokenError {
data: NintendoAccountAuthError;
created_at: number;
}
export async function getNaToken(
storage: persist.LocalStorage, na_session_token: string, client_id: string, ratelimit = SHOULD_LIMIT_USE,
) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(na_session_token);
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
throw new Error('Invalid Nintendo Account session token issuer');
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token issuer');
}
if (jwt.payload.typ !== 'session_token') {
throw new Error('Invalid Nintendo Account session token type');
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token type');
}
if (jwt.payload.aud !== client_id) {
throw new Error('Invalid Nintendo Account session token audience');
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token audience');
}
if (jwt.payload.exp <= (Date.now() / 1000)) {
throw new Error('Nintendo Account session token expired');
throw new NintendoAccountSessionTokenExpiredError('Nintendo Account session token expired');
}
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
@ -38,24 +44,67 @@ export async function getNaToken(
await storage.getItem('NaToken.' + na_session_token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
await checkUseLimit(storage, 'na', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
const error: SavedNintendoAccountTokenError | undefined =
await storage.getItem('NaTokenError.' + na_session_token);
if (error) {
throw new NintendoAccountSessionTokenInvalidError('Invalid Nintendo Account session token', error);
}
const attempt = await checkUseLimit(storage, 'na', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
console.warn('Authenticating to Nintendo Accounts');
debug('Authenticating with session token');
const token = await getNintendoAccountToken(na_session_token, client_id);
try {
const token = await getNintendoAccountToken(na_session_token, client_id);
const existingToken: SavedNintendoAccountToken = {
token,
expires_at: Date.now() + (token.expires_in * 1000),
};
const existingToken: SavedNintendoAccountToken = {
token,
expires_at: Date.now() + (token.expires_in * 1000),
};
await storage.setItem('NaToken.' + na_session_token, existingToken);
await storage.setItem('NaToken.' + na_session_token, existingToken);
return existingToken;
return existingToken;
} catch (err) {
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
const data: SavedNintendoAccountTokenError = {
data: err.data,
created_at: Date.now(),
};
await storage.setItem('NaTokenError.' + na_session_token, data);
}
await attempt.recordError(err);
throw err;
}
}
debug('Using existing token');
return existingToken;
}
export class InvalidNintendoAccountTokenError extends Error {}
export class NintendoAccountSessionTokenExpiredError extends InvalidNintendoAccountTokenError implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('na.session_token_expired', 'Your Nintendo Account session token has expired.\n\nYou need to sign in again.');
}
}
export class NintendoAccountSessionTokenInvalidError extends InvalidNintendoAccountTokenError implements HasErrorDescription {
constructor(
message: string,
readonly data: SavedNintendoAccountTokenError,
) {
super(message);
}
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('na.session_token_invalid_cached', 'Your Nintendo Account session token has expired or was revoked.\n\nYou need to sign in again.');
}
}

View File

@ -3,7 +3,7 @@ import { Response } from 'node-fetch';
import { getToken, Login } from './coral.js';
import NooklinkApi, { NooklinkAuthData, NooklinkUserApi, NooklinkUserAuthData } from '../../api/nooklink.js';
import { Users, WebServiceError } from '../../api/nooklink-types.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { checkMembershipActive, checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
@ -31,14 +31,6 @@ export async function getWebServiceToken(
const { default: { coral_gws_nooklink: config } } = await import('../remote-config.js');
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'nooklink', jwt.payload.sub);
}
console.warn('Authenticating to NookLink');
debug('Authenticating to NookLink');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@ -48,18 +40,35 @@ export async function getWebServiceToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedToken = await NooklinkApi.loginWithCoral(nso, data.user);
checkMembershipActive(data);
await storage.setItem('NookToken.' + token, existingToken);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'nooklink', jwt.payload.sub);
}
const nooklink = NooklinkApi.createWithSavedToken(existingToken);
try {
console.warn('Authenticating to NookLink');
debug('Authenticating to NookLink');
nooklink.onTokenExpired = createTokenExpiredHandler(storage, token, nooklink, {
existingToken,
znc_proxy_url: proxy_url,
});
const existingToken: SavedToken = await NooklinkApi.loginWithCoral(nso, data.user);
return {nooklink, data: existingToken};
await storage.setItem('NookToken.' + token, existingToken);
const nooklink = NooklinkApi.createWithSavedToken(existingToken);
nooklink.onTokenExpired = createTokenExpiredHandler(storage, token, nooklink, {
existingToken,
znc_proxy_url: proxy_url,
});
return {nooklink, data: existingToken};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing web service token');

View File

@ -34,14 +34,6 @@ export async function getIksmToken(
throw new Error('No valid iksm_session cookie');
}
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'splatnet2', jwt.payload.sub);
}
console.warn('Authenticating to SplatNet 2');
debug('Authenticating to SplatNet 2');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@ -51,18 +43,33 @@ export async function getIksmToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedIksmSessionToken = await SplatNet2Api.loginWithCoral(nso, data.user);
await storage.setItem('IksmToken.' + token, existingToken);
if (!iksm_sessions.has(existingToken.iksm_session)) {
iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'splatnet2', jwt.payload.sub);
}
return {
splatnet: SplatNet2Api.createWithSavedToken(existingToken),
data: existingToken,
};
try {
console.warn('Authenticating to SplatNet 2');
debug('Authenticating to SplatNet 2');
const existingToken: SavedIksmSessionToken = await SplatNet2Api.loginWithCoral(nso, data.user);
await storage.setItem('IksmToken.' + token, existingToken);
if (!iksm_sessions.has(existingToken.iksm_session)) {
iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]);
}
return {
splatnet: SplatNet2Api.createWithSavedToken(existingToken),
data: existingToken,
};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing token');

View File

@ -2,7 +2,7 @@ import persist from 'node-persist';
import { Response } from 'node-fetch';
import { getToken, Login, SavedToken } from './coral.js';
import SplatNet3Api, { SplatNet3AuthData, SplatNet3AuthErrorCode, SplatNet3AuthErrorResponse } from '../../api/splatnet3.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { checkMembershipActive, checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
@ -30,14 +30,6 @@ export async function getBulletToken(
const { default: { coral_gws_splatnet3: config } } = await import('../remote-config.js');
if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication');
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'splatnet3', jwt.payload.sub);
}
console.warn('Authenticating to SplatNet 3');
debug('Authenticating to SplatNet 3');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@ -47,17 +39,34 @@ export async function getBulletToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user);
checkMembershipActive(data);
await storage.setItem('BulletToken.' + token, existingToken);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'splatnet3', jwt.payload.sub);
}
const splatnet = SplatNet3Api.createWithSavedToken(existingToken);
try {
console.warn('Authenticating to SplatNet 3');
debug('Authenticating to SplatNet 3');
const renew_token_data = {existingToken, znc_proxy_url: proxy_url};
splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, renew_token_data);
splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, renew_token_data);
const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user);
return {splatnet, data: existingToken};
await storage.setItem('BulletToken.' + token, existingToken);
const splatnet = SplatNet3Api.createWithSavedToken(existingToken);
const renew_token_data = {existingToken, znc_proxy_url: proxy_url};
splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, renew_token_data);
splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, renew_token_data);
return {splatnet, data: existingToken};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing token');

View File

@ -1,5 +1,8 @@
import * as util from 'node:util';
import * as persist from 'node-persist';
import createDebug from '../../util/debug.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
import { SavedToken } from './coral.js';
const debug = createDebug('nxapi:auth:util');
@ -13,21 +16,114 @@ export const SHOULD_LIMIT_USE = !process.stdin.isTTY || !process.stderr.isTTY;
export const LIMIT_REQUESTS = 4;
export const LIMIT_PERIOD = 60 * 60 * 1000; // 60 minutes
type RateLimitAttempts = number[];
export async function checkUseLimit(
storage: persist.LocalStorage,
key: string, user: string,
/** Set to false to count the attempt but ignore the limit */ ratelimit = true,
/** [requests, period_ms] */ limits: [number, number] = [LIMIT_REQUESTS, LIMIT_PERIOD]
limits: [requests: number, period_ms: number] = [LIMIT_REQUESTS, LIMIT_PERIOD]
) {
let attempts: RateLimitAttempts = await storage.getItem('RateLimitAttempts-' + key + '.' + user) ?? [];
attempts = attempts.filter(a => a >= Date.now() - limits[1]);
let attempts: SavedRateLimitAttempt[] = await storage.getItem('RateLimitAttempts-' + key + '.' + user) ?? [];
if (typeof attempts[0] === 'number') attempts = attempts.map((a: SavedRateLimitAttempt | number) =>
typeof a === 'number' ? {time: a} : a);
attempts = attempts.filter(a => a.time >= Date.now() - limits[1]);
if (ratelimit && attempts.length >= limits[0]) {
throw new Error('Too many attempts to authenticate');
for (const attempt of attempts) decorateRateLimitAttempt(attempt);
throw new RateLimitError('Too many attempts to authenticate (' + key + ')', key, attempts);
}
attempts.unshift(Date.now());
const attempt = new RateLimitAttempt(storage, key, user);
debug('rl attempt', attempt, attempts.length, limits[0]);
attempts.unshift({time: attempt.time});
await storage.setItem('RateLimitAttempts-' + key + '.' + user, attempts);
return attempt;
}
class RateLimitAttempt {
constructor(
readonly storage: persist.LocalStorage,
readonly key: string, readonly user: string,
readonly time = Date.now(),
) {}
async recordError(err: Error | unknown) {
const error_description = ErrorDescription.getErrorDescription(err);
const error_description_data = err instanceof HasErrorDescription ? err[ErrorDescriptionSymbol] : null;
await this.recordErrorData(error_description, err, error_description_data);
}
async recordErrorData(error_description: string, data: unknown, error_description_data?: ErrorDescription | null) {
const key = 'RateLimitAttempts-' + this.key + '.' + this.user;
let attempts: SavedRateLimitAttempt[] = await this.storage.getItem(key) ?? [];
const attempt = attempts.find(a => a.time === this.time);
if (!attempt) return;
attempt.error_description = error_description;
attempt.error_description_data = error_description_data ?? undefined;
attempt.error_data = data;
await this.storage.setItem(key, attempts);
}
}
interface SavedRateLimitAttempt {
time: number;
error_description?: string;
error_description_data?: {
type: string;
message: string;
};
error_data?: unknown;
}
function decorateRateLimitAttempt(attempt: SavedRateLimitAttempt) {
Object.defineProperty(attempt, util.inspect.custom, {value: inspectRateLimitAttempt});
}
function inspectRateLimitAttempt(
this: SavedRateLimitAttempt,
depth: number, options: util.InspectOptionsStylized, inspect: typeof util.inspect,
) {
const time = options.stylize('RateLimitAttempt', 'special') + ' ' + new Date(this.time);
if (!this.error_description) {
return time + ' ' + options.stylize('[no error]', 'undefined');
} else if (depth < 0) {
return time + ' ' + options.stylize('[error hidden]', 'undefined');
} else {
return time + '\n' + this.error_description + ' ' + inspect(this.error_data, {
...options,
depth: options.depth ? options.depth - 1 : null,
});
}
}
export class RateLimitError extends Error implements HasErrorDescription {
constructor(
message: string,
readonly key: string,
readonly attempts: SavedRateLimitAttempt[],
) {
super(message);
}
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('auth.use_limit_exceeded', 'Too many attempts to authenticate.');
}
}
export function checkMembershipActive(data: SavedToken) {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new MembershipRequiredError('Nintendo Switch Online membership required');
}
export class MembershipRequiredError extends Error implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('auth.nso_membership_required', 'Nintendo Switch Online membership required.\n\nMake sure your account has an active Nintendo Switch Online membership. It may take up to two hours for your membership status to update.');
}
}

View File

@ -1,3 +1,4 @@
import * as util from 'node:util';
import { AbortError } from 'node-fetch';
import createDebug from './debug.js';
import Loop, { LoopResult } from './loop.js';
@ -6,6 +7,43 @@ import { ErrorResponse } from '../api/util.js';
const debug = createDebug('nxapi:util:errors');
export class ErrorDescription {
constructor(
readonly type: string,
readonly message: string,
) {}
static getErrorDescription(err: Error | unknown) {
if (err instanceof HasErrorDescription) {
const description = err[ErrorDescriptionSymbol];
if (description) {
return description.message +
(err instanceof Error ? '\n\n--\n\n' + (err.stack ?? err.message) : '');
}
}
if (err instanceof Error) {
return err.stack || err.message;
}
return util.inspect(err, {compact: true});
}
}
export const ErrorDescriptionSymbol = Symbol('ErrorDescription');
export abstract class HasErrorDescription {
abstract get [ErrorDescriptionSymbol](): ErrorDescription | null;
}
Object.defineProperty(HasErrorDescription, Symbol.hasInstance, {
configurable: true,
value: (instance: HasErrorDescription) => {
return instance && ErrorDescriptionSymbol in instance;
},
});
export const temporary_system_errors = {
'ETIMEDOUT': 'request timed out',
'ENOTFOUND': null,