Add Nintendo Account authentication and web services in Electron

This commit is contained in:
Samuel Elliott 2022-04-05 20:37:33 +01:00
parent a5838ec47b
commit 2bc251a58b
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
10 changed files with 649 additions and 16 deletions

7
.vscode/launch.json vendored
View File

@ -16,7 +16,12 @@
"args": [
"dist/app/main/app-entry.cjs"
],
"outputCapture": "std"
"outputCapture": "std",
"env": {
"DEBUG": "*,-express:*,-body-parser:*",
"DEBUG_COLORS": "1",
"FORCE_COLOR": "3"
},
}
]
}

View File

@ -137,6 +137,73 @@ export interface NintendoAccountAccessTokenJwtPayload extends JwtPayload {
aud: string;
}
export enum NintendoAccountScope {
OPENID = 'openid', // Used by NSO, PCTL, nintendo.co.uk
OFFLINE = 'offline', // Used by ec
USER = 'user', // Used by NSO, PCTL, nintendo.co.uk
USER_BIRTHDAY = 'user.birthday', // Used by NSO, PCTL, nintendo.co.uk
USER_MII = 'user.mii', // Used by NSO, nintendo.co.uk
USER_SCREENNAME = 'user.screenName', // Used by NSO
USER_EMAIL = 'user.email', // Used by nintendo.co.uk
USER_LINKS = 'user.links[].id', // Used by nintendo.co.uk
USER_LINKS_NNID = 'user.links.nintendoNetwork.id', // Used by ec
USER_MEMBERSHIP = 'user.membership', // Used by nintendo.co.uk
USER_WISHLIST = 'user.wishlist', // Used by nintendo.co.uk
ESHOP_DEMO = 'eshopDemo', // Used by nintendo.co.uk
ESHOP_DEVICE = 'eshopDevice', // Used by nintendo.co.uk
ESHOP_PRICE = 'eshopPrice', // Used by nintendo.co.uk
MISSIONSTATUS = 'missionStatus', // Used by nintendo.co.uk
MISSIONSTATUS_PROGRESS = 'missionStatus:progress', // Used by nintendo.co.uk
POINTWALLET = 'pointWallet', // Used by nintendo.co.uk
USERNOTIFICATIONMESSAGE_ANYCLIENTS = 'userNotificationMessage:anyClients', // Used by nintendo.co.uk
USERNOTIFICATIONMESSAGE_ANYCLIENTS_WRITE = 'userNotificationMessage:anyClients:write', // Used by nintendo.co.uk
MOONUSER_ADMINISTRATION = 'moonUser:administration', // Used by PCTL
MOONDEVICE_CREATE = 'moonDevice:create', // Used by PCTL
MOONOWNEDDEVICE_ADMINISTRATION = 'moonOwnedDevice:administration', // Used by PCTL
MOONPARENTALCONTROLSETTING = 'moonParentalControlSetting', // Used by PCTL
MOONPARENTALCONTROLSETTING_UPDATE = 'moonParentalControlSetting:update', // Used by PCTL
MOONPARENTALCONTROLSETTINGSTATE = 'moonParentalControlSettingState', // Used by PCTL
MOONPAIRINGSTATE = 'moonPairingState', // Used by PCTL
MOONSMARTDEVICE_ADMINISTRATION = 'moonSmartDevice:administration', // Used by PCTL
MOONDAILYSUMMARY = 'moonDailySummary', // Used by PCTL
MOONMONTHLYSUMMARY = 'moonMonthlySummary', // Used by PCTL
}
export enum NintendoAccountJwtScope {
'openid' = 0,
'user' = 8,
'user.birthday' = 9,
'user.mii' = 17,
'user.screenName' = 23,
'moonUser:administration' = 320,
'moonDevice:create' = 321,
'moonOwnedDevice:administration' = 325,
'moonParentalControlSetting' = 322,
'moonParentalControlSetting:update' = 323,
'moonParentalControlSettingState' = 324,
'moonPairingState' = 326,
'moonSmartDevice:administration' = 327,
'moonDailySummary' = 328,
'moonMonthlySummary' = 329,
// 10, 12, 70, 81, 198, 288, 289, 291, 292, 356, 357, 376
// 'user.email' = -1,
// 'user.links[].id' = -1,
// 'user.membership' = -1,
// 'user.wishlist' = -1,
// 'eshopDemo' = -1,
// 'eshopDevice' = -1,
// 'eshopPrice' = -1,
// 'missionStatus' = -1,
// 'missionStatus:progress' = -1,
// 'pointWallet' = -1,
// 'userNotificationMessage:anyClients' = -1,
// 'userNotificationMessage:anyClients:write' = -1,
// 1, 31
// 'offline' = -1,
// 'user.links.nintendoNetwork.id' = -1,
}
export interface NintendoAccountUser {
emailOptedIn: boolean;
language: string;

View File

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Button, Image, StyleSheet, Text, TextProps, useColorScheme, View } from 'react-native';
import React, { useCallback } from 'react';
import { StyleSheet, Text, useColorScheme, View } from 'react-native';
import { NintendoAccountUser } from '../../api/na.js';
import { SavedMoonToken, SavedToken } from '../../util.js';
import ipc from './ipc.js';

