From 5cb66f4249f4bd2814d17869232ef88289760628 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Tue, 11 Oct 2022 12:00:57 +0100 Subject: [PATCH] Use cached web service token to renew SplatNet 3 tokens --- src/api/coral.ts | 19 ++++++--- src/api/splatnet3-types.ts | 2 +- src/api/splatnet3.ts | 76 ++++++++++++++++++++++++++---------- src/api/znc-proxy.ts | 9 ++++- src/common/auth/splatnet3.ts | 32 +++++++++++---- 5 files changed, 101 insertions(+), 37 deletions(-) diff --git a/src/api/coral.ts b/src/api/coral.ts index dfc6ad3..ff75a35 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -41,7 +41,7 @@ export interface ResultData { } export default class CoralApi { - onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise) | null = null; + onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise) | null = null; /** @internal */ _renewToken: Promise | null = null; @@ -85,7 +85,9 @@ export default class CoralApi { if (data.status === CoralStatus.TOKEN_EXPIRED && _autoRenewToken && !_attempt && this.onTokenExpired) { // _renewToken will be awaited when calling fetch - this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).finally(() => { + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => { + if (data) this.setTokenWithSavedToken(data); + }).finally(() => { this._renewToken = null; }); return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1); @@ -223,7 +225,9 @@ export default class CoralApi { } catch (err) { if (err instanceof ErrorResponse && err.data.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) { // _renewToken will be awaited when calling getWebServiceToken - this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).finally(() => { + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).then(data => { + if (data) this.setTokenWithSavedToken(data); + }).finally(() => { this._renewToken = null; }); return this.getWebServiceToken(id, _attempt + 1); @@ -261,12 +265,15 @@ export default class CoralApi { async renewToken(token: string, user: NintendoAccountUser) { const data = await this.getToken(token, user); - - this.token = data.credential.accessToken; - + this.setTokenWithSavedToken(data); return data; } + /** @private */ + setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) { + this.token = data.credential.accessToken; + } + static async createWithSessionToken(token: string, useragent = getAdditionalUserAgents()) { const data = await this.loginWithSessionToken(token, useragent); return {nso: this.createWithSavedToken(data, useragent), data}; diff --git a/src/api/splatnet3-types.ts b/src/api/splatnet3-types.ts index 39cc75e..61ab3ae 100644 --- a/src/api/splatnet3-types.ts +++ b/src/api/splatnet3-types.ts @@ -1046,7 +1046,7 @@ interface FooterSeasonMessage { } /** 994cf141e55213e6923426caf37a1934 VsHistoryDetailPagerRefetchQuery */ -export interface VsHistoryDetailPagerRefetchQueryResult { +export interface VsHistoryDetailPagerRefetchResult { vsHistoryDetail: { __typename: 'VsHistoryDetail'; nextHistoryDetail: { diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index a224086..1d72428 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -5,7 +5,7 @@ import { NintendoAccountUser } from './na.js'; import { defineResponse, ErrorResponse, HasResponse } from './util.js'; import CoralApi from './coral.js'; import { timeoutSignal } from '../util/misc.js'; -import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult, CoopHistoryResult, CoopHistoryDetailResult, FestRecordResult, FestRecordRefetchResult, DetailFestRecordDetailResult, DetailVotingStatusResult, DetailFestVotingStatusRefetchResult, VotesUpdateFestVoteResult } from './splatnet3-types.js'; +import { BankaraBattleHistoriesResult, BattleHistoryCurrentPlayerResult, BulletToken, CurrentFestResult, FriendListResult, GraphQLRequest, GraphQLResponse, HistoryRecordResult, HomeResult, LatestBattleHistoriesResult, PrivateBattleHistoriesResult, RegularBattleHistoriesResult, RequestId, SettingResult, StageScheduleResult, VsHistoryDetailResult, CoopHistoryResult, CoopHistoryDetailResult, FestRecordResult, FestRecordRefetchResult, DetailFestRecordDetailResult, DetailVotingStatusResult, DetailFestVotingStatusRefetchResult, VotesUpdateFestVoteResult, VsHistoryDetailPagerRefetchResult } from './splatnet3-types.js'; const debug = createDebug('nxapi:api:splatnet3'); @@ -21,10 +21,22 @@ const languages = [ const SPLATNET3_URL = SPLATNET3_WEBSERVICE_URL + '/api'; const SHOULD_RENEW_TOKEN_AT = 300; // 5 minutes in seconds +const TOKEN_EXPIRES_IN = 2 * 60 * 60 * 1000; // 2 hours in milliseconds + +const AUTH_ERROR_CODES = { + 204: 'USER_NOT_REGISTERED', + 400: 'ERROR_INVALID_PARAMETERS', + 401: 'ERROR_INVALID_GAME_WEB_TOKEN', + 403: 'ERROR_OBSOLETE_VERSION', + 429: 'ERROR_RATE_LIMIT', + 500: 'ERROR_SERVER', + 503: 'ERROR_SERVER_MAINTENANCE', + 599: 'ERROR_SERVER', +} as const; export default class SplatNet3Api { - onTokenShouldRenew: ((remaining: number, res: Response) => Promise) | null = null; - onTokenExpired: ((res: Response) => Promise) | null = null; + onTokenShouldRenew: ((remaining: number, res: Response) => Promise) | null = null; + onTokenExpired: ((res: Response) => Promise) | null = null; /** @internal */ _renewToken: Promise | null = null; @@ -67,7 +79,9 @@ export default class SplatNet3Api { if (response.status === 401 && !_attempt && this.onTokenExpired) { // _renewToken will be awaited when calling fetch - this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, response).finally(() => { + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, response).then(data => { + if (data) this.setTokenWithSavedToken(data); + }).finally(() => { this._renewToken = null; }); return this.fetch(url, method, body, headers, _log, _attempt + 1); @@ -81,7 +95,9 @@ export default class SplatNet3Api { if (remaining <= SHOULD_RENEW_TOKEN_AT && !_attempt && this.onTokenShouldRenew) { // _renewToken will be awaited when calling fetch - this._renewToken = this._renewToken ?? this.onTokenShouldRenew.call(null, remaining, response).finally(() => { + this._renewToken = this._renewToken ?? this.onTokenShouldRenew.call(null, remaining, response).then(data => { + if (data) this.setTokenWithSavedToken(data); + }).finally(() => { this._renewToken = null; }); } @@ -205,7 +221,7 @@ export default class SplatNet3Api { } async getBattleHistoryDetailPagerRefetch(id: string) { - return this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { + return this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { vsResultId: id, }); } @@ -220,6 +236,25 @@ export default class SplatNet3Api { }); } + async renewTokenWithCoral(nso: CoralApi, user: NintendoAccountUser) { + const data = await SplatNet3Api.loginWithCoral(nso, user); + this.setTokenWithSavedToken(data); + return data; + } + + async renewTokenWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) { + const data = await SplatNet3Api.loginWithWebServiceToken(webserviceToken, user); + this.setTokenWithSavedToken(data); + return data; + } + + protected setTokenWithSavedToken(data: SplatNet3AuthData) { + this.bullet_token = data.bullet_token.bulletToken; + this.version = data.version; + this.language = data.bullet_token.lang; + this.useragent = data.useragent; + } + static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) { const data = await this.loginWithCoral(nso, user); return {splatnet: this.createWithSavedToken(data), data}; @@ -294,7 +329,7 @@ export default class SplatNet3Api { const cookies = response.headers.get('Set-Cookie'); const [signal2, cancel2] = timeoutSignal(); - const token_response = await fetch(SPLATNET3_URL + '/bullet_tokens', { + const tr = await fetch(SPLATNET3_URL + '/bullet_tokens', { method: 'POST', headers: { 'User-Agent': SPLATNET3_WEBSERVICE_USERAGENT, @@ -313,21 +348,13 @@ export default class SplatNet3Api { debug('fetch %s %s, response %s', 'POST', '/bullet_tokens', response.status); - if (token_response.status === 401) { - throw new ErrorResponse('[splatnet3] ERROR_INVALID_GAME_WEB_TOKEN', token_response, await token_response.text()); - } - if (token_response.status === 403) { - throw new ErrorResponse('[splatnet3] ERROR_OBSOLETE_VERSION', token_response, await token_response.text()); - } - if (token_response.status === 204) { - throw new ErrorResponse('[splatnet3] USER_NOT_REGISTERED', token_response, await token_response.text()); - } - if (token_response.status !== 201) { - throw new ErrorResponse('[splatnet3] Non-201 status code', token_response, await token_response.text()); - } + const error: string | undefined = AUTH_ERROR_CODES[tr.status as keyof typeof AUTH_ERROR_CODES]; + if (error) throw new ErrorResponse('[splatnet3] ' + error, tr, await tr.text()); + if (tr.status !== 201) throw new ErrorResponse('[splatnet3] Non-201 status code', tr, await tr.text()); - const bullet_token = await token_response.json() as BulletToken; - const expires_at = Date.now() + (2 * 60 * 60 * 1000); // ?? + const bullet_token = await tr.json() as BulletToken; + const created_at = Date.now(); + const expires_at = created_at + TOKEN_EXPIRES_IN; return { webserviceToken, @@ -340,6 +367,7 @@ export default class SplatNet3Api { version, bullet_token, + created_at, expires_at, useragent: SPLATNET3_WEBSERVICE_USERAGENT, }; @@ -357,6 +385,12 @@ export interface SplatNet3AuthData { version: string; bullet_token: BulletToken; + created_at: number; + /** + * /api/bullet_tokens does not provide the token validity duration. Instead this assumes + * the token is valid for 2 hours. GraphQL responses include the actual remaining time + * in the x-bullettoken-remaining header. + */ expires_at: number; useragent: string; } diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 26c25c4..23c0b6b 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -2,7 +2,7 @@ import fetch, { Response } from 'node-fetch'; import createDebug from 'debug'; import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralErrorResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js'; import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js'; -import CoralApi, { CorrelationIdSymbol, ResponseDataSymbol, Result } from './coral.js'; +import CoralApi, { CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js'; import { NintendoAccountUser } from './na.js'; import { SavedToken } from '../common/auth/coral.js'; import { timeoutSignal } from '../util/misc.js'; @@ -12,7 +12,7 @@ const debug = createDebug('nxapi:api:znc-proxy'); export default class ZncProxyApi implements CoralApi { // Not used by ZncProxyApi - onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise) | null = null; + onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise) | null = null; /** @internal */ _renewToken: Promise | null = null; @@ -147,6 +147,11 @@ export default class ZncProxyApi implements CoralApi { return data; } + /** @private */ + setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) { + throw new Error('Not supported in ZncProxyApi'); + } + static async createWithSessionToken(url: string, token: string) { const nso = new this(url, token); const data = await nso.fetch('/auth'); diff --git a/src/common/auth/splatnet3.ts b/src/common/auth/splatnet3.ts index 52ce7d8..9099d76 100644 --- a/src/common/auth/splatnet3.ts +++ b/src/common/auth/splatnet3.ts @@ -1,11 +1,12 @@ import createDebug from 'debug'; import persist from 'node-persist'; import { Response } from 'node-fetch'; -import { getToken, Login } from './coral.js'; +import { getToken, Login, SavedToken } from './coral.js'; import SplatNet3Api, { SplatNet3AuthData } from '../../api/splatnet3.js'; import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js'; import { Jwt } from '../../util/jwt.js'; import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js'; +import { ErrorResponse } from '../../api/util.js'; const debug = createDebug('nxapi:auth:splatnet3'); @@ -94,6 +95,28 @@ async function renewToken( storage: persist.LocalStorage, token: string, splatnet: SplatNet3Api, previousToken: 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 storage.setItem('BulletToken.' + token, existingToken); + + return; + } else { + debug('Unable to renew bullet token with saved web services token - cached data for this session token doesn\'t exist??'); + } + } catch (err) { + if (err instanceof ErrorResponse && err.response.status === 401) { + // Web service token invalid/expired... + debug('Web service token expired, authenticating with new token', err); + } else { + throw err; + } + } + const {nso, data} = await getToken(storage, token, znc_proxy_url); if (data[Login]) { @@ -103,12 +126,7 @@ async function renewToken( const activeevent = await nso.getActiveEvent(); } - const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user); - - splatnet.bullet_token = existingToken.bullet_token.bulletToken; - splatnet.version = existingToken.version; - splatnet.language = existingToken.bullet_token.lang; - splatnet.useragent = existingToken.useragent; + const existingToken: SavedBulletToken = await splatnet.renewTokenWithCoral(nso, data.user); await storage.setItem('BulletToken.' + token, existingToken); }