Add API proxy access tokens

This allows providing access to the API proxy server as your account but only to access certain data (probably user/friend presence for Discord).

For example a token generated using `nxapi nso znc-proxy-tokens --znc-proxy-url "http://[::1]:12345/api/znc" create --policy-user-presence` will only be able to access your presence at http://[::1]:12345/api/znc/user/presence (with the access token provided in the Authorization header as a Bearer token or in the token query string parameter).
This commit is contained in:
Samuel Elliott
2022-03-22 23:39:56 +00:00
parent 5b432ab517
commit 1a36947720
3 changed files with 381 additions and 16 deletions

View File

@@ -1,7 +1,9 @@
import createDebug from 'debug';
import express from 'express';
import express, { Request, Response } from 'express';
import bodyParser from 'body-parser';
import * as net from 'net';
import persist from 'node-persist';
import { v4 as uuidgen } from 'uuid';
import { ActiveEvent, Announcement, CurrentUser, Friend, Presence, WebService } from '../../api/znc-types.js';
import ZncApi from '../../api/znc.js';
import type { Arguments as ParentArguments } from '../nso.js';
@@ -13,10 +15,34 @@ declare global {
interface Request {
znc?: ZncApi;
zncAuth?: SavedToken;
zncAuthPolicy?: AuthPolicy;
zncAuthPolicyUser?: string;
zncAuthPolicyToken?: string;
}
}
}
export interface AuthToken {
user: string;
policy?: AuthPolicy;
created_at: number;
}
export interface AuthPolicy {
announcements?: boolean;
list_friends?: boolean;
list_friends_presence?: boolean;
friend?: boolean;
friend_presence?: boolean;
webservices?: boolean;
activeevent?: boolean;
current_user?: boolean;
current_user_presence?: boolean;
friends?: string[];
friends_presence?: string[];
}
const debug = createDebug('cli:nso:http-server');
export const command = 'http-server';
@@ -68,11 +94,47 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
next();
};
const authToken: express.RequestHandler = async (req, res, next) => {
if (req.headers['authorization']?.startsWith('Bearer ')) {
const token = req.headers['authorization'].substr(7);
const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + token);
if (!auth) return next();
req.zncAuthPolicy = auth.policy;
req.zncAuthPolicyUser = auth.user;
req.zncAuthPolicyToken = token;
} else if (req.query.token) {
const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + req.query.token);
if (!auth) return next();
req.zncAuthPolicy = auth.policy;
req.zncAuthPolicyUser = auth.user;
req.zncAuthPolicyToken = '' + req.query.token;
}
next();
};
function tokenUnauthorised(req: Request, res: Response) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
error: 'token_unauthorised',
}));
}
const nsoAuth: express.RequestHandler = async (req, res, next) => {
try {
const auth = req.headers['authorization'];
if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication');
const nintendoAccountSessionToken = auth.substr(3);
let nintendoAccountSessionToken: string;
if (req.zncAuthPolicyUser) {
const na_token = await storage.getItem('NintendoAccountToken.' + req.zncAuthPolicyUser);
if (!na_token) throw new Error('Nintendo Account for this token must reauthenticate');
nintendoAccountSessionToken = na_token;
} else {
const auth = req.headers['authorization'];
if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication');
nintendoAccountSessionToken = auth.substr(3);
}
const {nso, data} = await getToken(storage, nintendoAccountSessionToken, argv.zncProxyUrl);
@@ -99,13 +161,82 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
res.end(JSON.stringify(req.zncAuth));
});
app.get('/api/znc/token', authToken, (req, res) => {
if (!req.zncAuthPolicyToken) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: 'no_policy'}));
return;
}
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(req.zncAuthPolicy));
});
app.delete('/api/znc/token', authToken, async (req, res) => {
if (!req.zncAuthPolicyToken) {
res.statusCode = 403;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: 'no_policy'}));
return;
}
await storage.removeItem('ZncProxyAuthPolicy.' + req.zncAuthPolicyToken!);
const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser) ?? []);
tokens.delete(req.zncAuthPolicyToken);
await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser, [...tokens]);
res.statusCode = 204;
res.end();
});
app.get('/api/znc/tokens', nsoAuth, async (req, res) => {
const token_ids: string[] | undefined = await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id);
const tokens = (await Promise.all(token_ids?.map(async id => {
const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + id);
if (!auth) return;
return {
token: id,
user: auth.user,
policy: auth.policy,
created_at: auth.created_at,
};
}) ?? [])).filter(p => p);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({tokens}));
});
app.post('/api/znc/tokens', nsoAuth, bodyParser.json(), async (req, res) => {
const token = uuidgen();
const auth: AuthToken = {
user: req.zncAuth!.user.id,
policy: req.body.policy,
created_at: Math.floor(Date.now() / 1000),
};
await storage.setItem('ZncProxyAuthPolicy.' + token, auth);
const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id) ?? []);
tokens.add(token);
await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id, [...tokens]);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
token,
...auth,
}));
});
//
// Announcements
// This is cached for all users.
//
let cached_announcements: Announcement[] | null = null;
app.get('/api/znc/announcements', localAuth, nsoAuth, async (req, res) => {
app.get('/api/znc/announcements', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.announcements) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, async (req, res) => {
if (cached_announcements) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({
@@ -167,18 +298,36 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
};
app.get('/api/znc/friends', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/friends', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.list_friends) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({friends, updated}));
res.end(JSON.stringify({
friends: req.zncAuthPolicy?.friends ?
friends.filter(f => req.zncAuthPolicy!.friends!.includes(f.nsaId)) : friends,
updated,
}));
});
app.get('/api/znc/friends/presence', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/friends/presence', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
const presence: Record<string, Presence> = {};
for (const friend of friends) {
if (req.zncAuthPolicy) {
const p = req.zncAuthPolicy;
if (p.friends_presence && !p.friends_presence.includes(friend.nsaId)) continue;
if (p.friends && !p.friends_presence && !p.friends.includes(friend.nsaId)) continue;
}
presence[friend.nsaId] = friend.presence;
}
@@ -186,9 +335,14 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
res.end(JSON.stringify(presence));
});
app.get('/api/znc/friend/:nsid', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/friend/:nsaid', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.friend) return tokenUnauthorised(req, res);
if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
const friend = friends.find(f => f.nsaId === req.params.nsid);
const friend = friends.find(f => f.nsaId === req.params.nsaid);
if (!friend) {
res.statusCode = 404;
@@ -204,9 +358,15 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
res.end(JSON.stringify({friend, updated}));
});
app.get('/api/znc/friend/:nsid/presence', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/friend/:nsaid/presence', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.friend_presence) return tokenUnauthorised(req, res);
if (req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends_presence.includes(req.params.nsaid)) return tokenUnauthorised(req, res);
if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
const friend = friends.find(f => f.nsaId === req.params.nsid);
const friend = friends.find(f => f.nsaId === req.params.nsaid);
if (!friend) {
res.statusCode = 404;
@@ -222,7 +382,11 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
res.end(JSON.stringify(friend.presence));
});
app.get('/api/znc/webservices', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/webservices', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.webservices) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
res.setHeader('Content-Type', 'application/json');
@@ -247,7 +411,11 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
});
app.get('/api/znc/activeevent', localAuth, nsoAuth, getAppData, async (req, res) => {
app.get('/api/znc/activeevent', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.activeevent) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getAppData, async (req, res) => {
const [friends, webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!;
res.setHeader('Content-Type', 'application/json');
@@ -284,14 +452,23 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
};
app.get('/api/znc/user', localAuth, nsoAuth, getUserData, async (req, res) => {
app.get('/api/znc/user', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getUserData, async (req, res) => {
const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({user, updated}));
});
app.get('/api/znc/user/presence', localAuth, nsoAuth, getUserData, async (req, res) => {
app.get('/api/znc/user/presence', authToken, (req, res, next) => {
if (!req.zncAuthPolicy) return next();
if (!req.zncAuthPolicy.current_user_presence) return tokenUnauthorised(req, res);
if (!('current_user_presence' in req.zncAuthPolicy) && !req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res);
next();
}, localAuth, nsoAuth, getUserData, async (req, res) => {
const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!;
res.setHeader('Content-Type', 'application/json');
@@ -359,6 +536,24 @@ class ZncPresenceEventStream extends ZncNotifications {
userNotifications: user,
'friend-notifications': friend,
friendNotifications: friend,
'splatnet2-monitor-directory': undefined,
splatnet2MonitorDirectory: undefined,
'splatnet2-monitor-profile-image': false,
splatnet2MonitorProfileImage: false,
'splatnet2-monitor-favourite-stage': undefined,
splatnet2MonitorFavouriteStage: undefined,
'splatnet2-monitor-favourite-colour': undefined,
splatnet2MonitorFavouriteColour: undefined,
'splatnet2-monitor-battles': false,
splatnet2MonitorBattles: false,
'splatnet2-monitor-battle-summary-image': false,
splatnet2MonitorBattleSummaryImage: false,
'splatnet2-monitor-battle-images': false,
splatnet2MonitorBattleImages: false,
'splatnet2-monitor-coop': false,
splatnet2MonitorCoop: false,
'splatnet2-monitor-update-interval': 180,
splatnet2MonitorUpdateInterval: 180,
}, storage, token, nso, data);
}

View File

@@ -8,3 +8,4 @@ export * as friends from './friends.js';
export * as presence from './presence.js';
export * as notify from './notify.js';
export * as httpServer from './http-server.js';
export * as zncProxyTokens from './znc-proxy-tokens.js';

View File

@@ -0,0 +1,169 @@
import createDebug from 'debug';
import fetch from 'node-fetch';
// @ts-expect-error
import Table from 'cli-table/lib/index.js';
import type { Arguments as ParentArguments } from '../nso.js';
import { Argv, getToken, initStorage } from '../../util.js';
import { AuthPolicy, AuthToken } from './http-server.js';
const debug = createDebug('cli:nso:znc-proxy-tokens');
export const command = 'znc-proxy-tokens <command>';
export const desc = 'Manage access tokens for `nxapi nso http-server`';
interface AuthTokens {
tokens: ({
token: string;
} & AuthToken)[];
}
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',
}).command('list', 'Lists access tokens', () => {}, async argv => {
if (!argv.zncProxyUrl) {
throw new Error('Requires --znc-proxy-url');
}
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid);
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
const tokens = await nso.fetch<AuthTokens>('/tokens');
const table = new Table({
head: [
'Token',
'Created at',
'Access policy',
],
});
for (const auth of tokens.tokens) {
table.push([
auth.token,
new Date(auth.created_at * 1000).toISOString(),
JSON.stringify(auth.policy, null, 2) ?? 'No policy',
]);
}
if (!table.length) {
console.warn('No access tokens');
return;
}
console.log(table.toString());
}).command('create', 'Creates an access token', yargs => {
return yargs.option('policy', {
describe: 'Restrict allowed actions',
type: 'boolean',
default: true,
}).option('policy-announcements', {
describe: 'Allow access to /announcements',
type: 'boolean',
default: false,
}).option('policy-list-friends', {
describe: 'Allow access to /friends',
type: 'boolean',
default: false,
}).option('policy-list-friends-presence', {
describe: 'Allow access to /friends/presence',
type: 'boolean',
default: false,
}).option('policy-friend', {
describe: 'Allow access to /friend/:id',
type: 'boolean',
default: false,
}).option('policy-friend-presence', {
describe: 'Allow access to /friend/:id/presence',
type: 'boolean',
default: false,
}).option('policy-webservices', {
describe: 'Allow access to /webservices',
type: 'boolean',
default: false,
}).option('policy-activeevent', {
describe: 'Allow access to /activeevent',
type: 'boolean',
default: false,
}).option('policy-user', {
describe: 'Allow access to /user',
type: 'boolean',
default: false,
}).option('policy-user-presence', {
describe: 'Allow access to /user/presence',
type: 'boolean',
default: false,
}).option('policy-friends', {
describe: 'Restrict friends to listed Nintendo Switch account IDs',
type: 'array',
}).option('policy-friends-presence', {
describe: 'Restrict friends to listed Nintendo Switch account IDs for presence only',
type: 'array',
});
}, async argv => {
if (!argv.zncProxyUrl) {
throw new Error('Requires --znc-proxy-url');
}
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
const token: string = argv.token ||
await storage.getItem('NintendoAccountToken.' + usernsid);
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
const policy: AuthPolicy | null = argv.policy ? {
announcements: argv.policyAnnouncements,
list_friends: argv.policyListFriends,
list_friends_presence: argv.policyListFriendsPresence,
friend: argv.policyFriend,
friend_presence: argv.policyFriendPresence,
webservices: argv.policyWebservices,
activeevent: argv.policyActiveevent,
current_user: argv.policyUser,
current_user_presence: argv.policyUserPresence,
friends: argv.policyFriends as string[] | undefined,
friends_presence: argv.policyFriendsPresence as string[] | undefined,
} : null;
const auth = await nso.fetch<{token: string;} & AuthToken>('/tokens', 'POST', JSON.stringify({policy}), {
'Content-Type': 'application/json',
});
console.warn('Created access token', auth);
console.log(auth.token);
}).command('revoke <token>', 'Deletes an access tokens', yargs => {
return yargs.positional('token', {
describe: 'Access token to delete',
type: 'string',
demandOption: true,
});
}, async argv => {
if (!argv.zncProxyUrl) {
throw new Error('Requires --znc-proxy-url');
}
const response = await fetch(argv.zncProxyUrl + '/token', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + argv.token,
},
});
debug('fetch %s %s, response %d', 'DELETE', '/token', response.status);
if (response.status !== 204) {
throw new Error('Unknown error ' + response.status);
}
console.warn('Deleted access token');
});
}