mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-24 23:16:53 -05:00
Add nxapi nxapi-auth link-url command, API proxy client
This commit is contained in:
parent
c809722ca8
commit
f7084107c6
|
|
@ -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,
|
||||
|
|
|
|||
37
src/api/f.ts
37
src/api/f.ts
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
1
src/cli/nxapi-auth/commands.ts
Normal file
1
src/cli/nxapi-auth/commands.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * as linkUrl from './link-url.js';
|
||||
20
src/cli/nxapi-auth/index.ts
Normal file
20
src/cli/nxapi-auth/index.ts
Normal 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>>;
|
||||
103
src/cli/nxapi-auth/link-url.ts
Normal file
103
src/cli/nxapi-auth/link-url.ts
Normal 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('');
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user