View File

@ -3,4 +3,20 @@ import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const electron = require('electron');
export default electron;
export const app = electron.app;
export const BrowserWindow = electron.BrowserWindow;
export const dialog = electron.dialog;
export const ipcMain = electron.ipcMain;
export const Menu = electron.Menu;
export const MenuItem = electron.MenuItem;
export const nativeImage = electron.nativeImage;
export const Notification = electron.Notification;
export const session = electron.session;
export const shell = electron.shell;
export const Tray = electron.Tray;
export type BrowserWindow = import('electron').BrowserWindow;
export type Menu = import('electron').Menu;
export type MenuItem = import('electron').MenuItem;
export type Notification = import('electron').Notification;
export type Tray = import('electron').Tray;

View File

@ -1,13 +1,19 @@
import electron from '../electron.js';
import { app, BrowserWindow, ipcMain } from '../electron.js';
import * as path from 'path';
import persist from 'node-persist';
import createDebug from 'debug';
import * as persist from 'node-persist';
import { initStorage, paths } from '../../util.js';
import MenuApp from './menu.js';
import { EventEmitter } from 'events';
import dotenv from 'dotenv';
import dotenvExpand from 'dotenv-expand';
const debug = createDebug('app:main');
const __dirname = path.join(import.meta.url.substr(7), '..');
const bundlepath = path.join(import.meta.url.substr(7), '..', '..', 'bundle');
const { app, BrowserWindow, ipcMain } = electron;
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
@ -24,21 +30,49 @@ function createWindow() {
}
app.whenReady().then(async () => {
createWindow();
dotenvExpand.expand(dotenv.config({
path: path.join(paths.data, '.env'),
}));
if (process.env.NXAPI_DATA_PATH) dotenvExpand.expand(dotenv.config({
path: path.join(process.env.NXAPI_DATA_PATH, '.env'),
}));
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
if (process.env.DEBUG) createDebug.enable(process.env.DEBUG);
const storage = await initStorage(paths.data);
const storage = await initStorage(process.env.NXAPI_DATA_PATH ?? paths.data);
const store = new Store(storage);
ipcMain.handle('nxapi:accounts:list', () => storage.getItem('NintendoAccountIds'));
ipcMain.handle('nxapi:nso:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken.' + id));
ipcMain.handle('nxapi:nso:getcachedtoken', (e, token: string) => storage.getItem('NsoToken.' + token));
ipcMain.handle('nxapi:moon:gettoken', (e, id: string) => storage.getItem('NintendoAccountToken-pctl.' + id));
ipcMain.handle('nxapi:moon:getcachedtoken', (e, token: string) => storage.getItem('MoonToken.' + token));
const sendToAllWindows = (channel: string, ...args: any[]) =>
BrowserWindow.getAllWindows().forEach(w => w.webContents.send(channel, ...args));
store.on('update-nintendo-accounts', () => sendToAllWindows('nxapi:accounts:shouldrefresh'));
const menu = new MenuApp(store);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
debug('App started');
// createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
export class Store extends EventEmitter {
constructor(
public storage: persist.LocalStorage
) {
super();
}
//
}

156
src/app/main/menu.ts Normal file
View File

@ -0,0 +1,156 @@
import { app, dialog, Menu, Tray, nativeImage, MenuItem } from '../electron.js';
import { addNsoAccount, addPctlAccount } from './na-auth.js';
import { Store } from './index.js';
import { getToken, SavedMoonToken, SavedToken } from '../../util.js';
import { WebService } from '../../api/znc-types.js';
import openWebService from './webservices.js';
export default class MenuApp {
tray: Tray;
constructor(readonly store: Store) {
const icon = nativeImage.createEmpty();
this.tray = new Tray(icon);
this.tray.setTitle('nxapi');
this.tray.setToolTip('nxapi');
store.on('update-nintendo-accounts', () => this.updateMenu());
this.updateMenu();
}
async updateMenu() {
const menu = new Menu();
const ids = await this.store.storage.getItem('NintendoAccountIds') as string[] | undefined;
menu.append(new MenuItem({label: 'Nintendo Switch Online', enabled: false}));
for (const id of ids ?? []) {
const token = await this.store.storage.getItem('NintendoAccountToken.' + id) as string | undefined;
if (!token) continue;
const data = await this.store.storage.getItem('NsoToken.' + token) as SavedToken | undefined;
if (!data) continue;
const item = new MenuItem({
label: data.nsoAccount.user.name,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
{label: 'NSA ID: ' + data.nsoAccount.user.nsaId, enabled: false},
{type: 'separator'},
{label: 'Web services', enabled: false},
...await this.getWebServiceItems(token) as any,
],
});
}
menu.append(new MenuItem({label: 'Add account', click: this.addNsoAccount}));
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Nintendo Switch Parental Controls', enabled: false}));
for (const id of ids ?? []) {
const token = await this.store.storage.getItem('NintendoAccountToken-pctl.' + id) as string | undefined;
if (!token) continue;
const data = await this.store.storage.getItem('MoonToken.' + token) as SavedMoonToken | undefined;
if (!data) continue;
const item = new MenuItem({
label: data.user.nickname,
submenu: [
{label: 'Nintendo Account ID: ' + data.user.id, enabled: false},
],
});
menu.append(item);
}
menu.append(new MenuItem({label: 'Add account', click: this.addPctlAccount}));
menu.append(new MenuItem({type: 'separator'}));
menu.append(new MenuItem({label: 'Quit', click: () => app.quit()}));
this.tray.setContextMenu(menu);
}
addNsoAccount = () => {
addNsoAccount(this.store.storage).catch(err => {
if (err.message === 'Canceled') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
});
};
addPctlAccount = () => {
addPctlAccount(this.store.storage).catch(err => {
if (err.message === 'Canceled') return;
dialog.showErrorBox('Error adding account', err.stack || err.message);
});
};
// Hardcode these temporarily until they are cached
webservices: WebService[] | null = [
{
id: 4953919198265344,
uri: 'https://web.sd.lp1.acbaa.srv.nintendo.net',
customAttributes: [
{attrKey: 'verifyMembership', attrValue: 'true'},
{attrKey: 'deepLinkingEnabled', attrValue: 'true'},
{attrKey: 'appNavigationBarBgColor', attrValue: '82D7AA'},
{attrKey: 'appStatusBarBgColor', attrValue: '82D7AA'},
],
whiteList: ['*.acbaa.srv.nintendo.net'],
name: 'Animal Crossing: New Horizons',
imageUri: 'https://cdn.znc.srv.nintendo.net/gameWebServices/n5b4648f/n5b4648f/images/euEn/banner.png',
},
{
id: 5598642853249024,
uri: 'https://app.smashbros.nintendo.net',
customAttributes: [
{attrKey: 'verifyMembership', attrValue: 'true'},
{attrKey: 'appNavigationBarBgColor', attrValue: 'A50514'},
{attrKey: 'appStatusBarBgColor', attrValue: 'A50514'},
],
whiteList: ['app.smashbros.nintendo.net'],
name: 'Super Smash Bros. Ultimate',
imageUri: 'https://cdn.znc.srv.nintendo.net/gameWebServices/n3f32691/n3f32691/images/euEn/banner.png',
},
{
id: 5741031244955648,
uri: 'https://app.splatoon2.nintendo.net/',
customAttributes: [
{attrKey: 'appNavigationBarBgColor', attrValue: 'E60012'},
{attrKey: 'appStatusBarBgColor', attrValue: 'E60012'},
],
whiteList: ['app.splatoon2.nintendo.net'],
name: 'Splatoon 2',
imageUri: 'https://cdn.znc.srv.nintendo.net/gameWebServices/splatoon2/images/euEn/banner.png',
},
];
async getWebServices(token: string) {
if (this.webservices) return this.webservices;
const {nso, data} = await getToken(this.store.storage, token, process.env.ZNC_PROXY_URL);
const webservices = await nso.getWebServices();
return this.webservices = webservices.result;
}
async getWebServiceItems(token: string) {
const webservices = await this.getWebServices(token);
const items = [];
for (const webservice of webservices) {
items.push(new MenuItem({
label: webservice.name,
click: async () => {
const {nso, data} = await getToken(this.store.storage, token);
await openWebService(nso, data, webservice);
},
}));
}
return items;
}
}

