diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index aade54a..0db7596 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,6 +1,6 @@ -import fetch from 'node-fetch'; +import fetch, { Response } from 'node-fetch'; import createDebug from 'debug'; -import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, ZncStatus, ZncSuccessResponse } from './znc-types.js'; +import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, ZncErrorResponse, ZncStatus, ZncSuccessResponse } from './znc-types.js'; import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { version } from '../util/product.js'; @@ -12,6 +12,11 @@ const debug = createDebug('nxapi:api:znc-proxy'); export default class ZncProxyApi implements ZncApi { static useragent: string | null = null; + // Not used by ZncProxyApi + onTokenExpired: ((data: ZncErrorResponse, res: Response) => Promise) | null = null; + /** @internal */ + _renewToken: Promise | null = null; + constructor( private url: string, // ZncApi uses the NSO token (valid for a few hours) @@ -139,6 +144,33 @@ export default class ZncProxyApi implements ZncApi { } } +export interface AuthToken { + user: string; + policy?: AuthPolicy; + created_at: number; +} +export interface AuthPolicy { + announcements?: boolean; + list_friends?: boolean; + list_friends_presence?: boolean; + friend?: boolean; + friend_presence?: boolean; + webservices?: boolean; + activeevent?: boolean; + current_user?: boolean; + current_user_presence?: boolean; + + friends?: string[]; + friends_presence?: string[]; +} + +export enum ZncPresenceEventStreamEvent { + FRIEND_ONLINE = '0', + FRIEND_OFFLINE = '1', + FRIEND_TITLE_CHANGE = '2', + FRIEND_TITLE_STATECHANGE = '3', +} + export type PresenceUrlResponse = Presence | {presence: Presence} | CurrentUser | {user: CurrentUser} | diff --git a/src/api/znc.ts b/src/api/znc.ts index 7546c3d..14a0ded 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -1,8 +1,8 @@ -import fetch from 'node-fetch'; +import fetch, { Response } from 'node-fetch'; import { v4 as uuidgen } from 'uuid'; import createDebug from 'debug'; import { f, FlapgIid } from './f.js'; -import { AccountLogin, AccountToken, Announcements, CurrentUser, CurrentUserPermissions, Event, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, ZncResponse, ZncStatus } from './znc-types.js'; +import { AccountLogin, AccountToken, Announcements, CurrentUser, CurrentUserPermissions, Event, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, ZncErrorResponse, ZncResponse, ZncStatus, ZncSuccessResponse } from './znc-types.js'; import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import { JwtPayload } from '../util/jwt.js'; @@ -20,12 +20,21 @@ export const ZNCA_CLIENT_ID = '71b963c1b7b6d119'; export default class ZncApi { static useragent: string | null = null; + onTokenExpired: ((data: ZncErrorResponse, res: Response) => Promise) | null = null; + /** @internal */ + _renewToken: Promise | null = null; + constructor( public token: string, public useragent: string | null = ZncApi.useragent ) {} - async fetch(url: string, method = 'GET', body?: string, headers?: object) { + async fetch( + url: string, method = 'GET', body?: string, headers?: object, + /** @internal */ _attempt = 0 + ): Promise> { + await this._renewToken; + const response = await fetch(ZNC_URL + url, { method: method, headers: Object.assign({ @@ -42,6 +51,14 @@ export default class ZncApi { const data = await response.json() as ZncResponse; + if (data.status === ZncStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) { + // _renewToken will be awaited when calling fetch + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).finally(() => { + this._renewToken = null; + }); + return this.fetch(url, method, body, headers, _attempt + 1); + } + if ('errorMessage' in data) { throw new ErrorResponse('[znc] ' + data.errorMessage, response, data); } diff --git a/src/app/main/webservices.ts b/src/app/main/webservices.ts index 152a48d..50751a3 100644 --- a/src/app/main/webservices.ts +++ b/src/app/main/webservices.ts @@ -216,9 +216,6 @@ export class WebServiceIpc { } async requestGameWebToken(event: IpcMainInvokeEvent): Promise { - // TODO: if the web service token has expired the NSO app token will probably have expired as well - // This needs to renew that token if necessary - const {nso, nsoAccount, webservice} = this.getWindowData(event.sender); debug('Web service %s, user %s, called requestGameWebToken', webservice.name, nsoAccount.user.name); diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index d17144d..9b2e389 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -10,6 +10,7 @@ import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; import { getToken, SavedToken } from '../../common/auth/nso.js'; import { NotificationManager, ZncNotifications } from '../../common/notify.js'; +import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js'; declare global { namespace Express { @@ -24,26 +25,6 @@ declare global { } } -export interface AuthToken { - user: string; - policy?: AuthPolicy; - created_at: number; -} -export interface AuthPolicy { - announcements?: boolean; - list_friends?: boolean; - list_friends_presence?: boolean; - friend?: boolean; - friend_presence?: boolean; - webservices?: boolean; - activeevent?: boolean; - current_user?: boolean; - current_user_presence?: boolean; - - friends?: string[]; - friends_presence?: string[]; -} - const debug = createDebug('cli:nso:http-server'); export const command = 'http-server'; @@ -682,13 +663,6 @@ export async function handler(argv: ArgumentsCamelCase) { } } -export enum ZncPresenceEventStreamEvent { - FRIEND_ONLINE = '0', - FRIEND_OFFLINE = '1', - FRIEND_TITLE_CHANGE = '2', - FRIEND_TITLE_STATECHANGE = '3', -} - class EventStreamNotificationManager extends NotificationManager { constructor( public req: express.Request, diff --git a/src/cli/nso/znc-proxy-tokens.ts b/src/cli/nso/znc-proxy-tokens.ts index 1d03f26..87aacce 100644 --- a/src/cli/nso/znc-proxy-tokens.ts +++ b/src/cli/nso/znc-proxy-tokens.ts @@ -3,7 +3,7 @@ import fetch from 'node-fetch'; import Table from '../util/table.js'; import type { Arguments as ParentArguments } from '../nso.js'; import { getToken } from '../../common/auth/nso.js'; -import { AuthPolicy, AuthToken } from './http-server.js'; +import { AuthPolicy, AuthToken } from '../../api/znc-proxy.js'; import { Argv } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; diff --git a/src/common/auth/nso.ts b/src/common/auth/nso.ts index 0b98c01..ab6a049 100644 --- a/src/common/auth/nso.ts +++ b/src/common/auth/nso.ts @@ -1,9 +1,10 @@ import createDebug from 'debug'; import * as persist from 'node-persist'; +import { Response } from 'node-fetch'; import { FlapgApiResponse, FResult } from '../../api/f.js'; import { NintendoAccountSessionTokenJwtPayload, NintendoAccountToken, NintendoAccountUser } from '../../api/na.js'; import { Jwt } from '../../util/jwt.js'; -import { AccountLogin } from '../../api/znc-types.js'; +import { AccountLogin, ZncErrorResponse } from '../../api/znc-types.js'; import ZncApi, { ZNCA_CLIENT_ID } from '../../api/znc.js'; import ZncProxyApi from '../../api/znc-proxy.js'; @@ -69,6 +70,8 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro expires_at: Date.now() + (data.credential.expiresIn * 1000), }; + nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, existingToken); + await storage.setItem('NsoToken.' + token, existingToken); await storage.setItem('NintendoAccountToken.' + data.user.id, token); @@ -78,10 +81,34 @@ export async function getToken(storage: persist.LocalStorage, token: string, pro debug('Using existing token'); await storage.setItem('NintendoAccountToken.' + existingToken.user.id, token); - return { - nso: proxy_url ? - new ZncProxyApi(proxy_url, token) : - new ZncApi(existingToken.credential.accessToken), - data: existingToken, + const nso = proxy_url ? + new ZncProxyApi(proxy_url, token) : + new ZncApi(existingToken.credential.accessToken); + + nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, existingToken); + + return {nso, data: existingToken}; +} + +function createTokenExpiredHandler( + storage: persist.LocalStorage, token: string, nso: ZncApi, existingToken: SavedToken +) { + return (data: ZncErrorResponse, response: Response) => { + debug('Token expired', existingToken.user.id, data); + return renewToken(storage, token, nso, existingToken); }; } + +async function renewToken( + storage: persist.LocalStorage, token: string, nso: ZncApi, previousToken: SavedToken +) { + const data = await nso.renewToken(token, previousToken.user); + + const existingToken: SavedToken = { + user: previousToken.user, + ...data, + expires_at: Date.now() + (data.credential.expiresIn * 1000), + }; + + await storage.setItem('NsoToken.' + token, existingToken); +} diff --git a/src/common/notify.ts b/src/common/notify.ts index 5e39521..5517974 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -169,22 +169,7 @@ export class ZncNotifications extends Loop { } async handleError(err: ErrorResponse | NodeJS.ErrnoException): Promise { - if (err && 'response' in err && err.data?.status === 9404) { - // Token expired - debug('Renewing token'); - - const data = await this.nso.renewToken(this.token, this.data.user); - - const existingToken: SavedToken = { - user: this.data.user, - ...data, - expires_at: Date.now() + (data.credential.expiresIn * 1000), - }; - - await this.storage.setItem('NsoToken.' + this.token, existingToken); - - return LoopResult.OK_SKIP_INTERVAL; - } else if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') { + if ('code' in err && (err as any).type === 'system' && err.code === 'ETIMEDOUT') { debug('Request timed out, waiting %ds before retrying', this.update_interval, err); return LoopResult.OK; diff --git a/tsconfig.json b/tsconfig.json index de01ac9..ea81891 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "lib": [ "es2020" ], + "stripInternal": true, "skipLibCheck": true, "allowJs": true },