SplatNet 3 authentication

This commit is contained in:
Samuel Elliott 2022-09-08 19:44:15 +01:00
parent 80afc62268
commit 1aa4aad85a
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
16 changed files with 481 additions and 5 deletions

View File

@ -561,7 +561,7 @@ This requires:
- The frida-server executable is located at `/data/local/tmp/frida-server` on the Android device (a different path can be provided using the `--frida-server-path` option)
- The Nintendo Switch Online app is installed on the Android device
No other software (e.g. frida-tools) needs to be installed on the computer running nxapi. The Android device must be constantly reachable using ADB. The server will exit if the device is unreachable.
No other software (e.g. frida-tools) needs to be installed on the computer running nxapi. The Android device must be constantly reachable using ADB. The server will attempt to reconnect to the Android device and will automatically retry any requests that would fail due to the device disconnecting. The server will exit if it fails to reconnect to the device. A service manager should be used to restart the server if it exits.
```sh
# Start the server using the ADB server "android.local:5555" listening on all interfaces on a random port
@ -598,3 +598,24 @@ curl --header "Content-Type: application/json" --data '{"type": "nso", "token":
# This should be set when running any nso commands as the access token will be refreshed automatically when it expires
ZNCA_API_URL=http://[::1]:12345/api/znca nxapi nso ...
```
Information about the device and the Nintendo Switch Online app, as well as information on how long the request took to process will be included in the response headers.
Header | Description
--------------------------------|------------------
`X-Android-Build-Type` | Android build type, e.g. `user`
`X-Android-Release` | Android release/marketing version, e.g. `8.0.0`
`X-Android-Platform-Version` | Android SDK version, e.g. `26`
`X-znca-Platform` | Device platform - always `Android`
`X-znca-Version` | App release/marketing version, e.g. `2.2.0`
`X-znca-Build` | App build/internal version, e.g. `2832`
The following performance metrics are included in the `Server-Timing` header:
Name | Description
------------|------------------
`validate` | Time validating the request body.
`attach` | Time waiting for the device to become available, start frida-server, start the app and attach the Frida script to the app process. This metric will not be included if the server is already connected to the device.
`queue` | Time waiting for the processing thread to become available.
`init` | Time waiting for `com.nintendo.coral.core.services.voip.Libvoipnji.init`.
`process` | Time waiting for `com.nintendo.coral.core.services.voip.Libvoipnji.genAudioH`/`genAudioH2`.

View File

@ -22,7 +22,8 @@
"./coral": "./dist/exports/coral.js",
"./moon": "./dist/exports/moon.js",
"./splatnet2": "./dist/exports/splatnet2.js",
"./nooklink": "./dist/exports/nooklink.js"
"./nooklink": "./dist/exports/nooklink.js",
"./splatnet3": "./dist/exports/splatnet3.js"
},
"bin": {
"nxapi": "bin/nxapi.js"

View File

@ -15,5 +15,10 @@
},
"coral_gws_nooklink": {
"blanco_version": "2.1.1"
},
"coral_gws_splatnet3": {
"app_ver": "1.0.0-5e2bcdfb",
"version": "1.0.0",
"revision": "5e2bcdfbc87dab203663b3eec2495633e5f67808"
}
}

View File