265
src/app/main/na-auth.ts Normal file
View File

@ -0,0 +1,265 @@
import * as crypto from 'crypto';
import createDebug from 'debug';
import * as persist from 'node-persist';
import fetch from 'node-fetch';
import { BrowserWindow, nativeImage, Notification, session, shell } from '../electron.js';
import { getNintendoAccountSessionToken, NintendoAccountSessionToken } from '../../api/na.js';
import { Jwt } from '../../api/util.js';
import { ZNCA_CLIENT_ID } from '../../api/znc.js';
import { getPctlToken, getToken, SavedMoonToken, SavedToken } from '../../util.js';
import { ZNMA_CLIENT_ID } from '../../api/moon.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,
};
}
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,
},
});
return window;
}
export interface NintendoAccountSessionTokenCode {
code: string;
verifier: string;
window?: BrowserWindow;
}
export function getSessionTokenCode(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();
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') {
const authorisedparams = new URLSearchParams(url.hash.substr(1));
debug('Redirect URL parameters', [...authorisedparams.entries()]);
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,
});
}
} else if (url.origin === 'https://accounts.nintendo.com') {
// Ok
} else {
event.preventDefault();
}
});
window.on('closed', () => {
rj(new Error('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 => {
debug('open', details);
shell.openExternal(details.url);
return {action: 'deny'};
});
debug('Loading Nintendo Account authorisation', {
authoriseurl,
state,
verifier,
challenge,
});
window.loadURL(authoriseurl);
});
}
const NSO_SCOPE = [
'openid',
'user',
'user.birthday',
'user.mii',
'user.screenName',
];
export async function addNsoAccount(storage: persist.LocalStorage) {
const {code, verifier, window} = await getSessionTokenCode(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);
let icon = undefined;
try {
const response = await fetch(data!.nsoAccount.user.imageUri);
const image = await response.buffer();
icon = nativeImage.createFromBuffer(image);
} catch (err) {}
new Notification({
title: 'Nintendo Switch Online',
body: 'Already signed in as ' + data?.nsoAccount.user.name + ' (' + data?.user.nickname + ')',
icon,
}).show();
return getToken(storage, nsotoken, process.env.ZNC_PROXY_URL);
}
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);
const users = new Set(await storage.getItem('NintendoAccountIds') ?? []);
users.add(data.user.id);
await storage.setItem('NintendoAccountIds', [...users]);
let icon = undefined;
try {
const response = await fetch(data.nsoAccount.user.imageUri);
const image = await response.buffer();
icon = nativeImage.createFromBuffer(image);
} catch (err) {}
new Notification({
title: 'Nintendo Switch Online',
body: 'Authenticated as ' + data.nsoAccount.user.name + ' (NSO ' + data.user.nickname + ')',
icon,
}).show();
return {nso, data};
} finally {
window?.close();
}
}
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) {
const {code, verifier, window} = await getSessionTokenCode(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);
}
const token = await getNintendoAccountSessionToken(code, verifier, ZNMA_CLIENT_ID);
debug('session token', token);
const {moon, data} = await getPctlToken(storage, token.session_token);
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();
}
}

