mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-04-25 07:27:19 -05:00
552 lines
18 KiB
TypeScript
552 lines
18 KiB
TypeScript
import process from 'node:process';
|
|
import * as crypto from 'node:crypto';
|
|
import createDebug from 'debug';
|
|
import * as persist from 'node-persist';
|
|
import { app, BrowserWindow, dialog, MessageBoxOptions, Notification, session, shell } from './electron.js';
|
|
import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js';
|
|
import { ZNCA_CLIENT_ID } from '../../api/coral.js';
|
|
import { ZNMA_CLIENT_ID } from '../../api/moon.js';
|
|
import { getToken, SavedToken } from '../../common/auth/coral.js';
|
|
import { getPctlToken, SavedMoonToken } from '../../common/auth/moon.js';
|
|
import { Jwt } from '../../util/jwt.js';
|
|
import { tryGetNativeImageFromUrl } from './util.js';
|
|
import { ZNCA_API_USE_URL } from '../../common/constants.js';
|
|
import { createWindow } from './windows.js';
|
|
import { WindowType } from '../common/types.js';
|
|
import { protocol_registration_options } from './index.js';
|
|
|
|
const debug = createDebug('app:main:na-auth');
|
|
|
|
export type NintendoAccountAuthResult = NintendoAccountSessionToken;
|
|
|
|
export function getAuthUrl(client_id: string, scope: string | string[]) {
|
|
const state = crypto.randomBytes(36).toString('base64url');
|
|
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
const challenge = crypto.createHash('sha256').update(verifier).digest().toString('base64url');
|
|
|
|
const params = {
|
|
state,
|
|
redirect_uri: 'npf' + client_id + '://auth',
|
|
client_id,
|
|
scope: typeof scope === 'string' ? scope : scope.join(' '),
|
|
response_type: 'session_token_code',
|
|
session_token_code_challenge: challenge,
|
|
session_token_code_challenge_method: 'S256',
|
|
theme: 'login_form',
|
|
};
|
|
|
|
const url = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' +
|
|
new URLSearchParams(params).toString();
|
|
|
|
return {
|
|
url,
|
|
state,
|
|
verifier,
|
|
challenge,
|
|
};
|
|
}
|
|
|
|
const css = `
|
|
html {
|
|
overflow-x: hidden;
|
|
}
|
|
`;
|
|
|
|
let i = 0;
|
|
|
|
export function createAuthWindow() {
|
|
const browser_session = session.defaultSession;
|
|
|
|
const window = new BrowserWindow({
|
|
width: 400,
|
|
height: 600,
|
|
resizable: false,
|
|
maximizable: false,
|
|
fullscreenable: false,
|
|
title: 'Nintendo Account',
|
|
webPreferences: {
|
|
session: browser_session,
|
|
scrollBounce: true,
|
|
},
|
|
});
|
|
|
|
window.webContents.on('did-finish-load', () => {
|
|
window.webContents.insertCSS(css);
|
|
});
|
|
|
|
return window;
|
|
}
|
|
|
|
export interface NintendoAccountSessionTokenCode {
|
|
code: string;
|
|
verifier: string;
|
|
window?: BrowserWindow;
|
|
}
|
|
|
|
export class AuthoriseError extends Error {
|
|
constructor(readonly code: string, message?: string) {
|
|
super(message);
|
|
}
|
|
|
|
static fromSearchParams(qs: URLSearchParams) {
|
|
const code = qs.get('error') ?? 'unknown_error';
|
|
return new AuthoriseError(code, qs.get('error_description') ?? code);
|
|
}
|
|
}
|
|
|
|
export class AuthoriseCancelError extends AuthoriseError {
|
|
constructor(message?: string) {
|
|
super('access_denied', message);
|
|
}
|
|
}
|
|
|
|
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: false):
|
|
Promise<NintendoAccountSessionTokenCode & {window: BrowserWindow}>
|
|
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window: true):
|
|
Promise<NintendoAccountSessionTokenCode & {window?: never}>
|
|
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window?: boolean):
|
|
Promise<NintendoAccountSessionTokenCode & {window?: BrowserWindow}>
|
|
export function getSessionTokenCodeByInAppBrowser(client_id: string, scope: string | string[], close_window = true) {
|
|
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
|
|
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
|
|
const window = createAuthWindow();
|
|
|
|
const handleAuthUrl = (url: URL) => {
|
|
const authorisedparams = new URLSearchParams(url.hash.substr(1));
|
|
debug('Redirect URL parameters', [...authorisedparams.entries()]);
|
|
|
|
if (authorisedparams.get('state') !== state) {
|
|
rj(new Error('Invalid state'));
|
|
window.close();
|
|
return;
|
|
}
|
|
|
|
if (authorisedparams.has('error')) {
|
|
rj(AuthoriseError.fromSearchParams(authorisedparams));
|
|
window.close();
|
|
return;
|
|
}
|
|
|
|
if (!authorisedparams.has('session_token_code')) {
|
|
rj(new Error('Response didn\'t include a session token code'));
|
|
window.close();
|
|
return;
|
|
}
|
|
|
|
const code = authorisedparams.get('session_token_code')!;
|
|
const [jwt, sig] = Jwt.decode(code);
|
|
|
|
debug('code', code, jwt, sig);
|
|
|
|
if (close_window) {
|
|
rs({
|
|
code,
|
|
verifier,
|
|
});
|
|
|
|
window.close();
|
|
} else {
|
|
rs({
|
|
code,
|
|
verifier,
|
|
window,
|
|
});
|
|
}
|
|
};
|
|
|
|
window.webContents.on('will-navigate', (event, url_string) => {
|
|
const url = new URL(url_string);
|
|
|
|
debug('will navigate', url);
|
|
|
|
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
|
|
handleAuthUrl(url);
|
|
event.preventDefault();
|
|
} else if (url.origin === 'https://accounts.nintendo.com') {
|
|
// Ok
|
|
} else {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
window.on('closed', () => {
|
|
rj(new AuthoriseCancelError('Canceled'));
|
|
});
|
|
|
|
window.webContents.on('did-fail-load', e => rj(e));
|
|
|
|
window.webContents.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Mobile/15E148 Safari/604.1';
|
|
|
|
window.webContents.setWindowOpenHandler(details => {
|
|
const url = new URL(details.url);
|
|
|
|
debug('open', details);
|
|
|
|
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
|
|
handleAuthUrl(url);
|
|
} else {
|
|
shell.openExternal(details.url);
|
|
}
|
|
|
|
return {action: 'deny'};
|
|
});
|
|
|
|
debug('Loading Nintendo Account authorisation', {
|
|
authoriseurl,
|
|
state,
|
|
verifier,
|
|
challenge,
|
|
});
|
|
|
|
window.loadURL(authoriseurl);
|
|
});
|
|
}
|
|
|
|
const FORCE_MANUAL_AUTH_URI_ENTRY = process.env.NXAPI_FORCE_MANUAL_AUTH === '1';
|
|
|
|
export function getSessionTokenCodeByDefaultBrowser(
|
|
client_id: string, scope: string | string[],
|
|
close_window = true,
|
|
force_manual = FORCE_MANUAL_AUTH_URI_ENTRY
|
|
) {
|
|
return new Promise<NintendoAccountSessionTokenCode>((rs, rj) => {
|
|
const {url: authoriseurl, state, verifier, challenge} = getAuthUrl(client_id, scope);
|
|
let window: BrowserWindow | undefined = undefined;
|
|
|
|
const handleAuthUrl = (url: URL) => {
|
|
const authorisedparams = new URLSearchParams(url.hash.substr(1));
|
|
debug('Redirect URL parameters', [...authorisedparams.entries()]);
|
|
|
|
if (authorisedparams.get('state') !== state) {
|
|
rj(new Error('Invalid state'));
|
|
window?.close();
|
|
return;
|
|
}
|
|
|
|
if (authorisedparams.has('error')) {
|
|
rj(AuthoriseError.fromSearchParams(authorisedparams));
|
|
window?.close();
|
|
return;
|
|
}
|
|
|
|
if (!authorisedparams.has('session_token_code')) {
|
|
rj(new Error('Response didn\'t include a session token code'));
|
|
window?.close();
|
|
return;
|
|
}
|
|
|
|
const code = authorisedparams.get('session_token_code')!;
|
|
const [jwt, sig] = Jwt.decode(code);
|
|
|
|
debug('code', code, jwt, sig);
|
|
|
|
if (window && close_window) window.close();
|
|
else if (window) rs({code, verifier, window});
|
|
else rs({code, verifier});
|
|
};
|
|
|
|
debug('Prompting user for Nintendo Account authorisation', {
|
|
authoriseurl,
|
|
state,
|
|
verifier,
|
|
challenge,
|
|
});
|
|
|
|
const protocol = 'npf' + client_id;
|
|
|
|
if (force_manual) {
|
|
debug('Manual entry forced, prompting for redirect URI');
|
|
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
|
|
} else if (app.isDefaultProtocolClient(protocol,
|
|
protocol_registration_options?.path, protocol_registration_options?.argv
|
|
)) {
|
|
debug('App is already default protocol handler, opening browser');
|
|
auth_state.set(state, [handleAuthUrl, rj, protocol]);
|
|
shell.openExternal(authoriseurl);
|
|
} else {
|
|
const registered_app = app.getApplicationNameForProtocol(protocol);
|
|
|
|
if (registered_app || !app.setAsDefaultProtocolClient(protocol,
|
|
protocol_registration_options?.path, protocol_registration_options?.argv
|
|
)) {
|
|
debug('Another app is using the auth protocol or registration failed, prompting for redirect URI');
|
|
window = askUserForRedirectUri(authoriseurl, client_id, handleAuthUrl, rj);
|
|
} else {
|
|
debug('App is now default protocol handler, opening browser');
|
|
auth_state.set(state, [handleAuthUrl, rj, protocol]);
|
|
shell.openExternal(authoriseurl);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const auth_state = new Map<string, [rs: (url: URL) => void, rj: (reason: any) => void, protocol: string]>();
|
|
|
|
export function handleAuthUri(url_string: string) {
|
|
const url = new URL(url_string);
|
|
const qs = new URLSearchParams(url.hash.substr(1));
|
|
|
|
debug('Received auth URL', url, qs.entries());
|
|
|
|
const state_str = qs.get('state');
|
|
if (!state_str) return;
|
|
const state = auth_state.get(state_str);
|
|
if (!state) return;
|
|
|
|
debug('Received valid auth URL with state', state_str);
|
|
|
|
auth_state.delete(state_str);
|
|
const [rs, rj, protocol] = state;
|
|
|
|
rs(url);
|
|
}
|
|
|
|
app.on('quit', () => {
|
|
for (const [,, protocol] of auth_state.values()) {
|
|
app.removeAsDefaultProtocolClient(protocol,
|
|
protocol_registration_options?.path, protocol_registration_options?.argv);
|
|
}
|
|
});
|
|
|
|
function askUserForRedirectUri(
|
|
authoriseurl: string, client_id: string,
|
|
handleAuthUrl: (url: URL) => void, rj: (reason: any) => void
|
|
) {
|
|
const window = createWindow(WindowType.ADD_ACCOUNT_MANUAL_PROMPT, {
|
|
authoriseurl,
|
|
client_id,
|
|
}, {
|
|
show: false,
|
|
maximizable: false,
|
|
minimizable: false,
|
|
width: 560,
|
|
height: 300,
|
|
minWidth: 450,
|
|
maxWidth: 700,
|
|
minHeight: 300,
|
|
maxHeight: 300,
|
|
});
|
|
|
|
window.webContents.on('will-navigate', (event, url_string) => {
|
|
event.preventDefault();
|
|
|
|
const url = new URL(url_string);
|
|
|
|
debug('will navigate', url);
|
|
|
|
if (url.protocol === 'npf' + client_id + ':' && url.host === 'auth') {
|
|
handleAuthUrl(url);
|
|
}
|
|
});
|
|
|
|
window.on('closed', () => {
|
|
rj(new AuthoriseCancelError('Canceled'));
|
|
});
|
|
|
|
return window;
|
|
}
|
|
|
|
const NSO_SCOPE = [
|
|
'openid',
|
|
'user',
|
|
'user.birthday',
|
|
'user.mii',
|
|
'user.screenName',
|
|
];
|
|
|
|
export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
|
|
const {code, verifier, window} = use_in_app_browser ?
|
|
await getSessionTokenCodeByInAppBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false) :
|
|
await getSessionTokenCodeByDefaultBrowser(ZNCA_CLIENT_ID, NSO_SCOPE, false);
|
|
|
|
window?.setFocusable(false);
|
|
window?.blurWebView();
|
|
|
|
try {
|
|
const [jwt, sig] = Jwt.decode(code);
|
|
|
|
const nsotoken = await storage.getItem('NintendoAccountToken.' + jwt.payload.sub) as string | undefined;
|
|
|
|
if (nsotoken) {
|
|
const data = await storage.getItem('NsoToken.' + nsotoken) as SavedToken | undefined;
|
|
|
|
debug('Already authenticated', data);
|
|
|
|
new Notification({
|
|
title: 'Nintendo Switch Online',
|
|
body: 'Already signed in as ' + data?.nsoAccount.user.name + ' (' + data?.user.nickname + ')',
|
|
icon: await tryGetNativeImageFromUrl(data!.nsoAccount.user.imageUri),
|
|
}).show();
|
|
|
|
return getToken(storage, nsotoken, process.env.ZNC_PROXY_URL, false);
|
|
}
|
|
|
|
await checkZncaApiUseAllowed(storage, window);
|
|
|
|
const token = await getNintendoAccountSessionToken(code, verifier, ZNCA_CLIENT_ID);
|
|
|
|
debug('session token', token);
|
|
|
|
const {nso, data} = await getToken(storage, token.session_token, process.env.ZNC_PROXY_URL, false);
|
|
|
|
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
|
|
users.add(data.user.id);
|
|
await storage.setItem('NintendoAccountIds', [...users]);
|
|
|
|
new Notification({
|
|
title: 'Nintendo Switch Online',
|
|
body: 'Authenticated as ' + data.nsoAccount.user.name + ' (NSO ' + data.user.nickname + ')',
|
|
icon: await tryGetNativeImageFromUrl(data.nsoAccount.user.imageUri),
|
|
}).show();
|
|
|
|
return {nso, data};
|
|
} finally {
|
|
window?.close();
|
|
}
|
|
}
|
|
|
|
export async function askAddNsoAccount(storage: persist.LocalStorage, iab = true) {
|
|
try {
|
|
return await addNsoAccount(storage, iab);
|
|
} catch (err: any) {
|
|
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
|
|
|
|
dialog.showErrorBox('Error adding account', err.stack || err.message);
|
|
}
|
|
}
|
|
|
|
async function checkZncaApiUseAllowed(storage: persist.LocalStorage, window?: BrowserWindow, force = false) {
|
|
if (!force) {
|
|
if (await storage.getItem('ZncaApiConsent')) {
|
|
return;
|
|
}
|
|
|
|
if (process.env.ZNC_PROXY_URL) {
|
|
debug('Skipping znca API consent; znc proxy URL set');
|
|
await storage.setItem('ZncaApiConsent', true);
|
|
return;
|
|
}
|
|
|
|
const ids: string[] | undefined = await storage.getItem('NintendoAccountIds');
|
|
|
|
for (const id of ids ?? []) {
|
|
const nsotoken: string | undefined = await storage.getItem('NintendoAccountToken.' + id);
|
|
if (!nsotoken) continue;
|
|
|
|
debug('Skipping znca API consent; Nintendo Switch Online account already linked');
|
|
await storage.setItem('ZncaApiConsent', true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (await askZncaApiUseAllowed(window)) {
|
|
await storage.setItem('ZncaApiConsent', true);
|
|
} else {
|
|
throw new Error('Cannot continue without third-party APIs allowed');
|
|
}
|
|
}
|
|
|
|
const ZNCA_API_USE_TEXT = `To access the Nintendo Switch Online app API, nxapi must send some data to third-party APIs. This is required to generate some data to make Nintendo think you\'re using the real Nintendo Switch Online app.
|
|
|
|
By default, this uses the imink API, but another service can be used by setting an environment variable. The default API may change without notice if you do not force use of a specific service.
|
|
|
|
The data sent includes:
|
|
|
|
- When authenticating to the Nintendo Switch Online app: a Nintendo Account ID token, containing your Nintendo Account ID and country, which is valid for 15 minutes
|
|
- When authenticating to game-specific services: a Coral (Nintendo Switch Online app) ID token, containing your Coral user ID, Nintendo Switch Online membership status, and Nintendo Account child restriction status, which is valid for 2 hours`;
|
|
|
|
async function askZncaApiUseAllowed(window?: BrowserWindow): Promise<boolean> {
|
|
const options: MessageBoxOptions = {
|
|
message: 'Third-party API usage',
|
|
detail: ZNCA_API_USE_TEXT,
|
|
buttons: ['OK', 'Cancel', 'More information'],
|
|
cancelId: 1,
|
|
};
|
|
|
|
const result = window ?
|
|
await dialog.showMessageBox(window, options) :
|
|
await dialog.showMessageBox(options);
|
|
|
|
debug('znca API consent', result);
|
|
|
|
if (result.response === 2) {
|
|
shell.openExternal(ZNCA_API_USE_URL);
|
|
return askZncaApiUseAllowed(window);
|
|
}
|
|
|
|
return result.response === 0;
|
|
}
|
|
|
|
const MOON_SCOPE = [
|
|
'openid',
|
|
'user',
|
|
'user.mii',
|
|
'moonUser:administration',
|
|
'moonDevice:create',
|
|
'moonOwnedDevice:administration',
|
|
'moonParentalControlSetting',
|
|
'moonParentalControlSetting:update',
|
|
'moonParentalControlSettingState',
|
|
'moonPairingState',
|
|
'moonSmartDevice:administration',
|
|
'moonDailySummary',
|
|
'moonMonthlySummary',
|
|
];
|
|
|
|
export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_browser = true) {
|
|
const {code, verifier, window} = use_in_app_browser ?
|
|
await getSessionTokenCodeByInAppBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false) :
|
|
await getSessionTokenCodeByDefaultBrowser(ZNMA_CLIENT_ID, MOON_SCOPE, false);
|
|
|
|
window?.setFocusable(false);
|
|
window?.blurWebView();
|
|
|
|
try {
|
|
const [jwt, sig] = Jwt.decode(code);
|
|
|
|
const moontoken = await storage.getItem('NintendoAccountToken-pctl.' + jwt.payload.sub) as string | undefined;
|
|
|
|
if (moontoken) {
|
|
const data = await storage.getItem('MoonToken.' + moontoken) as SavedMoonToken | undefined;
|
|
|
|
debug('Already authenticated', data);
|
|
|
|
new Notification({
|
|
title: 'Nintendo Switch Parental Controls',
|
|
body: 'Already signed in as ' + data?.user.nickname,
|
|
}).show();
|
|
|
|
return getPctlToken(storage, moontoken, false);
|
|
}
|
|
|
|
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
|
|
|
|
debug('session token', token);
|
|
|
|
const {moon, data} = await getPctlToken(storage, token.session_token, false);
|
|
|
|
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
|
|
users.add(data.user.id);
|
|
await storage.setItem('NintendoAccountIds', [...users]);
|
|
|
|
new Notification({
|
|
title: 'Nintendo Switch Parental Controls',
|
|
body: 'Authenticated as ' + data.user.nickname,
|
|
}).show();
|
|
|
|
return {moon, data};
|
|
} finally {
|
|
window?.close();
|
|
}
|
|
}
|
|
|
|
export async function askAddPctlAccount(storage: persist.LocalStorage, iab = true) {
|
|
try {
|
|
return await addPctlAccount(storage, iab);
|
|
} catch (err: any) {
|
|
if (err instanceof AuthoriseError && err.code === 'access_denied') return;
|
|
|
|
dialog.showErrorBox('Error adding account', err.stack || err.message);
|
|
}
|
|
}
|