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
19 changed files with 446 additions and 176 deletions

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.');
}
}