Add nxapi nxapi-auth link-url command, API proxy client

This commit is contained in:
Samuel Elliott 2025-07-29 00:07:34 +01:00
parent c809722ca8
commit f7084107c6
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
7 changed files with 198 additions and 18 deletions

View File

@ -6,7 +6,7 @@ import { timeoutSignal } from '../util/misc.js';
import { getAdditionalUserAgents } from '../util/useragent.js';
import type { CoralRemoteConfig } from '../common/remote-config.js';
import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements_4, BlockingUsers, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, Friend_4, FriendCodeUrl, FriendCodeUser, Friends_4, GetActiveEventResult, ListChat, ListHashtag, ListHashtagParameter, ListMedia, ListMediaParameter, ListPushNotificationSettings, Media, PlayLogPermissions, PresencePermissions, PushNotificationPlayInvitationScope, ReceivedFriendRequest, ReceivedFriendRequests, SentFriendRequests, ShowUserLogin, UpdatePushNotificationSettingsParameter, UpdatePushNotificationSettingsParameterItem, User, UserPlayLog, WebServices_4, WebServiceToken, WebServiceTokenParameter } from './coral-types.js';
import { createZncaApi, DecryptResponseResult, FResult, HashMethod, RequestEncryptionProvider, ZncaApi } from './f.js';
import { createZncaApi, DecryptResponseResult, FResult, HashMethod, RequestEncryptionProvider, ZncaApi, ZncaApiNxapi } from './f.js';
import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountScope, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
import { ErrorResponse, ResponseSymbol } from './util.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
@ -374,6 +374,7 @@ export const RequestFlagNoEncryptionSymbol = Symbol('RequestFlagNoEncryption');
export const RequestFlagNoParameterSymbol = Symbol('RequestFlagNoParameter');
export const RequestFlagRequestIdSymbol = Symbol('RequestFlagRequestId');
export const RequestFlagNoAutoRenewTokenSymbol = Symbol('RequestFlagNoAutoRenewToken');
export const RequestFlagNxapiZncaApiRequestNsaAssertionSymbol = Symbol('RequestFlagNxapiZncaApiRequestNsaAssertion');
export interface RequestFlags {
[RequestFlagAddProductVersionSymbol]: boolean;
@ -383,6 +384,7 @@ export interface RequestFlags {
[RequestFlagNoParameterSymbol]: boolean;
[RequestFlagRequestIdSymbol]: RequestFlagRequestId;
[RequestFlagNoAutoRenewTokenSymbol]: boolean;
[RequestFlagNxapiZncaApiRequestNsaAssertionSymbol]: boolean;
}
export enum RequestFlagRequestId {
NONE,
@ -548,6 +550,7 @@ export default class CoralApi extends AbstractCoralApi implements CoralApiInterf
[RequestFlagAddPlatformSymbol]: true,
[RequestFlagAddProductVersionSymbol]: true,
[RequestFlagNoAutoRenewTokenSymbol]: true,
[RequestFlagNxapiZncaApiRequestNsaAssertionSymbol]: true,
});
} catch (err) {
if (err instanceof CoralErrorResponse && err.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) {
@ -912,7 +915,12 @@ class CoralApiRequest<T = unknown> {
request_encryption: RequestEncryptionProvider,
/** @internal */ _attempt: number,
) {
const decrypted = await request_encryption.decryptResponse(data);
debug('decrypting response', this.url, data.length);
const decrypted = request_encryption instanceof ZncaApiNxapi ?
await request_encryption.decryptResponse(data,
this.flags[RequestFlagNxapiZncaApiRequestNsaAssertionSymbol]) :
await request_encryption.decryptResponse(data);
const encryption: ResponseEncryption = {
encrypted: data,

View File

@ -316,10 +316,15 @@ export interface ResourceData {
authorisation_server_metadata: AuthorisationServerMetadata;
}
export const NsaAssertionSymbol = Symbol('NsaAssertion');
export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider {
readonly url: URL;
readonly auth: NxapiZncaAuth | null;
last_nsa_assertion: string | null = null
onReceiveNsaAssertion: ((nsa_assertion: string) => void) | null = null;
headers = new Headers();
constructor(
@ -479,19 +484,24 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider {
async decryptResponse(
data: Uint8Array,
request_nsa_assertion = false,
/** @internal */ _attempt = 0,
): Promise<DecryptResponseResult> {
if (this.auth && !this.auth.has_valid_token) await this.auth.authenticate();
debugZncaApi('decrypting response', data);
// debugZncaApi('decrypting response', data);
const req: AndroidZncaDecryptResponseRequest = {
data: Buffer.from(data).toString('base64'),
};
if (request_nsa_assertion) {
req.request_nsa_assertion = true;
}
const headers = new Headers(this.headers);
headers.set('Content-Type', 'application/json');
headers.set('Accept', 'text/plain');
headers.set('Accept', request_nsa_assertion ? 'application/json' : 'text/plain');
if (this.app?.platform) headers.append('X-znca-Platform', this.app.platform);
if (this.app?.version) headers.append('X-znca-Version', this.app.version);
if (ZNCA_VERSION) headers.append('X-znca-Client-Version', ZNCA_VERSION);
@ -511,18 +521,27 @@ export class ZncaApiNxapi extends ZncaApi implements RequestEncryptionProvider {
if (this.auth && err.data?.error === 'invalid_token' && !_attempt) {
this.auth.token = null;
return this.decryptResponse(data, _attempt + 1);
return this.decryptResponse(data, request_nsa_assertion, _attempt + 1);
}
throw err;
}
const decrypted_data = await response.text();
const result = request_nsa_assertion ? await response.json() as AndroidZncaDecryptResponseResponse : {
data: await response.text(),
};
if (result.nsa_assertion) {
this.last_nsa_assertion = result.nsa_assertion;
this.onReceiveNsaAssertion?.call(null, result.nsa_assertion);
}
return {
data: decrypted_data,
data: result.data,
// @ts-expect-error
[NsaAssertionSymbol]: result.nsa_assertion ?? null,
response,
};
}
@ -535,6 +554,8 @@ export class NxapiZncaAuth {
{ id: string; secret: string; } |
{ id: string; } |
null = null;
request_scope = 'ca:gf ca:er ca:dr';
token: TokenData | null = null;
refresh_token: string | null = null;
@ -576,6 +597,10 @@ export class NxapiZncaAuth {
debugZncaAuth('client authentication not configured');
}
if (process.env.NXAPI_ZNCA_API_AUTH_SCOPE) {
auth.request_scope = process.env.NXAPI_ZNCA_API_AUTH_SCOPE;
}
return auth;
}
@ -617,7 +642,7 @@ export class NxapiZncaAuth {
body.append('refresh_token', refresh_token);
} else {
body.append('grant_type', 'client_credentials');
body.append('scope', 'ca:gf ca:er ca:dr');
body.append('scope', this.request_scope);
}
if (this.client_credentials && 'secret' in this.client_credentials) {

View File

@ -1,7 +1,7 @@
import { fetch, Response } from 'undici';
import { ActiveEvent, CurrentUser, Event, Friend, PresencePermissions, User, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4, PresenceOnline_4, PresenceOnline, PresenceOffline, GetActiveEventResult, ReceivedFriendRequest, SentFriendRequest } from './coral-types.js';
import { ActiveEvent, CurrentUser, Event, Friend, PresencePermissions, User, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4, PresenceOnline_4, PresenceOnline, PresenceOffline, GetActiveEventResult, ReceivedFriendRequest, SentFriendRequest, CoralError } from './coral-types.js';
import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js';
import { AbstractCoralApi, CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestIdSymbol, RequestFlags, ResponseDataSymbol, ResponseEncryptionSymbol, Result } from './coral.js';
import { AbstractCoralApi, CoralApiInterface, CoralAuthData, CoralErrorResponse, CorrelationIdSymbol, PartialCoralAuthData, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestIdSymbol, RequestFlags, ResponseDataSymbol, ResponseEncryptionSymbol, Result } from './coral.js';
import { NintendoAccountToken, NintendoAccountUser } from './na.js';
import { SavedToken } from '../common/auth/coral.js';
import createDebug from '../util/debug.js';
@ -21,7 +21,10 @@ export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInt
super();
}
async fetchProxyApi<T = unknown>(url: URL | string, method = 'GET', body?: string, _headers?: HeadersInit) {
async fetchProxyApi<T = unknown>(
url: URL | string, method = 'GET', body?: string, _headers?: HeadersInit,
/** @internal */ _log?: string,
) {
if (typeof url === 'string' && url.startsWith('/')) url = url.substring(1);
const base_url = typeof this.url === 'string' ? new URL(this.url) : this.url;
@ -45,7 +48,7 @@ export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInt
}).finally(cancel);
const debug_url = typeof url === 'string' ? '/' + url : url.toString();
debug('fetch %s %s, response %s', method, debug_url, response.status);
debug('fetch %s %s%s, response %s', method, debug_url, _log ? ', ' + _log : '', response.status);
if (!response.ok) {
throw await ZncProxyErrorResponse.fromResponse(response, '[zncproxy] Non-2xx status code');
@ -64,14 +67,33 @@ export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInt
if (parameter[RequestFlagNoParameterSymbol]) options.push(['no_parameter', true]);
if (RequestFlagRequestIdSymbol in parameter) options.push(['request_id', parameter[RequestFlagRequestIdSymbol]]);
const result = await this.fetchProxyApi<{result: T}>('call', 'POST', JSON.stringify({
url,
parameter,
try {
const result = await this.fetchProxyApi<{result: T}>('call', 'POST', JSON.stringify({
url,
parameter,
options: options.length ? Object.fromEntries(options) : undefined,
}));
options: options.length ? Object.fromEntries(options) : undefined,
}), undefined, 'call ' + url);
return createResult(result, result.result);
return createResult(result, result.result);
} catch (err) {
if (err instanceof ZncProxyErrorResponse &&
err.response.status === 500 &&
err.data && typeof err.data === 'object' &&
'error' in err.data && err.data.error === 'unknown_error' &&
'data' in err.data && err.data.data && typeof err.data.data === 'object' &&
'status' in err.data.data && typeof err.data.data.status === 'number' &&
'errorMessage' in err.data.data && typeof err.data.data.errorMessage === 'string' &&
'correlationId' in err.data.data && typeof err.data.data.correlationId === 'string'
) {
const coral_err = new CoralErrorResponse('[znc] ' + err.data.data.errorMessage,
err.response, err.data.data as CoralError);
Object.defineProperty(coral_err, 'parent', {value: err});
throw coral_err;
}
throw err;
}
}
async getAnnouncements() {

View File

@ -4,6 +4,7 @@ export * as splatnet2 from './splatnet2/index.js';
export * as nooklink from './nooklink/index.js';
export * as splatnet3 from './splatnet3/index.js';
export * as pctl from './pctl/index.js';
export * as nxapiAuth from './nxapi-auth/index.js';
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
export * as presenceServer from './presence-server.js';
export * as util from './util/index.js';

View File

@ -0,0 +1 @@
export * as linkUrl from './link-url.js';

View File

@ -0,0 +1,20 @@
import type { Arguments as ParentArguments } from '../../cli.js';
import createDebug from '../../util/debug.js';
import { Argv, YargsArguments } from '../../util/yargs.js';
import * as commands from './commands.js';
const debug = createDebug('cli:nxapi-auth');
export const command = 'nxapi-auth <command>';
export const desc = 'nxapi-auth';
export function builder(yargs: Argv<ParentArguments>) {
for (const command of Object.values(commands)) {
// @ts-expect-error
yargs.command(command);
}
return yargs;
}
export type Arguments = YargsArguments<ReturnType<typeof builder>>;

View File

@ -0,0 +1,103 @@
import type { Arguments as ParentArguments } from '../nso/index.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import Users from '../../common/users.js';
import CoralApi, { ResponseEncryptionSymbol } from '../../api/coral.js';
import { NsaAssertionSymbol, ZncaApiNxapi } from '../../api/f.js';
const debug = createDebug('cli:nxapi-auth:link-url');
export const command = 'link-url';
export const desc = 'Link a Nintendo Switch user to nxapi-auth';
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('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
if (argv.zncProxyUrl) {
throw new Error('API proxy not supported');
}
if (!process.env.NXAPI_ZNCA_API_AUTH_SCOPE) {
// TODO: default cli/app clients should have this scope set automatically
// - custom clients won't be able to request this scope though so can't
// just be the default request scope
process.env.NXAPI_ZNCA_API_AUTH_SCOPE = 'ca:gf ca:er ca:dr ca:na';
}
const storage = await initStorage(argv.dataPath);
const user_na_id = argv.user ?? await storage.getItem('SelectedUser');
const na_session_token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + user_na_id);
const users = Users.coral(storage, argv.zncProxyUrl);
const user = await users.get(na_session_token);
if (!(user.nso instanceof CoralApi)) {
throw new Error('API proxy not supported');
}
if (!(user.nso.request_encryption instanceof ZncaApiNxapi)) {
throw new Error('Unsupported znca API');
}
const webservices = await user.getWebServices();
const [fr_received, fr_sent] = await Promise.all([
user.getReceivedFriendRequests(),
user.getSentFriendRequests(),
]);
const friendcodeurl = await user.nso.getFriendCodeUrl();
const webservice = webservices.find(webservice => {
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
if (verifymembership?.attrValue === 'true' &&
!user.data.nsoAccount.user.links.nintendoAccount.membership.active
) return false;
return true;
});
if (!webservice) {
throw new Error('Invalid web service');
}
debug('using web service', webservice);
const webserviceToken = await user.nso.getWebServiceToken(webservice.id);
const decrypt_result = webserviceToken[ResponseEncryptionSymbol]?.decrypt_result ?? {};
const nsa_assertion = NsaAssertionSymbol in decrypt_result ? decrypt_result[NsaAssertionSymbol] as string : null;
if (!nsa_assertion) {
throw new Error('API did not return an NSA assertion');
}
debug('received NSA assertion', nsa_assertion);
const link_url = new URL('https://nxapi-auth.fancy.org.uk/user/link');
link_url.searchParams.append('nsa_assertion', nsa_assertion);
link_url.searchParams.append('friend_code', friendcodeurl.friendCode);
link_url.searchParams.append('friend_code_url', friendcodeurl.url);
console.log('Open this URL and login to nxapi-auth to link your account:');
console.log('');
console.log(link_url);
console.log('');
}