@ -81,6 +81,9 @@ export default class NooklinkApi {
}
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const { default: { coral_gws_nooklink: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
const webserviceToken = await nso.getWebServiceToken(NOOKLINK_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken, user);

View File

@ -0,0 +1,27 @@
/** /bullet_tokens */
export interface BulletToken {
bulletToken: string;
lang: string;
is_noe_country: 'true' | unknown;
// ...
}
/** /graphql */
export interface GraphQLRequest<Variables extends unknown> {
variables: Variables;
extensions: {
persistedQuery: {
version: 1;
sha256Hash: RequestParameters['id'];
};
};
}
export interface RequestParameters {
id: string;
// ...
}
export interface GraphQLResponse<T = unknown> {
// ...
}

223
src/api/splatnet3.ts Normal file
View File

@ -0,0 +1,223 @@
import fetch from 'node-fetch';
import createDebug from 'debug';
import { WebServiceToken } from './coral-types.js';
import { NintendoAccountUser } from './na.js';
import { defineResponse, ErrorResponse } from './util.js';
import CoralApi from './coral.js';
import { timeoutSignal } from '../util/misc.js';
import { BulletToken, GraphQLRequest, GraphQLResponse, RequestParameters } from './splatnet3-types.js';
const debug = createDebug('nxapi:api:splatnet3');
export const SPLATNET3_WEBSERVICE_ID = '4834290508791808';
export const SPLATNET3_WEBSERVICE_URL = 'https://api.lp1.av5ja.srv.nintendo.net';
export const SPLATNET3_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36';
const languages = [
'de-DE', 'en-GB', 'en-US', 'es-ES', 'es-MX', 'fr-CA',
'fr-FR', 'it-IT', 'ja-JP', 'ko-KR', 'nl-NL', 'ru-RU',
'zh-CN', 'zh-TW',
];
const SPLATNET3_URL = SPLATNET3_WEBSERVICE_URL + '/api';
export default class SplatNet3Api {
protected constructor(
public bullet_token: string,
public version: string,
public language: string,
public useragent: string,
) {}
async fetch<T = unknown>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
const [signal, cancel] = timeoutSignal();
const response = await fetch(SPLATNET3_URL + url, {
method,
headers: Object.assign({
'User-Agent': this.useragent,
'Accept': '*/*',
'Referrer': 'https://api.lp1.av5ja.srv.nintendo.net/',
'X-Requested-With': 'XMLHttpRequest',
'authorization': 'Bearer ' + this.bullet_token,
'content-type': 'application/json',
'X-Web-View-Ver': this.version,
'Accept-Language': this.language,
}, headers),
body,
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', response, await response.text());
}
const data = await response.json() as T;
return defineResponse(data, response);
}
async graphql<T = unknown, V = unknown>(request_parameters: RequestParameters, variables: V) {
const req: GraphQLRequest<V> = {
variables,
extensions: {
persistedQuery: {
version: 1,
sha256Hash: request_parameters.id,
},
},
};
const data = await this.fetch<GraphQLResponse<T>>('/graphql', 'POST', JSON.stringify(req));
return data;
}
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const data = await this.loginWithCoral(nso, user);
return {splatnet: this.createWithSavedToken(data), data};
}
static createWithSavedToken(data: SplatNet3AuthData) {
return new this(
data.bullet_token.bulletToken,
data.version,
data.bullet_token.lang,
data.useragent,
);
}
static createWithCliTokenData(data: SplatNet3CliTokenData) {
return new this(
data.bullet_token,
data.version,
data.language,
SPLATNET3_WEBSERVICE_USERAGENT,
);
}
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
const { default: { coral_gws_splatnet3: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication');
const webserviceToken = await nso.getWebServiceToken(SPLATNET3_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken, user);
}
static async loginWithWebServiceToken(
webserviceToken: WebServiceToken, user: NintendoAccountUser
): Promise<SplatNet3AuthData> {
const { default: { coral_gws_splatnet3: config } } = await import('../common/remote-config.js');
if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication');
const language = languages.includes(user.language) ? user.language : 'en-GB';
const version = config.app_ver ?? config.version + '-' + config.revision.substr(0, 8);
const url = new URL(SPLATNET3_WEBSERVICE_URL);
url.search = new URLSearchParams({
lang: user.language,
na_country: user.country,
na_lang: user.language,
}).toString();
const [signal, cancel] = timeoutSignal();
const response = await fetch(url.toString(), {
headers: {
'Upgrade-Insecure-Requests': '1',
'User-Agent': SPLATNET3_WEBSERVICE_USERAGENT,
'x-appcolorscheme': 'DARK',
'x-gamewebtoken': webserviceToken.accessToken,
'dnt': '1',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-GB,en-US;q=0.8',
'X-Requested-With': 'com.nintendo.znca',
},
signal,
}).finally(cancel);
debug('fetch %s %s, response %s', 'GET', url, response.status);
const body = await response.text();
if (response.status !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', response, body);
}
const cookies = response.headers.get('Set-Cookie');
const [signal2, cancel2] = timeoutSignal();
const token_response = await fetch(SPLATNET3_URL + '/bullet_tokens', {
method: 'POST',
headers: {
'User-Agent': SPLATNET3_WEBSERVICE_USERAGENT,
'Accept': '*/*',
'Referrer': 'https://api.lp1.av5ja.srv.nintendo.net/',
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
'X-Web-View-Ver': version,
'X-NACOUNTRY': user.country,
'Accept-Language': language,
'X-GameWebToken': webserviceToken.accessToken,
},
body: '',
signal: signal2,
}).finally(cancel2);
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 !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', token_response, await token_response.text());
}
const bullet_token = await token_response.json() as BulletToken;
const expires_at = Date.now() + (2 * 60 * 60 * 1000); // ??
return {
webserviceToken,
url: url.toString(),
cookies,
body,
language,
country: user.country,
version,
bullet_token,
expires_at,
useragent: SPLATNET3_WEBSERVICE_USERAGENT,
};
}
}
export interface SplatNet3AuthData {
webserviceToken: WebServiceToken;
url: string;
cookies: string | null;
body: string;
language: string;
country: string;
version: string;
bullet_token: BulletToken;
expires_at: number;
useragent: string;
}
export interface SplatNet3CliTokenData {
bullet_token: string;
expires_at: number;
language: string;
version: string;
}

View File

@ -2,6 +2,7 @@ export * as users from './users.js';
export * as nso from './nso.js';
export * as splatnet2 from './splatnet2.js';
export * as nooklink from './nooklink.js';
export * as splatnet3 from './splatnet3.js';
export * as pctl from './pctl.js';
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
export * as util from './util.js';

View File

@ -59,7 +59,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
// This means the other user had already sent this user a friend request,
// so sending them a friend request just accepted theirs
const friends = await nso.getFriendList();
const friend = friends.result.friends.find(f => f.nsaId === nsa_id);
const friend = friends.friends.find(f => f.nsaId === nsa_id);
if (friend) {
console.log('You are now friends with %s.', friend.name);

29
src/cli/splatnet3.ts Normal file
View File

@ -0,0 +1,29 @@
import process from 'node:process';
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../cli.js';
import { Argv, YargsArguments } from '../util/yargs.js';
import * as commands from './splatnet3/index.js';
const debug = createDebug('cli:splatnet3');
export const command = 'splatnet3 <command>';
export const desc = 'SplatNet 3';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs.option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
describe: 'Automatically obtain and refresh the SplatNet 3 access token',
type: 'boolean',
default: true,
});
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -0,0 +1,2 @@
export * as user from './user.js';
export * as token from './token.js';

