mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-10-21 17:11:40 -05:00
SplatNet 3 authentication
This commit is contained in:
@@ -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);
|
||||
|
||||
27
src/api/splatnet3-types.ts
Normal file
27
src/api/splatnet3-types.ts
Normal 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
223
src/api/splatnet3.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
29
src/cli/splatnet3.ts
Normal 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>>;
|
||||
2
src/cli/splatnet3/index.ts
Normal file
2
src/cli/splatnet3/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * as user from './user.js';
|
||||
export * as token from './token.js';
|
||||
51
src/cli/splatnet3/token.ts
Normal file
51
src/cli/splatnet3/token.ts
Normal 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
33
src/cli/splatnet3/user.ts
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
65
src/common/auth/splatnet3.ts
Normal file
65
src/common/auth/splatnet3.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
6
src/exports/splatnet3.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
default,
|
||||
SplatNet3AuthData,
|
||||
} from '../api/splatnet3.js';
|
||||
|
||||
export * from '../api/splatnet3-types.js';
|
||||
Reference in New Issue
Block a user