diff --git a/src/api/coral.ts b/src/api/coral.ts index 537ef00..ee40d07 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -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 { +export class CoralErrorResponse extends ErrorResponse 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 = { diff --git a/src/api/na.ts b/src/api/na.ts index 8231a5f..0f38dd3 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -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 {} +export class NintendoAccountAuthErrorResponse extends ErrorResponse 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 {} diff --git a/src/app/main/index.ts b/src/app/main/index.ts index b4e70a2..93ea2e3 100644 --- a/src/app/main/index.ts +++ b/src/app/main/index.ts @@ -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; + readonly users: Users>; 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, }); diff --git a/src/app/main/ipc.ts b/src/app/main/ipc.ts index 22c0139..afd314b 100644 --- a/src/app/main/ipc.ts +++ b/src/app/main/ipc.ts @@ -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())); diff --git a/src/app/main/menu.ts b/src/app/main/menu.ts index 683b47e..939eeb5 100644 --- a/src/app/main/menu.ts +++ b/src/app/main/menu.ts @@ -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); } diff --git a/src/app/main/monitor.ts b/src/app/main/monitor.ts index f156592..11f3ee3 100644 --- a/src/app/main/monitor.ts +++ b/src/app/main/monitor.ts @@ -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 | NodeJS.ErrnoException ): Promise { - 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, }); diff --git a/src/app/main/na-auth.ts b/src/app/main/na-auth.ts index d27f857..1275550 100644 --- a/src/app/main/na-auth.ts +++ b/src/app/main/na-auth.ts @@ -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); } diff --git a/src/app/main/util.ts b/src/app/main/util.ts index f71ad16..790a3ad 100644 --- a/src/app/main/util.ts +++ b/src/app/main/util.ts @@ -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); +} diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 96fd1a6..f5af0e1 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -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 { diff --git a/src/cli/nso/webservicetoken.ts b/src/cli/nso/webservicetoken.ts index ccc2a66..3488f5e 100644 --- a/src/cli/nso/webservicetoken.ts +++ b/src/cli/nso/webservicetoken.ts @@ -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) { } 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); diff --git a/src/cli/presence-server.ts b/src/cli/presence-server.ts index 206d89b..af35cac 100644 --- a/src/cli/presence-server.ts +++ b/src/cli/presence-server.ts @@ -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, + readonly coral_users: Users>, readonly splatnet3_users: Users | 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, Friend, string] | null = null; for (const user_naid of this.user_ids) { const token = await this.storage.getItem('NintendoAccountToken.' + user_naid); diff --git a/src/common/auth/coral.ts b/src/common/auth/coral.ts index 070a5da..f434fb0 100644 --- a/src/common/auth/coral.ts +++ b/src/common/auth/coral.ts @@ -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(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; + } } diff --git a/src/common/auth/moon.ts b/src/common/auth/moon.ts index 378a547..7d45423 100644 --- a/src/common/auth/moon.ts +++ b/src/common/auth/moon.ts @@ -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(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; + } } diff --git a/src/common/auth/na.ts b/src/common/auth/na.ts index 34ef147..aa50958 100644 --- a/src/common/auth/na.ts +++ b/src/common/auth/na.ts @@ -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(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.'); + } +} diff --git a/src/common/auth/nooklink.ts b/src/common/auth/nooklink.ts index 70f2f22..c3cf71f 100644 --- a/src/common/auth/nooklink.ts +++ b/src/common/auth/nooklink.ts @@ -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(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(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'); diff --git a/src/common/auth/splatnet2.ts b/src/common/auth/splatnet2.ts index a1a3892..fefd8d9 100644 --- a/src/common/auth/splatnet2.ts +++ b/src/common/auth/splatnet2.ts @@ -34,14 +34,6 @@ export async function getIksmToken( throw new Error('No valid iksm_session cookie'); } - if (ratelimit) { - const [jwt, sig] = Jwt.decode(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(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'); diff --git a/src/common/auth/splatnet3.ts b/src/common/auth/splatnet3.ts index add70e0..4739391 100644 --- a/src/common/auth/splatnet3.ts +++ b/src/common/auth/splatnet3.ts @@ -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(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(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'); diff --git a/src/common/auth/util.ts b/src/common/auth/util.ts index 67dc284..e462481 100644 --- a/src/common/auth/util.ts +++ b/src/common/auth/util.ts @@ -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.'); + } } diff --git a/src/util/errors.ts b/src/util/errors.ts index 7e7a4e6..d39b4c9 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -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,