View File

@ -0,0 +1,79 @@
import createDebug from 'debug';
import { BrowserWindow, session, shell } from '../electron.js';
import ZncApi from '../../api/znc.js';
import { SavedToken } from '../../util.js';
import { WebService } from '../../api/znc-types.js';
const debug = createDebug('app:main:webservices');
export function createWebServiceWindow(na_id: string, webservice: WebService) {
const browser_session = session.fromPartition('webservices-' + na_id, {
cache: false,
});
const window = new BrowserWindow({
width: 375,
height: 667,
resizable: false,
title: webservice.name,
webPreferences: {
session: browser_session,
scrollBounce: true,
},
});
return window;
}
const windows = new Map<string, BrowserWindow>();
export default async function openWebService(nso: ZncApi, data: SavedToken, webservice: WebService) {
const windowid = data.user.id + ':' + webservice.id;
if (windows.has(windowid)) {
const window = windows.get(windowid)!;
window.focus();
return;
}
const window = createWebServiceWindow(data.user.id, webservice);
window.on('closed', () => {
windows.delete(windowid);
});
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 => {
debug('open', details);
shell.openExternal(details.url);
return {action: 'deny'};
});
const webserviceToken = await nso.getWebServiceToken('' + webservice.id);
const url = new URL(webservice.uri);
url.search = new URLSearchParams({
lang: data.user.language,
na_country: data.user.country,
na_lang: data.user.language,
}).toString();
debug('Loading web service', {
url: url.toString(),
webservice,
webserviceToken,
});
window.webContents.openDevTools();
window.loadURL(url.toString(), {
extraHeaders: Object.entries({
'x-gamewebtoken': webserviceToken.result.accessToken,
'dnt': '1',
'X-Requested-With': 'com.nintendo.znca',
}).map(([key, value]) => key + ': ' + value).join('\n'),
});
}

View File

@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron';
import * as path from 'path';
import * as EventEmitter from 'events';
import { SavedMoonToken, SavedToken } from '../../util.js';
const ipc = {
@ -8,8 +8,12 @@ const ipc = {
getSavedNsoToken: (token: string) => ipcRenderer.invoke('nxapi:nso:getcachedtoken', token) as Promise<SavedToken | undefined>,
getNintendoAccountMoonToken: (id: string) => ipcRenderer.invoke('nxapi:moon:gettoken', id) as Promise<string | undefined>,
getSavedMoonToken: (token: string) => ipcRenderer.invoke('nxapi:moon:getcachedtoken', token) as Promise<SavedMoonToken | undefined>,
events: new EventEmitter(),
};
export type NxapiElectronIpc = typeof ipc;
ipcRenderer.on('nxapi:accounts:shouldrefresh', () => ipc.events.emit('update-nintendo-accounts'));
contextBridge.exposeInMainWorld('nxapiElectronIpc', ipc);

View File

@ -8,6 +8,13 @@
"declaration": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true
}
"skipLibCheck": true,
"allowJs": true
},
"exclude": [
"dist",
"bin",
"rollup.config.js",
"data"
]
}