mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-10-24 02:25:25 -05:00
Improve error messages, record error history for use limits
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user