From 5aba9928997f49b1b387e521d7ddfc899cdc0651 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Wed, 16 Mar 2022 20:14:33 +0000 Subject: [PATCH] Add a command to get a session token --- src/api/na.ts | 34 +++++++++++++++++ src/cli/auth.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 1 + src/cli/token.ts | 30 ++++++--------- 4 files changed, 145 insertions(+), 19 deletions(-) create mode 100644 src/cli/auth.ts diff --git a/src/api/na.ts b/src/api/na.ts index d081c61..cceb5c1 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -4,6 +4,35 @@ import { ErrorResponse } from './util.js'; const debug = createDebug('api:na'); +export async function getNintendoAccountSessionToken(code: string, verifier: string, client_id: string) { + debug('Getting Nintendo Account session token'); + + const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/session_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Platform': 'Android', + 'X-ProductVersion': '2.0.0', + 'User-Agent': 'NASDKAPI; Android', + }, + body: new URLSearchParams({ + client_id, + session_token_code: code, + session_token_code_verifier: verifier, + }).toString(), + }); + + const token = await response.json() as NintendoAccountSessionToken | NintendoAccountError; + + if ('errorCode' in token) { + throw new ErrorResponse('[na] + ' + token.detail, response, token); + } + + debug('Got Nintendo Account session token', token); + + return token; +} + export async function getNintendoAccountToken(token: string) { debug('Getting Nintendo Account token'); @@ -56,6 +85,11 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) { return user; } +export interface NintendoAccountSessionToken { + session_token: string; + code: string; +} + export interface NintendoAccountToken { scope: ['openid', 'user', 'user.birthday', 'user.mii', 'user.screenName']; token_type: 'Bearer'; diff --git a/src/cli/auth.ts b/src/cli/auth.ts new file mode 100644 index 0000000..f19467d --- /dev/null +++ b/src/cli/auth.ts @@ -0,0 +1,99 @@ +import * as util from 'util'; +import createDebug from 'debug'; +import * as crypto from 'crypto'; +import type { Arguments as ParentArguments } from '../cli.js'; +import { ArgumentsCamelCase, Argv, getToken, initStorage, YargsArguments } from '../util.js'; +import { getNintendoAccountSessionToken } from '../api/na.js'; + +const debug = createDebug('cli:auth'); + +export const command = 'auth'; +export const desc = 'Generate a link to login to a Nintendo Account'; + +export function builder(yargs: Argv) { + return yargs.option('auth', { + describe: 'Authenticate immediately', + type: 'boolean', + default: true, + }).option('select', { + describe: 'Set as default user (default: true if only user)', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + 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: 'npf71b963c1b7b6d119://auth', + client_id: '71b963c1b7b6d119', + scope: 'openid user user.birthday user.mii user.screenName', + response_type: 'session_token_code', + session_token_code_challenge: challenge, + session_token_code_challenge_method: 'S256', + theme: 'login_form', + }; + + const authoriseurl = 'https://accounts.nintendo.com/connect/1.0.0/authorize?' + + new URLSearchParams(params).toString(); + + debug('Authentication parameters', { + state, + verifier, + challenge, + }, params); + + console.log('1. Open this URL and login to your Nintendo Account:'); + console.log(''); + console.log(authoriseurl); + console.log(''); + + console.log('2. On the "Linking an External Account" page, right click "Select this person" and copy the link. It should start with "npf71b963c1b7b6d119://auth".'); + console.log(''); + + const read = await import('read'); + // @ts-expect-error + const prompt = util.promisify(read.default as typeof read); + + const applink = await prompt({ + prompt: `Paste the link: `, + output: process.stderr, + }); + + console.log(''); + + const authorisedurl = new URL(applink); + const authorisedparams = new URLSearchParams(authorisedurl.hash.substr(1)); + debug('Redirect URL parameters', [...authorisedparams.entries()]); + + const token = await getNintendoAccountSessionToken( + authorisedparams.get('session_token_code')!, verifier, '71b963c1b7b6d119'); + + console.log('Session token', token); + + if (argv.auth) { + const storage = await initStorage(argv.dataPath); + + const {nso, data} = await getToken(storage, token.session_token, argv.zncProxyUrl); + + console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)', + data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + + await storage.setItem('NintendoAccountToken.' + data.user.id, token.session_token); + + const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + users.add(data.user.id); + await storage.setItem('NintendoAccountIds', [...users]); + + if ('select' in argv ? argv.select : users.size === 1) { + await storage.setItem('SelectedUser', data.user.id); + + console.log('Set as default user'); + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e09d7d0..ddadff2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,5 @@ export * as token from './token.js'; +export * as auth from './auth.js'; export * as users from './users.js'; export * as user from './user.js'; export * as announcements from './announcements.js'; diff --git a/src/cli/token.ts b/src/cli/token.ts index db43772..e5917ca 100644 --- a/src/cli/token.ts +++ b/src/cli/token.ts @@ -12,10 +12,6 @@ export function builder(yargs: Argv) { return yargs.positional('token', { describe: 'Nintendo Account session token (it is recommended this is not set and you enter it interactively)', type: 'string', - }).option('auth', { - describe: 'Authenticate immediately', - type: 'boolean', - default: true, }).option('select', { describe: 'Set as default user (default: true if only user)', type: 'boolean', @@ -39,26 +35,22 @@ export async function handler(argv: ArgumentsCamelCase) { }); } - await storage.setItem('SessionToken', argv.token); + const {nso, data} = await getToken(storage, argv.token, argv.zncProxyUrl); - if (argv.auth) { - const {nso, data} = await getToken(storage, argv.token, argv.zncProxyUrl); + console.warn('Authenticated as Nintendo Account %s (NA %s, NSO %s)', + data.user.screenName, data.user.nickname, data.nsoAccount.user.name); - console.log('Authenticated as Nintendo Account %s (NA %s, NSO %s)', - data.user.screenName, data.user.nickname, data.nsoAccount.user.name); + await storage.setItem('NintendoAccountToken.' + data.user.id, argv.token); - await storage.setItem('NintendoAccountToken.' + data.user.id, argv.token); + const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); + users.add(data.user.id); + await storage.setItem('NintendoAccountIds', [...users]); - const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); - users.add(data.user.id); - await storage.setItem('NintendoAccountIds', [...users]); + console.log('Saved token'); - if ('select' in argv ? argv.select : users.size === 1) { - await storage.setItem('SelectedUser', data.user.id); + if ('select' in argv ? argv.select : users.size === 1) { + await storage.setItem('SelectedUser', data.user.id); - console.log('Set as default user'); - } - } else { - console.log('Saved token'); + console.log('Set as default user'); } }