View File

@ -0,0 +1,51 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';
import { SplatNet3CliTokenData } from '../../api/splatnet3.js';
const debug = createDebug('cli:splatnet3:token');
export const command = 'token';
export const desc = 'Get the authenticated Nintendo Account\'s SplatNet 3 user data and access token';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token || await storage.getItem('NintendoAccountToken.' + usernsid);
const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
if (argv.json || argv.jsonPrettyPrint) {
const result: SplatNet3CliTokenData = {
bullet_token: data.bullet_token.bulletToken,
expires_at: data.expires_at,
language: data.bullet_token.lang,
version: data.version,
};
console.log(JSON.stringify(result, null, argv.jsonPrettyPrint ? 4 : 0));
return;
}
console.log(data.bullet_token.bulletToken);
}

33
src/cli/splatnet3/user.ts Normal file
View File

@ -0,0 +1,33 @@
import createDebug from 'debug';
import type { Arguments as ParentArguments } from '../splatnet3.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { getBulletToken } from '../../common/auth/splatnet3.js';
const debug = createDebug('cli:splatnet3:user');
export const command = 'user';
export const desc = 'Get the authenticated Nintendo Account\'s player record';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid);
const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
throw new Error('Not implemented');
}

View File

@ -2,8 +2,7 @@ import createDebug from 'debug';
import persist from 'node-persist';
import { getToken, Login } from './coral.js';
import NooklinkApi, { NooklinkAuthData, NooklinkUserApi, NooklinkUserAuthData } from '../../api/nooklink.js';
import { AuthToken, Users } from '../../api/nooklink-types.js';
import { WebServiceToken } from '../../api/coral-types.js';
import { Users } from '../../api/nooklink-types.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
@ -28,6 +27,9 @@ export async function getWebServiceToken(
throw new Error('No valid NookLink web service token');
}
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);

View File

@ -0,0 +1,65 @@
import createDebug from 'debug';
import persist from 'node-persist';
import { getToken, Login } 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';
const debug = createDebug('nxapi:auth:splatnet3');
export interface SavedBulletToken extends SplatNet3AuthData {}
export async function getBulletToken(
storage: persist.LocalStorage, token: string, proxy_url?: string,
allow_fetch_token = false, ratelimit = SHOULD_LIMIT_USE
) {
if (!token) {
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
throw new Error('Invalid token');
}
const existingToken: SavedBulletToken | undefined = await storage.getItem('BulletToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
if (!allow_fetch_token) {
throw new Error('No valid bullet_token');
}
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]) {
const announcements = await nso.getAnnouncements();
const friends = await nso.getFriendList();
const webservices = await nso.getWebServices();
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user);
await storage.setItem('BulletToken.' + token, existingToken);
return {
splatnet: SplatNet3Api.createWithSavedToken(existingToken),
data: existingToken,
};
}
debug('Using existing token');
return {
splatnet: SplatNet3Api.createWithSavedToken(existingToken),
data: existingToken,
};
}

View File

@ -234,6 +234,7 @@ export interface NxapiRemoteConfig {
moon: MoonRemoteConfig | null;
coral_gws_nooklink: NooklinkRemoteConfig | null;
coral_gws_splatnet3: SplatNet3RemoteConfig | null;
}
export type DefaultZncaApiProvider =
@ -253,3 +254,9 @@ export interface MoonRemoteConfig {
export interface NooklinkRemoteConfig {
blanco_version: string;
}
export interface SplatNet3RemoteConfig {
app_ver: string;
version: string;
revision: string;
}

6
src/exports/splatnet3.ts Normal file
View File

@ -0,0 +1,6 @@
export {
default,
SplatNet3AuthData,
} from '../api/splatnet3.js';
export * from '../api/splatnet3-types.js';