fix: Seperate token checks by type
Some checks failed
Build and Publish Docker Image / Build and Publish Docker Image (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish Docker Image (arm64) (push) Has been cancelled

Give each expected token type a seperate function and un-export the "generic" one.

This required deciding what the "expected" token type is for all endpoints. Possibly contraversial choices are:

- API tokens only on the HTTP API (no service tokens)
- API tokens only on the gRPC APIs
- API tokens only on ExchangeTokenForUserData (I could only find 1 user of this API - BOSS - and they use API tokens here)

Notably, there is now no way to get the account server to validate a service token - services must decrypt them locally. We should probably add an API for this for the benefit of third parties.
This commit is contained in:
Ash Logan 2025-06-22 19:54:27 +10:00
parent fc1963363a
commit b9cf3194fc
11 changed files with 43 additions and 32 deletions

View File

@ -5,8 +5,9 @@ import { nintendoPasswordHash, decryptToken, unpackToken } from '@/util';
import { PNID } from '@/models/pnid';
import { Server } from '@/models/server';
import { LOG_ERROR } from '@/logger';
import { TokenType } from '@/types/common/token-types';
import { config } from '@/config-manager';
import { TokenType } from '@/types/common/token-types';
import { SystemType } from '@/types/common/system-types';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import type { IDeviceAttribute } from '@/types/mongoose/device-attribute';
import type { HydratedServerDocument } from '@/types/mongoose/server';
@ -14,7 +15,6 @@ import type { PNIDProfile } from '@/types/services/nnas/pnid-profile';
import type { ConnectionData } from '@/types/services/api/connection-data';
import type { ConnectionResponse } from '@/types/services/api/connection-response';
import type { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
import type { SystemType } from '@/types/common/system-types';
const connection_string = config.mongoose.connection_string;
const options = config.mongoose.options;
@ -106,25 +106,22 @@ export async function getPNIDByBasicAuth(token: string): Promise<HydratedPNIDDoc
return pnid;
}
export async function getPNIDByTokenAuth(token: string, allowedTypes?: SystemType[]): Promise<HydratedPNIDDocument | null> {
async function getPNIDByOAuthToken(token: string, expectedSystemTypes: SystemType[], expectedTokenType: TokenType): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
if (allowedTypes && !allowedTypes.includes(unpackedToken.system_type)) {
if (!expectedSystemTypes.includes(unpackedToken.system_type)) {
return null;
}
if (unpackedToken.token_type !== expectedTokenType) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
// TODO - This is a hack. If `allowedTypes` is not set, it is assumed to be a call from our internal API,
// and we ignore expiration checks on service tokens. Independent services should expire their own tokens
if (unpackedToken.token_type === TokenType.IndependentService && !allowedTypes) {
return pnid;
}
if (pnid) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
@ -141,6 +138,22 @@ export async function getPNIDByTokenAuth(token: string, allowedTypes?: SystemTyp
}
}
export async function getPNIDByNNASAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
return getPNIDByOAuthToken(token, [SystemType.WUP, SystemType.CTR], TokenType.OAuthAccess);
}
export async function getPNIDByNNASRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
return getPNIDByOAuthToken(token, [SystemType.WUP, SystemType.CTR], TokenType.OAuthRefresh);
}
export async function getPNIDByAPIAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
return getPNIDByOAuthToken(token, [SystemType.API], TokenType.OAuthAccess);
}
export async function getPNIDByAPIRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
return getPNIDByOAuthToken(token, [SystemType.API], TokenType.OAuthRefresh);
}
export async function getPNIDProfileJSONByPID(pid: number): Promise<PNIDProfile | null> {
verifyConnected();

View File

@ -1,5 +1,5 @@
import { getValueFromHeaders } from '@/util';
import { getPNIDByTokenAuth } from '@/database';
import { getPNIDByAPIAccessToken } from '@/database';
import { LOG_ERROR } from '@/logger';
import type express from 'express';
async function APIMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): Promise<void> {
@ -11,7 +11,7 @@ async function APIMiddleware(request: express.Request, _response: express.Respon
try {
const token = authHeader.split(' ')[1];
const pnid = await getPNIDByTokenAuth(token);
const pnid = await getPNIDByAPIAccessToken(token);
request.pnid = pnid;
} catch (error: any) {

View File

@ -1,7 +1,6 @@
import xmlbuilder from 'xmlbuilder';
import { SystemType } from '@/types/common/system-types';
import { getValueFromHeaders } from '@/util';
import { getPNIDByBasicAuth, getPNIDByTokenAuth } from '@/database';
import { getPNIDByBasicAuth, getPNIDByNNASAccessToken } from '@/database';
import type express from 'express';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
@ -24,8 +23,7 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
if (type === 'Basic' && request.path.includes('v1/api/people/@me/devices')) {
pnid = await getPNIDByBasicAuth(token);
} else if (type === 'Bearer') {
// TODO - This "accepted types list" is mostly a hack. Change this
pnid = await getPNIDByTokenAuth(token, [SystemType.WUP, SystemType.CTR]);
pnid = await getPNIDByNNASAccessToken(token);
}
if (!pnid) {

View File

@ -1,6 +1,6 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@ -89,7 +89,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken);
pnid = await getPNIDByAPIRefreshToken(refreshToken);
if (!pnid) {
response.status(400).json({

View File

@ -1,5 +1,5 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import { getPNIDByAPIAccessToken } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/account/get_user_data_rpc';
@ -10,7 +10,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
const pnid = await getPNIDByTokenAuth(request.token);
const pnid = await getPNIDByAPIAccessToken(request.token);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');

View File

@ -1,5 +1,5 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import { getPNIDByAPIAccessToken } from '@/database';
import { PNID_PERMISSION_FLAGS } from '@/types/common/permission-flags';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
@ -11,7 +11,7 @@ export async function exchangeTokenForUserData(request: ExchangeTokenForUserData
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');
}
const pnid = await getPNIDByTokenAuth(request.token);
const pnid = await getPNIDByAPIAccessToken(request.token);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid token');

View File

@ -1,5 +1,5 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import { getPNIDByAPIAccessToken } from '@/database';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
@ -31,7 +31,7 @@ export async function* authenticationMiddleware<Request, Response>(
let pnid = null;
if (token) {
pnid = await getPNIDByTokenAuth(token);
pnid = await getPNIDByAPIAccessToken(token);
}
if (!pnid && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {

View File

@ -1,6 +1,6 @@
import { Status, ServerError } from 'nice-grpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@ -45,7 +45,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
pnid = await getPNIDByAPIRefreshToken(refreshToken!); // * We know refreshToken will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');

View File

@ -1,5 +1,5 @@
import { Status, ServerError } from 'nice-grpc';
import { getPNIDByTokenAuth } from '@/database';
import { getPNIDByAPIAccessToken } from '@/database';
import type { ServerMiddlewareCall, CallContext } from 'nice-grpc';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
@ -31,7 +31,7 @@ export async function* authenticationMiddleware<Request, Response>(
let pnid = null;
if (token) {
pnid = await getPNIDByTokenAuth(token);
pnid = await getPNIDByAPIAccessToken(token);
}
if (!pnid && TOKEN_REQUIRED_PATHS.includes(call.method.path)) {

View File

@ -1,6 +1,6 @@
import { Status, ServerError } from 'nice-grpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken } from '@/util';
import { config } from '@/config-manager';
import { SystemType } from '@/types/common/system-types';
@ -45,7 +45,7 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
} else {
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
pnid = await getPNIDByAPIRefreshToken(refreshToken!); // * We know refreshToken will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');

View File

@ -3,7 +3,7 @@ import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import consoleStatusVerificationMiddleware from '@/middleware/console-status-verification';
import { getPNIDByTokenAuth, getPNIDByUsername } from '@/database';
import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database';
import { generateToken } from '@/util';
import { SystemType } from '@/types/common/system-types';
import { TokenType } from '@/types/common/token-types';
@ -90,7 +90,7 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
}
try {
pnid = await getPNIDByTokenAuth(refreshToken);
pnid = await getPNIDByNNASRefreshToken(refreshToken);
if (!pnid) {
response.status(400).send(xmlbuilder.create({