fix: make refresh token expire longer than access token, and centralise oauth generation

This commit is contained in:
William Oldham 2025-01-29 22:35:42 +00:00
parent d28ccbdf95
commit 1ecb7ad473
6 changed files with 110 additions and 163 deletions

View File

@ -1,8 +1,7 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import { nintendoPasswordHash, generateOAuthTokens} from '@/util';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
const router = express.Router();
@ -109,38 +108,23 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
try {
const systemType = 0x3; // * API
const { accessToken, refreshToken, accessTokenExpiresInSecs } = generateOAuthTokens(systemType, pnid);
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken
});
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: accessTokenExpiresInSecs,
refresh_token: refreshToken
});
} catch {
response.status(500).json({
app: 'api',
status: 500,
error: 'Internal server error'
});
}
});
export default router;

View File

@ -7,7 +7,7 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@ -366,38 +366,23 @@ router.post('/', async (request: express.Request, response: express.Response): P
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
try {
const systemType = 0x3 // * API
const { accessToken, refreshToken, accessTokenExpiresInSecs } = generateOAuthTokens(systemType, pnid);
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken
});
response.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: accessTokenExpiresInSecs,
refresh_token: refreshToken
});
} catch {
response.status(500).json({
app: 'api',
status: 500,
error: 'Internal server error'
});
}
});
export default router;

View File

@ -2,8 +2,7 @@ import { Status, ServerError } from 'nice-grpc';
import { LoginRequest, LoginResponse, DeepPartial } from '@pretendonetwork/grpc/api/login_rpc';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import { nintendoPasswordHash, generateOAuthTokens} from '@/util';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
export async function login(request: LoginRequest): Promise<DeepPartial<LoginResponse>> {
@ -54,36 +53,17 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
try {
const systemType = 0x3; // * API
const { accessToken, refreshToken, accessTokenExpiresInSecs } = generateOAuthTokens(systemType, pnid);
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: newRefreshToken
};
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: accessTokenExpiresInSecs,
refreshToken: refreshToken
};
} catch {
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
}
}

View File

@ -8,7 +8,7 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
@ -229,36 +229,17 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
await sendConfirmationEmail(pnid);
const accessTokenOptions = {
system_type: 0x3, // * API
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
try {
const systemType = 0x3 // * API
const { accessToken, refreshToken, accessTokenExpiresInSecs } = generateOAuthTokens(systemType, pnid);
const refreshTokenOptions = {
system_type: 0x3, // * API
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const refreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: 3600,
refreshToken: refreshToken
};
return {
accessToken: accessToken,
tokenType: 'Bearer',
expiresIn: accessTokenExpiresInSecs,
refreshToken: refreshToken
}
} catch {
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
}
}

View File

@ -4,8 +4,7 @@ import bcrypt from 'bcrypt';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import consoleStatusVerificationMiddleware from '@/middleware/console-status-verification';
import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database';
import { generateToken } from '@/util';
import { config } from '@/config-manager';
import { generateOAuthTokens } from '@/util';
import { Device } from '@/models/device';
const router = express.Router();
@ -153,37 +152,22 @@ router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatus
return;
}
const accessTokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
try {
const systemType = 0x1; // * WiiU
const { accessToken, refreshToken, accessTokenExpiresInSecs } = generateOAuthTokens(systemType, pnid);
const refreshTokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const accessTokenBuffer = await generateToken(config.aes_key, accessTokenOptions);
const refreshTokenBuffer = await generateToken(config.aes_key, refreshTokenOptions);
const accessToken = accessTokenBuffer ? accessTokenBuffer.toString('hex') : '';
const newRefreshToken = refreshTokenBuffer ? refreshTokenBuffer.toString('hex') : '';
// TODO - Handle null tokens
response.send(xmlbuilder.create({
OAuth20: {
access_token: {
token: accessToken,
refresh_token: newRefreshToken,
expires_in: 3600
response.send(xmlbuilder.create({
OAuth20: {
access_token: {
token: accessToken,
refresh_token: refreshToken,
expires_in: accessTokenExpiresInSecs
}
}
}
}).commentBefore('WARNING! DO NOT SHARE ANYTHING IN THIS REQUEST OR RESPONSE WITH UNTRUSTED USERS! IT CAN BE USED TO IMPERSONATE YOU AND YOUR CONSOLE, POTENTIALLY GETTING YOU BANNED!').end()); // TODO - This is ugly
}).commentBefore('WARNING! DO NOT SHARE ANYTHING IN THIS REQUEST OR RESPONSE WITH UNTRUSTED USERS! IT CAN BE USED TO IMPERSONATE YOU AND YOUR CONSOLE, POTENTIALLY GETTING YOU BANNED!').end()); // TODO - This is ugly
} catch {
response.status(500);
}
});
export default router;

View File

@ -12,7 +12,7 @@ import { sendMail } from '@/mailer';
import { config, disabledFeatures } from '@/config-manager';
import { TokenOptions } from '@/types/common/token-options';
import { Token } from '@/types/common/token';
import { IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import { HydratedPNIDDocument, IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import { SafeQs } from '@/types/common/safe-qs';
let s3: S3;
@ -53,6 +53,39 @@ export function nintendoBase64Encode(decoded: string | Buffer): string {
return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*');
}
export function generateOAuthTokens(systemType: number, pnid: HydratedPNIDDocument) {
const accessTokenExpiresInSecs = 60 * 60; // * 1 hour
const accessTokenOptions = {
system_type: systemType,
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
access_level: pnid.access_level,
expire_time: BigInt(Date.now() + (accessTokenExpiresInSecs * 1000)) // * 1 hour
};
const refreshTokenOptions = {
system_type: systemType,
token_type: 0x2, // * OAuth Refresh
pid: pnid.pid,
access_level: pnid.access_level,
expire_time: BigInt(Date.now() + (30 * 24 * 60 * 60 * 1000)) // * 30 days
};
const accessToken = generateToken(config.aes_key, accessTokenOptions)?.toString('hex');
const refreshToken = generateToken(config.aes_key, refreshTokenOptions)?.toString('hex');
if (!accessToken) throw new Error('Failed to generate access token');
if (!refreshToken) throw new Error('Failed to generate refresh token');
return {
accessToken,
accessTokenExpiresInSecs,
refreshToken
};
}
export function generateToken(key: string, options: TokenOptions): Buffer | null {
let dataBuffer = Buffer.alloc(1 + 1 + 4 + 8);
@ -234,7 +267,7 @@ export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IP
expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // * Only valid for 24 hours
};
const tokenBuffer = await generateToken(config.aes_key, tokenOptions);
const tokenBuffer = generateToken(config.aes_key, tokenOptions);
const passwordResetToken = tokenBuffer ? tokenBuffer.toString('hex') : '';
// TODO - Handle null token