diff --git a/docs/lib/coral.md b/docs/lib/coral.md index de1f6c3..d22adb3 100644 --- a/docs/lib/coral.md +++ b/docs/lib/coral.md @@ -54,8 +54,9 @@ import CoralApi, { CoralAuthData, PartialCoralAuthData } from 'nxapi/coral'; const coral: CoralApi; const auth_data: CoralAuthData; +const na_session_token: string; -const data = await coral.renewToken(auth_data); +const data = await coral.renewToken(na_session_token, auth_data.user); // data is a plain object of type PartialCoralAuthData const new_auth_data = Object.assign({}, auth_data, data); @@ -63,6 +64,34 @@ const new_auth_data = Object.assign({}, auth_data, data); // new_auth_data should be saved and reused ``` +#### `CoralApi.onTokenExpired` + +Function called when a `9404 Token expired` response is received from the API. + +This function should either call `CoralApi.getToken` to renew the token, then return the `PartialCoralAuthData` object, or call `CoralApi.renewToken`. + +```ts +import CoralApi, { CoralAuthData } from 'nxapi/coral'; +import { Response } from 'node-fetch'; + +const coral = CoralApi.createWithSavedToken(...); +let auth_data: CoralAuthData; +const na_session_token: string; + +coral.onTokenExpired = async (response: Response) => { + const data = await coral.getToken(na_session_token, auth_data.user); + // data is a plain object of type PartialCoralAuthData + + const new_auth_data = Object.assign({}, auth_data, data); + // new_auth_data is a plain object of type CoralAuthData + // new_auth_data should be saved and reused + + auth_data = new_auth_data; + + return data; +}; +``` + ### `ZncProxyApi` nxapi API proxy server client. Instances of this class are generally compatible with `CoralApi`; nxapi's command line interface and Electron apps internally use either depending on whether the API proxy is enabled. diff --git a/docs/lib/splatnet3.md b/docs/lib/splatnet3.md index ae448c1..d8317bc 100644 --- a/docs/lib/splatnet3.md +++ b/docs/lib/splatnet3.md @@ -56,6 +56,68 @@ const splatnet = SplatNet3Api.createWithCliTokenData(data); // splatnet instanceof SplatNet3Api ``` +#### `SplatNet3Api.onTokenExpired` + +Function called when a `401 Unauthorized` response is received from the API, meaning the token has expired. + +This function should either call `SplatNet3Api.loginWithWebServiceToken` or `SplatNet3Api.loginWithCoral` to renew the token, then return the `SplatNet3AuthData` object, or call `SplatNet3Api.renewTokenWithWebServiceToken` or `SplatNet3Api.renewTokenWithCoral`. An existing web service token should be used to avoid calling the imink/flapg API, and then fall back to issuing a new token. + +```ts +import { ErrorResponse } from 'nxapi'; +import CoralApi, { CoralAuthData } from 'nxapi/coral'; +import SplatNet3Api, { SplatNet3AuthData } from 'nxapi/splatnet3'; +import { Response } from 'node-fetch'; + +let splatnet3_auth_data: SplatNet3AuthData; +const splatnet = SplatNet3Api.createWithSavedToken(splatnet3_auth_data); + +splatnet.onTokenExpired = async (response: Response) => { + try { + // This should be cached - using stale data is fine, as only the user data is used + const coral_auth_data: CoralAuthData; + + const data = await SplatNet3Api.loginWithWebServiceToken(splatnet3_auth_data.webserviceToken, coral_auth_data.user); + // data is a plain object of type SplatNet3AuthData + // data should be saved and reused + + splatnet3_auth_data = data; + + return data; + } catch (err) { + // `401 Unauthorized` from `/api/bullet_tokens` means the web service token has expired (or is invalid) + if (err instanceof ErrorResponse && err.response.status === 401) { + const coral: CoralApi; + const coral_auth_data: CoralAuthData; + + const data = await SplatNet3Api.loginWithCoral(coral, coral_auth_data.user); + // data is a plain object of type SplatNet3AuthData + // data should be saved and reused + + splatnet3_auth_data = data; + + return data; + } + + throw err; + } +}; +``` + +#### `SplatNet3Api.onTokenShouldRenew` + +Function called when the `x-bullettoken-remaining` header received from the API is less than 300, meaning the token will expire in 5 minutes and should be renewed in the background. + +```ts +import SplatNet3Api, { SplatNet3AuthData } from 'nxapi/splatnet3'; +import { Response } from 'node-fetch'; + +const splatnet = SplatNet3Api.createWithSavedToken(...); + +splatnet.onTokenShouldRenew = async (remaining: number, response: Response) => { + // See SplatNet3Api.onTokenExpired for an example +}; +``` + ### API types `nxapi/splatnet3` exports all API types from [src/api/splatnet3-types.ts](../../src/api/splatnet3-types.ts). diff --git a/src/api/coral.ts b/src/api/coral.ts index ff75a35..d3a7f8d 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -224,6 +224,7 @@ export default class CoralApi { return await this.call('/v2/Game/GetWebServiceToken', req, false); } catch (err) { if (err instanceof ErrorResponse && err.data.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) { + debug('Error getting web service token, renewing token before retrying', err); // _renewToken will be awaited when calling getWebServiceToken this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).then(data => { if (data) this.setTokenWithSavedToken(data); diff --git a/src/api/util.ts b/src/api/util.ts index f306a3b..29534ba 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -2,6 +2,7 @@ import * as util from 'node:util'; import { Response as NodeFetchResponse } from 'node-fetch'; export const ResponseSymbol = Symbol('Response'); +const ErrorResponseSymbol = Symbol('IsErrorResponse'); export interface ResponseData { [ResponseSymbol]: R; @@ -24,6 +25,8 @@ export class ErrorResponse extends Error { ) { super(message); + Object.defineProperty(this, ErrorResponseSymbol, {enumerable: false, value: ErrorResponseSymbol}); + if (typeof body === 'string') { this.body = body; try { @@ -51,9 +54,6 @@ export class ErrorResponse extends Error { Object.defineProperty(ErrorResponse, Symbol.hasInstance, { configurable: true, value: (instance: ErrorResponse) => { - return instance instanceof Error && - 'response' in instance && - 'body' in instance && - 'data' in instance; + return instance && ErrorResponseSymbol in instance; }, }); diff --git a/src/common/auth/splatnet3.ts b/src/common/auth/splatnet3.ts index 9099d76..919e926 100644 --- a/src/common/auth/splatnet3.ts +++ b/src/common/auth/splatnet3.ts @@ -53,8 +53,10 @@ export async function getBulletToken( await storage.setItem('BulletToken.' + token, existingToken); const splatnet = SplatNet3Api.createWithSavedToken(existingToken); - splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, existingToken, proxy_url); - splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, existingToken, proxy_url); + + 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}; } @@ -64,45 +66,47 @@ export async function getBulletToken( const splatnet = SplatNet3Api.createWithSavedToken(existingToken); if (allow_fetch_token) { - splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, existingToken, proxy_url); - splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, existingToken, proxy_url); + 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}; } function createTokenExpiredHandler( - storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, existingToken: SavedBulletToken, - znc_proxy_url?: string + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, + data: {existingToken: SavedBulletToken; znc_proxy_url?: string} ) { return (response: Response) => { debug('Token expired, renewing'); - return renewToken(storage, token, splatnet, existingToken, znc_proxy_url); + return renewToken(storage, token, splatnet, data); }; } function createTokenShouldRenewHandler( - storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, existingToken: SavedBulletToken, - znc_proxy_url?: string + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, + data: {existingToken: SavedBulletToken; znc_proxy_url?: string} ) { return (remaining: number, response: Response) => { debug('Token will expire in %d seconds, renewing', remaining); - return renewToken(storage, token, splatnet, existingToken, znc_proxy_url); + return renewToken(storage, token, splatnet, data); }; } async function renewToken( - storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, previousToken: SavedBulletToken, - znc_proxy_url?: string + storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, + renew_token_data: {existingToken: SavedBulletToken; znc_proxy_url?: string} ) { try { const data: SavedToken | undefined = await storage.getItem('NsoToken.' + token); if (data) { const existingToken: SavedBulletToken = - await splatnet.renewTokenWithWebServiceToken(previousToken.webserviceToken, data.user); + await splatnet.renewTokenWithWebServiceToken(renew_token_data.existingToken.webserviceToken, data.user); await storage.setItem('BulletToken.' + token, existingToken); + renew_token_data.existingToken = existingToken; return; } else { @@ -117,7 +121,7 @@ async function renewToken( } } - const {nso, data} = await getToken(storage, token, znc_proxy_url); + const {nso, data} = await getToken(storage, token, renew_token_data.znc_proxy_url); if (data[Login]) { const announcements = await nso.getAnnouncements(); @@ -129,4 +133,5 @@ async function renewToken( const existingToken: SavedBulletToken = await splatnet.renewTokenWithCoral(nso, data.user); await storage.setItem('BulletToken.' + token, existingToken); + renew_token_data.existingToken = existingToken; }