chore: revert auth-update changes and hotpatches related

Commits:
- f349b9de42
- b9966807c3
- 0bc3af0507
- 008a517947
This commit is contained in:
William Oldham 2025-05-09 18:08:47 +01:00
parent 056e773fe8
commit 1a3445db5b
27 changed files with 14627 additions and 15026 deletions

View File

@ -13,7 +13,6 @@ import { PNIDProfile } from '@/types/services/nnas/pnid-profile';
import { ConnectionData } from '@/types/services/api/connection-data';
import { ConnectionResponse } from '@/types/services/api/connection-response';
import { DiscordConnectionData } from '@/types/services/api/discord-connection-data';
import { SystemType, TokenType } from '@/types/common/token';
const connection_string = config.mongoose.connection_string;
const options = config.mongoose.options;
@ -105,108 +104,12 @@ export async function getPNIDByBasicAuth(token: string): Promise<HydratedPNIDDoc
return pnid;
}
export async function getPNIDByNNASAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
export async function getPNIDByTokenAuth(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
// * Return if the system type isn't Wii U (NNAS) and the token type isn't "OAuth Access"
if (unpackedToken.system_type !== SystemType.WIIU || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (pnid) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
return pnid;
} catch (error: any) {
// TODO - Handle error
LOG_ERROR(error);
return null;
}
}
export async function getPNIDByNNASRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
// * Return if the system type isn't Wii U (NNAS) and the token type isn't "OAuth Refresh"
if (unpackedToken.system_type !== SystemType.WIIU || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (pnid) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
return pnid;
} catch (error: any) {
// TODO - Handle error
LOG_ERROR(error);
return null;
}
}
export async function getPNIDByAPIAccessToken(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
// * Return if the system type isn't API (REST and gRPC) and the token type isn't "OAuth Access"
if (unpackedToken.system_type !== SystemType.API || unpackedToken.token_type !== TokenType.OAUTH_ACCESS) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (pnid) {
const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000));
if (Math.floor(Date.now() / 1000) > expireTime) {
return null;
}
}
return pnid;
} catch (error: any) {
// TODO - Handle error
LOG_ERROR(error);
return null;
}
}
export async function getPNIDByAPIRefreshToken(token: string): Promise<HydratedPNIDDocument | null> {
verifyConnected();
try {
const decryptedToken = decryptToken(Buffer.from(token, 'hex'));
const unpackedToken = unpackToken(decryptedToken);
// * Return if the system type isn't API (REST and gRPC) and the token type isn't "OAuth Refresh"
if (unpackedToken.system_type !== SystemType.API || unpackedToken.token_type !== TokenType.OAUTH_REFRESH) {
return null;
}
const pnid = await getPNIDByPID(unpackedToken.pid);
if (pnid) {

View File

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

View File

@ -0,0 +1,42 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
const VALID_CLIENT_ID_SECRET_PAIRS: Record<string, string> = {
// * 'Key' is the client ID, 'Value' is the client secret
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Possibly WiiU exclusive?
'daf6227853bcbdce3d75baee8332b': '3eff548eac636e2bf45bb7b375e7b6b0', // * Possibly 3DS exclusive?
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55', // * Possibly 3DS exclusive?
};
function nintendoClientHeaderCheck(request: express.Request, response: express.Response, next: express.NextFunction): void {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const clientID = getValueFromHeaders(request.headers, 'x-nintendo-client-id');
const clientSecret = getValueFromHeaders(request.headers, 'x-nintendo-client-secret');
if (
!clientID ||
!clientSecret ||
!VALID_CLIENT_ID_SECRET_PAIRS[clientID] ||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID]
) {
response.send(xmlbuilder.create({
errors: {
error: {
cause: 'client_id',
code: '0004',
message: 'API application invalid or incorrect application credentials'
}
}
}).end());
return;
}
return next();
}
export default nintendoClientHeaderCheck;

View File

@ -0,0 +1,158 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { Device } from '@/models/device';
import { getValueFromHeaders } from '@/util';
async function consoleStatusVerificationMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
if (!request.certificate || !request.certificate.valid) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
const deviceIDHeader = getValueFromHeaders(request.headers, 'x-nintendo-device-id');
if (!deviceIDHeader) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'deviceId format is invalid'
}
}).end());
return;
}
const deviceID = Number(deviceIDHeader);
if (isNaN(deviceID)) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'deviceId format is invalid'
}
}).end());
return;
}
const serialNumber = getValueFromHeaders(request.headers, 'x-nintendo-serial-number');
// TODO - Verify serial numbers somehow?
// * This is difficult to do safely because serial numbers are
// * inherently insecure.
// * Information about their structure can be found here:
// * https://www.3dbrew.org/wiki/Serials
// * Given this, anyone can generate a valid serial number which
// * passes these checks, even if the serial number isn't real.
// * The 3DS also futher complicates things, as it never sends
// * the complete serial number. The 3DS omits the check digit,
// * meaning any attempt to verify the serial number of a 3DS
// * family of console will ALWAYS fail. Nintendo likely just
// * has a database of all known serials which they are able to
// * compare against. We are not so lucky
if (!serialNumber) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}).end());
return;
}
let device = await Device.findOne({
serial: serialNumber,
});
const certificateHash = crypto.createHash('sha256').update(request.certificate._certificate).digest('base64');
if (!device && request.certificate.consoleType === '3ds') {
// * A 3DS console document will ALWAYS be created by NASC before
// * Hitting the NNAS server. NASC stores the serial number at
// * the time the device document was created. Therefore we can
// * know that serial tampering happened on the 3DS if this fails
// * to find a device document.
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}).end());
return;
} else if (device && !device.certificate_hash && request.certificate.consoleType === '3ds') {
device.certificate_hash = certificateHash;
await device.save();
}
device = await Device.findOne({
certificate_hash: certificateHash,
});
if (!device) {
// * Device must be a fresh Wii U
device = await Device.create({
model: 'wup',
device_id: deviceID,
serial: serialNumber,
linked_pids: [],
certificate_hash: certificateHash
});
}
if (device.serial !== serialNumber) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
const certificateDeviceID = parseInt(request.certificate.certificateName.slice(2).split('-')[0], 16);
if (deviceID !== certificateDeviceID) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
if (device.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0012',
message: 'Device has been banned by game server' // TODO - This is not the right error message
}
}
}).end());
return;
}
request.device = device;
return next();
}
export default consoleStatusVerificationMiddleware;

View File

@ -0,0 +1,17 @@
import express from 'express';
import NintendoCertificate from '@/nintendo-certificate';
import { getValueFromHeaders } from '@/util';
function deviceCertificateMiddleware(request: express.Request, _response: express.Response, next: express.NextFunction): void {
const certificate = getValueFromHeaders(request.headers, 'x-nintendo-device-cert');
if (!certificate) {
return next();
}
request.certificate = new NintendoCertificate(certificate);
return next();
}
export default deviceCertificateMiddleware;

View File

@ -53,7 +53,6 @@ async function NASCMiddleware(request: express.Request, response: express.Respon
return;
}
// TODO - Replace this with https://github.com/PretendoNetwork/nintendo-file-formats maybe?
const cert = new NintendoCertificate(fcdcert);
if (!cert.valid) {

View File

@ -1,264 +0,0 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
const VALID_CLIENT_ID_SECRET_PAIRS: Record<string, string> = {
// * 'Key' is the client ID, 'Value' is the client secret
'a2efa818a34fa16b8afbc8a74eba3eda': 'c91cdb5658bd4954ade78533a339cf9a', // * Wii U
'ea25c66c26b403376b4c5ed94ab9cdea': 'd137be62cb6a2b831cad8c013b92fb55' // * 3DS
};
const SYSTEM_VERSIONS = {
'0': '0320', // * 3DS
'1': '0270' // * Wii U
};
const REGIONS = [
'1', // * JPN
'2', // * USA
'4', // * EUR
'8', // * AUS
'16', // * CHN
'32', // * KOR
'64' // * TWN
];
const DEVICE_ID = /^\d{10}$/; // TODO - Are these ALWAYS 10 digits?
const SERIAL_REGEX = /^[A-Z]{2,3}\d{8,9}$/; // TODO - This is not robust, and may be wrong. See brew wikis (https://www.3dbrew.org/wiki/Serials, https://wiiubrew.org/wiki/Product_Information#Product_Serial_Numbers)
// * Checks only for the existence of common headers and does some sanity checks
function nnasBasicHeaderCheckMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): void {
response.set('Content-Type', 'text/xml');
response.set('Server', 'Nintendo 3DS (http)');
response.set('X-Nintendo-Date', new Date().getTime().toString());
const platformID = request.header('X-Nintendo-Platform-ID');
const deviceType = request.header('X-Nintendo-Device-Type');
const deviceID = request.header('X-Nintendo-Device-ID');
const serialNumber = request.header('X-Nintendo-Serial-Number');
const systemVersion = request.header('X-Nintendo-System-Version');
const region = request.header('X-Nintendo-Region');
const country = request.header('X-Nintendo-Country');
const clientID = request.header('X-Nintendo-Client-ID');
const clientSecret = request.header('X-Nintendo-Client-Secret');
const friendsVersion = request.header('X-Nintendo-FPD-Version');
const environment = request.header('X-Nintendo-Environment');
const titleID = request.header('X-Nintendo-Title-ID');
const uniqueID = request.header('X-Nintendo-Unique-ID');
const applicationVersion = request.header('X-Nintendo-Application-Version');
const model = request.header('X-Nintendo-Device-Model');
const deviceCertificate = request.header('X-Nintendo-Device-Cert');
// * 0 = 3DS, 1 = Wii U
if (platformID === undefined || (platformID !== '0' && platformID !== '1')) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'platformId format is invalid'
}
}
}).end());
return;
}
// * 1 = debug, 2 = retail
if (deviceType === undefined || (deviceType !== '1' && deviceType !== '2')) {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Device type format is invalid'
}
}
}).end());
return;
}
if (deviceID === undefined || !DEVICE_ID.test(deviceID)) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'deviceId format is invalid'
}
}
}).end());
return;
}
if (serialNumber === undefined || !SERIAL_REGEX.test(serialNumber)) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}
}).end());
return;
}
// TODO - Should the version check throw SYSTEM_UPDATE_REQUIRED?
if (systemVersion === undefined || SYSTEM_VERSIONS[platformID] !== systemVersion) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'version format is invalid'
}
}
}).end());
return;
}
if (region === undefined || !REGIONS.includes(region)) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'X-Nintendo-Region format is invalid'
}
}
}).end());
return;
}
if (country === undefined) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'X-Nintendo-Country format is invalid'
}
}
}).end());
return;
}
// TODO - Check the platform too?
if (
clientID === undefined ||
clientSecret === undefined ||
!VALID_CLIENT_ID_SECRET_PAIRS[clientID] ||
clientSecret !== VALID_CLIENT_ID_SECRET_PAIRS[clientID]
) {
response.send(xmlbuilder.create({
errors: {
error: {
cause: 'client_id',
code: '0004',
message: 'API application invalid or incorrect application credentials'
}
}
}).end());
return;
}
if (friendsVersion === undefined || friendsVersion !== '0000') {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Friends version is invalid'
}
}
}).end());
return;
}
// TODO - Check this against valid list
if (environment === undefined) {
response.send(xmlbuilder.create({
errors: {
error: {
code: '1017',
message: 'The requested game environment wasn\'t found for the given game server.'
}
}
}).end());
return;
}
if (titleID === undefined) {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Title ID format is invalid'
}
}
}).end());
return;
}
if (uniqueID === undefined) {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Unique ID format is invalid'
}
}
}).end());
return;
}
if (applicationVersion === undefined) {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Application version format is invalid'
}
}
}).end());
return;
}
if (platformID === '0' && model === undefined) {
// TODO - Unsure if this is the right error
response.send(xmlbuilder.create({
errors: {
error: {
code: '0002',
message: 'Model format is invalid'
}
}
}).end());
return;
}
if (platformID === '0' && deviceCertificate === undefined) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
return next();
}
export default nnasBasicHeaderCheckMiddleware;

View File

@ -1,220 +0,0 @@
import crypto from 'node:crypto';
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import NintendoCertificate from '@/nintendo-certificate';
import { Device } from '@/models/device';
// * These endpoints are requested by the Wii U prior to sending it's device certificate.
// * We cannot validate Wii U console details in these endpoints
const INSECURE_WIIU_ENDPOINTS = [
/^\/v1\/api\/devices\/@current\/status\/?$/,
/^\/v1\/api\/content\/agreements\/Nintendo-Network-EULA\/[A-Z]{2}\/@latest\/?$/, // TODO - Should this be a bit more flexible, changing the type and version?
/^\/v1\/api\/content\/time_zones\/[A-Z]{2}\/[a-z]{2}\/?$/,
/^\/v1\/api\/people\/\w{6,16}\/?$/, // TODO - "\w" is NOT the correct filter here, there's additional rules. But this works for now. See https://en-americas-support.nintendo.com/app/answers/detail/a_id/2221
/^\/v1\/api\/support\/validate\/email\/?$/,
/^\/v1\/account-settings\/?/ // * Disable all of these routes, don't check the end of the string
];
// * These endpoints are known to always have a certificate, on both consoles.
// * Any other endpoint only has a certificate sent to it on the 3DS, on the Wii U
// * we can only use the device ID and serial for lookups in those cases
const REQUIRED_CERT_CHECK_ENDPOINTS = [
/^\/v1\/api\/oauth20\/access_token\/generate\/?$/,
/^\/v1\/api\/people\/?$/,
/^\/v1\/api\/people\/@me\/agreements\/?$/, // TODO - We don't actually implement this endpoint yet
/^\/v1\/api\/people\/@me\/devices\/?$/,
/^\/v1\/api\/people\/@me\/devices\/owner\/?$/
];
async function nnasCheckDeviceMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const platformID = request.header('X-Nintendo-Platform-ID')!;
const deviceID = Number(request.header('X-Nintendo-Device-ID')!);
const serialNumber = request.header('X-Nintendo-Serial-Number')!;
const deviceCertificate = request.header('X-Nintendo-Device-Cert');
const path = request.originalUrl.split('?')[0];
if (platformID === '1' && INSECURE_WIIU_ENDPOINTS.some(regex => regex.test(path))) {
// * Some Wii U endpoints cannot be validated, since they are called prior to seeing a certificate
return next();
}
// * 3DS ALWAYS sends the device certificate
if (platformID === '0' && deviceCertificate == undefined) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
const shouldCheckCertificate = deviceCertificate !== undefined || platformID === '0' || REQUIRED_CERT_CHECK_ENDPOINTS.some(regex => regex.test(path));
if (shouldCheckCertificate) {
if (deviceCertificate === undefined) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
const certificate = new NintendoCertificate(deviceCertificate);
if (!certificate.valid) {
response.status(400).send(xmlbuilder.create({
error: {
code: '0110',
message: 'Unlinked device'
}
}).end());
return;
}
const certificateDeviceID = parseInt(certificate.certificateName.slice(2).split('-')[0], 16);
if (deviceID !== certificateDeviceID) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
let device = await Device.findOne({
serial: serialNumber,
});
if (!device && certificate.consoleType === '3ds') {
// * A 3DS console document will ALWAYS be created by NASC before
// * Hitting the NNAS server. NASC stores the serial number at
// * the time the device document was created. Therefore we can
// * know that serial tampering happened on the 3DS if this fails
// * to find a device document.
response.status(400).send(xmlbuilder.create({
error: {
code: '0002',
message: 'serialNumber format is invalid'
}
}).end());
return;
}
// * Update 3DS consoles to sync with the data from NASC
const certificateHash = crypto.createHash('sha256').update(Buffer.from(deviceCertificate, 'base64')).digest('base64');
if (device && !device.certificate_hash && certificate.consoleType === '3ds') {
// * First time seeing the 3DS in NNAS, link the device certificate
device.certificate_hash = certificateHash;
await device.save();
}
if (device && !device.device_id && certificate.consoleType === '3ds') {
// * First time seeing the 3DS in NNAS, link the device ID
device.device_id = certificateDeviceID;
await device.save();
}
// * Real device lookup/validation is always done with the certificate
device = await Device.findOne({
certificate_hash: certificateHash,
});
if (!device) {
if (certificate.consoleType === '3ds') {
// * If this happens, something has gone horribly wrong
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
// * Assume device is a Wii U we've never seen before
device = await Device.create({
model: 'wup',
device_id: deviceID,
serial: serialNumber,
linked_pids: [],
certificate_hash: certificateHash
});
}
if (device.serial !== serialNumber) {
// * Spoofed serial. Device ID compared to certificate directly earlier
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
request.device = device;
} else {
// * This should only be triggered on the Wii U for endpoints that don't send the device certificate,
// * but can also be reached AFTER one has been seen (IE, after PNID creation/linking).
// * This is generally considered safe since endpoints which fall into this category are used AFTER
// * the Wii U has sent the device certificate to the server, so a valid entry should be made for it
const device = await Device.findOne({
device_id: deviceID,
serial: serialNumber
});
if (!device) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'device_id',
code: '0113',
message: 'Unauthorized device'
}
}
}).end());
return;
}
if (device.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0012',
message: 'Device has been banned by game server' // TODO - This is not the right error message
}
}
}).end());
return;
}
request.device = device;
}
return next();
}
export default nnasCheckDeviceMiddleware;

View File

@ -1,7 +1,7 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getValueFromHeaders } from '@/util';
import { getPNIDByBasicAuth, getPNIDByNNASAccessToken } from '@/database';
import { getPNIDByBasicAuth, getPNIDByTokenAuth } from '@/database';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
async function PNIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
@ -14,16 +14,16 @@ async function PNIDMiddleware(request: express.Request, response: express.Respon
const parts = authHeader.split(' ');
const type = parts[0];
let token = parts[1];
let pnid: HydratedPNIDDocument | null = null;
let pnid: HydratedPNIDDocument | null;
if (request.isCemu) {
token = Buffer.from(token, 'hex').toString('base64');
}
if (type === 'Basic' && request.path.includes('v1/api/people/@me/devices')) {
if (type === 'Basic') {
pnid = await getPNIDByBasicAuth(token);
} else if (type === 'Bearer') {
pnid = await getPNIDByNNASAccessToken(token);
} else {
pnid = await getPNIDByTokenAuth(token);
}
if (!pnid) {

View File

@ -90,7 +90,10 @@ const PNIDSchema = new Schema<IPNID, PNIDModel, IPNIDMethods>({
},
devices: [DeviceSchema],
identification: { // * user identification tokens
email_code: String,
email_code: {
type: String,
unique: true
},
email_token: {
type: String,
unique: true

View File

@ -69,7 +69,6 @@ const SIGNATURE_SIZES = {
}
} as const;
// TODO - Replace this with https://github.com/PretendoNetwork/nintendo-file-formats
class NintendoCertificate {
_certificate: Buffer;
_certificateBody: Buffer;
@ -124,16 +123,8 @@ class NintendoCertificate {
const signatureTypeSizes = this._signatureTypeSizes(this.signatureType);
this._certificateBody = this._certificate.subarray(0x4 + signatureTypeSizes.SIZE + signatureTypeSizes.PADDING_SIZE);
this.signature = this._certificate.subarray(0x4, 0x4 + signatureTypeSizes.SIZE);
const padding = this._certificate.subarray(0x4 + signatureTypeSizes.SIZE, 0x4 + signatureTypeSizes.SIZE + signatureTypeSizes.PADDING_SIZE);
this.valid = padding.every(byte => byte === 0);
if (!this.valid) {
return;
}
this.issuer = this._certificate.subarray(0x80, 0xC0).toString().split('\0')[0];
this.keyType = this._certificate.readUInt32BE(0xC0);
this.certificateName = this._certificate.subarray(0xC4, 0x104).toString().split('\0')[0];
@ -146,7 +137,7 @@ class NintendoCertificate {
this.consoleType = '3ds';
}
this._verifySignatureECDSA(); // * Force it to use the expected certificate type
this._verifySignature();
}
}

View File

@ -1,9 +1,9 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { getPNIDByUsername, getPNIDByAPIRefreshToken } from '@/database';
import { nintendoPasswordHash, generateOAuthTokens} from '@/util';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { SystemType } from '@/types/common/token';
import { LOG_ERROR } from '@/logger';
const router = express.Router();
@ -87,7 +87,7 @@ router.post('/', async (request: express.Request, response: express.Response): P
return;
}
} else {
pnid = await getPNIDByAPIRefreshToken(refreshToken);
pnid = await getPNIDByTokenAuth(refreshToken);
if (!pnid) {
response.status(400).json({
@ -110,19 +110,44 @@ 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))
};
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))
};
try {
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
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: tokenGeneration.accessToken,
access_token: accessToken,
token_type: 'Bearer',
expires_in: tokenGeneration.expiresInSecs.access,
refresh_token: tokenGeneration.refreshToken
expires_in: 3600,
refresh_token: newRefreshToken
});
} catch (error: any) {
LOG_ERROR('/v1/login - token generation: ' + error);
if (error.stack) console.error(error.stack);
response.status(500).json({
app: 'api',
status: 500,

View File

@ -7,14 +7,13 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { config, disabledFeatures } from '@/config-manager';
import { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { SystemType } from '@/types/common/token';
const router = express.Router();
@ -367,14 +366,38 @@ 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))
};
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))
};
try {
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
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: tokenGeneration.accessToken,
access_token: accessToken,
token_type: 'Bearer',
expires_in: tokenGeneration.expiresInSecs.access,
refresh_token: tokenGeneration.refreshToken
expires_in: 3600,
refresh_token: refreshToken
});
} catch (error: any) {
LOG_ERROR('/v1/register - token generation: ' + error);

View File

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

View File

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

View File

@ -1,10 +1,10 @@
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, generateOAuthTokens} from '@/util';
import { getPNIDByUsername, getPNIDByTokenAuth } from '@/database';
import { nintendoPasswordHash, generateToken} from '@/util';
import { config } from '@/config-manager';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { SystemType } from '@/types/common/token';
export async function login(request: LoginRequest): Promise<DeepPartial<LoginResponse>> {
const grantType = request.grantType?.trim();
@ -16,45 +16,74 @@ export async function login(request: LoginRequest): Promise<DeepPartial<LoginRes
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
}
if (grantType === 'password' && !username) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
}
if (grantType === 'password' && !password) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
}
if (grantType === 'refresh_token' && !refreshToken) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
let pnid: HydratedPNIDDocument | null;
if (grantType === 'password') {
if (!username) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing username');
if (!password) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing password');
pnid = await getPNIDByUsername(username!); // * We know username will never be null here
pnid = await getPNIDByUsername(username);
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
}
if (!pnid) throw new ServerError(Status.INVALID_ARGUMENT, 'User not found');
const hashedPassword = nintendoPasswordHash(password, pnid.pid);
const hashedPassword = nintendoPasswordHash(password!, pnid.pid); // * We know password will never be null here
if (!bcrypt.compareSync(hashedPassword, pnid.password)) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Password is incorrect');
}
} else if (grantType === 'refresh_token') {
if (!refreshToken) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
pnid = await getPNIDByAPIRefreshToken(refreshToken);
if (!pnid) throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
} else {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid grant type');
pnid = await getPNIDByTokenAuth(refreshToken!); // * We know refreshToken will never be null here
if (!pnid) {
throw new ServerError(Status.INVALID_ARGUMENT, 'Invalid or missing refresh token');
}
}
if (pnid.deleted) {
throw new ServerError(Status.UNAUTHENTICATED, 'Account has been deleted');
}
try {
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
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))
};
return {
accessToken: tokenGeneration.accessToken,
tokenType: 'Bearer',
expiresIn: tokenGeneration.expiresInSecs.access,
refreshToken: tokenGeneration.refreshToken
};
} catch {
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
}
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
};
}

View File

@ -8,14 +8,13 @@ import moment from 'moment';
import hcaptcha from 'hcaptcha';
import Mii from 'mii-js';
import { doesPNIDExist, connection as databaseConnection } from '@/database';
import { nintendoPasswordHash, sendConfirmationEmail, generateOAuthTokens } from '@/util';
import { nintendoPasswordHash, sendConfirmationEmail, generateToken } from '@/util';
import { LOG_ERROR } from '@/logger';
import { PNID } from '@/models/pnid';
import { NEXAccount } from '@/models/nex-account';
import { config, disabledFeatures } from '@/config-manager';
import type { HydratedNEXAccountDocument } from '@/types/mongoose/nex-account';
import type { HydratedPNIDDocument } from '@/types/mongoose/pnid';
import { SystemType } from '@/types/common/token';
const PNID_VALID_CHARACTERS_REGEX = /^[\w\-.]*$/;
const PNID_PUNCTUATION_START_REGEX = /^[_\-.]/;
@ -230,16 +229,36 @@ export async function register(request: RegisterRequest): Promise<DeepPartial<Lo
await sendConfirmationEmail(pnid);
try {
const tokenGeneration = generateOAuthTokens(SystemType.API, pnid, { refreshExpiresIn: 14 * 24 * 60 * 60 }); // * 14 days
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))
};
return {
accessToken: tokenGeneration.accessToken,
tokenType: 'Bearer',
expiresIn: tokenGeneration.expiresInSecs.access,
refreshToken: tokenGeneration.refreshToken
};
} catch {
throw new ServerError(Status.INTERNAL, 'Could not generate OAuth tokens');
}
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
};
}

View File

@ -3,7 +3,6 @@ import { nintendoBase64Encode, nintendoBase64Decode, nascDateTime, nascError, ge
import { getServerByTitleID } from '@/database';
import { NASCRequestParams } from '@/types/services/nasc/request-params';
import { HydratedServerDocument } from '@/types/mongoose/server';
import { SystemType, TokenOptions, TokenType } from '@/types/common/token';
const router = express.Router();
@ -65,17 +64,19 @@ router.post('/', async (request: express.Request, response: express.Response): P
});
async function processLoginRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions: TokenOptions = {
system_type: SystemType['3DS'],
token_type: TokenType.NEX,
const tokenOptions = {
system_type: 0x2, // * 3DS
token_type: 0x3, // * NEX token
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const nexTokenBuffer = generateToken(server.aes_key, tokenOptions);
const nexToken = nintendoBase64Encode(nexTokenBuffer);
// TODO - Handle null tokens
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
const nexToken = nintendoBase64Encode(nexTokenBuffer || '');
return new URLSearchParams({
locator: nintendoBase64Encode(`${server.ip}:${server.port}`),
@ -87,17 +88,19 @@ async function processLoginRequest(server: HydratedServerDocument, pid: number,
}
async function processServiceTokenRequest(server: HydratedServerDocument, pid: number, titleID: string): Promise<URLSearchParams> {
const tokenOptions: TokenOptions = {
system_type: SystemType['3DS'],
token_type: TokenType.SERVICE,
const tokenOptions = {
system_type: 0x2, // * 3DS
token_type: 0x4, // * Service token
pid: pid,
access_level: 0,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const serviceTokenBuffer = generateToken(server.aes_key, tokenOptions);
const serviceToken = nintendoBase64Encode(serviceTokenBuffer);
// TODO - Handle null tokens
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
const serviceToken = nintendoBase64Encode(serviceTokenBuffer || '');
return new URLSearchParams({
retry: nintendoBase64Encode('0'),

View File

@ -2,8 +2,7 @@
import path from 'node:path';
import express from 'express';
import nnasBasicHeaderCheckMiddleware from '@/middleware/nnas-basic-header-check';
import nnasCheckDeviceMiddleware from '@/middleware/nnas-check-device';
import clientHeaderCheck from '@/middleware/client-header';
import cemuMiddleware from '@/middleware/cemu';
import pnidMiddleware from '@/middleware/pnid';
import { LOG_INFO, formatHostnames } from '@/logger';
@ -47,8 +46,7 @@ nnas.use('/v1/account-settings/js/', setJSHeader, express.static(path.join(__dir
nnas.use('/v1/account-settings/img/', setIMGHeader, express.static(path.join(__dirname, '../../assets/user-info-settings')));
LOG_INFO('[NNAS] Importing middleware');
nnas.use(nnasBasicHeaderCheckMiddleware);
nnas.use(nnasCheckDeviceMiddleware);
nnas.use(clientHeaderCheck);
nnas.use(cemuMiddleware);
nnas.use(pnidMiddleware);

View File

@ -1,10 +1,12 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database';
import { generateOAuthTokens } from '@/util';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import consoleStatusVerificationMiddleware from '@/middleware/console-status-verification';
import { getPNIDByTokenAuth, getPNIDByUsername } from '@/database';
import { generateToken } from '@/util';
import { config } from '@/config-manager';
import { Device } from '@/models/device';
import { SystemType } from '@/types/common/token';
const router = express.Router();
@ -13,7 +15,7 @@ const router = express.Router();
* Replacement for: https://account.nintendo.net/v1/api/oauth20/access_token/generate
* Description: Generates an access token for a user
*/
router.post('/access_token/generate', async (request: express.Request, response: express.Response): Promise<void> => {
router.post('/access_token/generate', deviceCertificateMiddleware, consoleStatusVerificationMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
const grantType = request.body.grant_type;
const username = request.body.user_id;
const password = request.body.password;
@ -86,7 +88,7 @@ router.post('/access_token/generate', async (request: express.Request, response:
}
try {
pnid = await getPNIDByNNASRefreshToken(refreshToken);
pnid = await getPNIDByTokenAuth(refreshToken);
if (!pnid) {
response.status(400).send(xmlbuilder.create({
@ -151,21 +153,37 @@ router.post('/access_token/generate', async (request: express.Request, response:
return;
}
try {
const tokenGeneration = generateOAuthTokens(SystemType.WIIU, pnid);
const accessTokenOptions = {
system_type: 0x1, // * WiiU
token_type: 0x1, // * OAuth Access
pid: pnid.pid,
expire_time: BigInt(Date.now() + (3600 * 1000))
};
response.send(xmlbuilder.create({
OAuth20: {
access_token: {
token: tokenGeneration.accessToken,
refresh_token: tokenGeneration.refreshToken,
expires_in: tokenGeneration.expiresInSecs.access
}
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
}
}).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);
}
}
}).end());
});
export default router;

View File

@ -3,6 +3,7 @@ import express from 'express';
import xmlbuilder from 'xmlbuilder';
import bcrypt from 'bcrypt';
import moment from 'moment';
import deviceCertificateMiddleware from '@/middleware/device-certificate';
import ratelimit from '@/middleware/ratelimit';
import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database';
import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util';
@ -48,7 +49,20 @@ router.get('/:username', async (request: express.Request, response: express.Resp
* Replacement for: https://account.nintendo.net/v1/api/people
* Description: Registers a new NNID
*/
router.post('/', ratelimit, async (request: express.Request, response: express.Response): Promise<void> => {
router.post('/', ratelimit, deviceCertificateMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
if (!request.certificate || !request.certificate.valid) {
// TODO - Change this to a different error
response.status(400).send(xmlbuilder.create({
error: {
cause: 'Bad Request',
code: '1600',
message: 'Unable to process request'
}
}).end());
return;
}
const person: Person = request.body.person;
const userExists = await doesPNIDExist(person.user_id);

View File

@ -1,9 +1,8 @@
import express from 'express';
import xmlbuilder from 'xmlbuilder';
import { getServerByClientID, getServerByGameServerID } from '@/database';
import { generateToken, getValueFromHeaders, getValueFromQueryString, isSystemType } from '@/util';
import { generateToken, getValueFromHeaders, getValueFromQueryString } from '@/util';
import { NEXAccount } from '@/models/nex-account';
import { SystemType, TokenOptions, TokenType } from '@/types/common/token';
const router = express.Router();
@ -90,24 +89,21 @@ router.get('/service_token/@me', async (request: express.Request, response: expr
return;
}
if (!isSystemType(server.device)) {
throw new Error('Invalid system type');
}
// * Asserted safely because of the check above
const systemType = server.device as SystemType;
const tokenOptions: TokenOptions = {
system_type: systemType as SystemType,
token_type: TokenType.SERVICE,
const tokenOptions = {
system_type: server.device,
token_type: 0x4, // * Service token
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const serviceTokenBuffer = generateToken(server.aes_key, tokenOptions);
const serviceToken = request.isCemu ? serviceTokenBuffer.toString('hex') : serviceTokenBuffer.toString('base64');
const serviceTokenBuffer = await generateToken(server.aes_key, tokenOptions);
let serviceToken = serviceTokenBuffer ? serviceTokenBuffer.toString('base64') : '';
if (request.isCemu) {
serviceToken = Buffer.from(serviceToken, 'base64').toString('hex');
}
response.send(xmlbuilder.create({
service_token: {
@ -216,23 +212,16 @@ router.get('/nex_token/@me', async (request: express.Request, response: express.
return;
}
if (!isSystemType(server.device)) {
throw new Error('Invalid system type');
}
// * Asserted safely because of the check above
const systemType = server.device as SystemType;
const tokenOptions: TokenOptions = {
system_type: systemType,
token_type: TokenType.NEX,
const tokenOptions = {
system_type: server.device,
token_type: 0x3, // * nex token,
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(parseInt(titleID, 16)),
expire_time: BigInt(Date.now() + (3600 * 1000))
};
const nexTokenBuffer = generateToken(server.aes_key, tokenOptions);
const nexTokenBuffer = await generateToken(server.aes_key, tokenOptions);
let nexToken = nexTokenBuffer ? nexTokenBuffer.toString('base64') : '';
if (request.isCemu) {

View File

@ -3,57 +3,8 @@ import express from 'express';
import xmlbuilder from 'xmlbuilder';
import moment from 'moment';
import { getPNIDByEmailAddress, getPNIDByPID } from '@/database';
import { Device } from '@/models/device';
import { sendEmailConfirmedEmail, sendConfirmationEmail, sendForgotPasswordEmail, sendEmailConfirmedParentalControlsEmail } from '@/util';
// * Middleware to ensure the input device is valid
// TODO - Make this available for more routes? This could be useful elsewhere
async function validateDeviceIDMiddleware(request: express.Request, response: express.Response, next: express.NextFunction): Promise<void> {
const deviceID = request.header('x-nintendo-device-id');
const serial = request.header('x-nintendo-serial-number');
// * Since these values are linked at the time of device creation, and the
// * device ID is always validated against the device certificate for legitimacy
// * we can safely assume that every console hitting our servers through normal
// * means will be stored correctly. And since once both values are set they
// * cannot be changed, these checks will always be safe
const device = await Device.findOne({
device_id: Number(deviceID),
serial: serial
});
if (!device) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'device_id',
code: '0113',
message: 'Unauthorized device'
}
}
}).end());
return;
}
if (device.access_level < 0) {
response.status(400).send(xmlbuilder.create({
errors: {
error: {
code: '0012',
message: 'Device has been banned by game server' // TODO - This is not the right error message
}
}
}).end());
return;
}
// TODO - Once we push support for linking PNIDs to consoles, also check if the PID is linked or not
next();
}
const router = express.Router();
/**
@ -120,13 +71,6 @@ router.put('/email_confirmation/:pid/:code', async (request: express.Request, re
return;
}
// * If the email is already confirmed don't bother continuing
if (pnid.email.validated) {
// TODO - Is there an actual error for this case?
response.status(200).send('');
return;
}
if (pnid.identification.email_code !== code) {
response.status(400).send(xmlbuilder.create({
errors: {
@ -157,7 +101,7 @@ router.put('/email_confirmation/:pid/:code', async (request: express.Request, re
* Replacement for: https://account.nintendo.net/v1/api/support/resend_confirmation
* Description: Resends a users confirmation email
*/
router.get('/resend_confirmation', validateDeviceIDMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
router.get('/resend_confirmation', async (request: express.Request, response: express.Response): Promise<void> => {
const pid = Number(request.headers['x-nintendo-pid']);
const pnid = await getPNIDByPID(pid);
@ -176,13 +120,6 @@ router.get('/resend_confirmation', validateDeviceIDMiddleware, async (request: e
return;
}
// * If the email is already confirmed don't bother continuing
if (pnid.email.validated) {
// TODO - Is there an actual error for this case?
response.status(200).send('');
return;
}
await sendConfirmationEmail(pnid);
response.status(200).send('');
@ -223,15 +160,19 @@ router.get('/send_confirmation/pin/:email', async (request: express.Request, res
* Description: Sends the user a password reset email
* NOTE: On NN this was a temp password that expired after 24 hours. We do not do that
*/
router.get('/forgotten_password/:pid', validateDeviceIDMiddleware, async (request: express.Request, response: express.Response): Promise<void> => {
if (!/^\d+$/.test(request.params.pid)) {
// * This is what Nintendo sends
router.get('/forgotten_password/:pid', async (request: express.Request, response: express.Response): Promise<void> => {
const pid = Number(request.params.pid);
const pnid = await getPNIDByPID(pid);
if (!pnid) {
// TODO - Better errors
response.status(400).send(xmlbuilder.create({
errors: {
error: {
cause: 'Not Found',
code: '1600',
message: 'Unable to process request'
cause: 'device_id',
code: '0113',
message: 'Unauthorized device'
}
}
}).end());
@ -239,16 +180,6 @@ router.get('/forgotten_password/:pid', validateDeviceIDMiddleware, async (reques
return;
}
const pid = Number(request.params.pid);
const pnid = await getPNIDByPID(pid);
if (!pnid) {
// * Whenever a PID is a number, but is invalid, Nintendo just 404s
// TODO - When we move to linking PNIDs to consoles, this also applies to valid PIDs not linked to the current console
response.status(404).send('');
return;
}
await sendForgotPasswordEmail(pnid);
response.status(200).send('');

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
export interface TokenOptions {
system_type: number;
token_type: number;
pid: number;
access_level?: number;
title_id?: bigint;
expire_time: bigint;
}

View File

@ -1,51 +1,8 @@
export const TokenType = {
OAUTH_ACCESS: 1,
OAUTH_REFRESH: 2,
NEX: 3,
SERVICE: 4,
PASSWORD_RESET: 5
} as const;
export type TokenType = typeof TokenType[keyof typeof TokenType];
export const SystemType = {
'WIIU': 1,
'3DS': 2,
'API': 3
} as const;
export type SystemType = typeof SystemType[keyof typeof SystemType];
export interface Token {
system_type: SystemType;
token_type: TokenType;
system_type: number;
token_type: number;
pid: number;
access_level?: number;
title_id?: bigint;
expire_time: bigint;
}
// ? Separated so additional non-token fields can be added in the future
export type TokenOptions = Token & {
access_level: number;
}
export type OAuthTokenGenerationResponse = {
accessToken: string;
refreshToken: string;
expiresInSecs: {
access: number;
refresh: number;
}
};
export type OAuthTokenOptions = {
/**
* The number of seconds the access token will be valid for, defaults to 1 hour
* @default 60 * 60
*/
accessExpiresIn?: number;
/**
* The number of seconds the refresh token will be valid for, defaults to 14 days
* @default 14 * 24 * 60 * 60
*/
refreshExpiresIn?: number;
}

View File

@ -10,8 +10,9 @@ import crc32 from 'buffer-crc32';
import crc from 'crc';
import { sendMail } from '@/mailer';
import { config, disabledFeatures } from '@/config-manager';
import { OAuthTokenGenerationResponse, OAuthTokenOptions, SystemType, Token, TokenOptions, TokenType } from '@/types/common/token';
import { HydratedPNIDDocument, IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import { TokenOptions } from '@/types/common/token-options';
import { Token } from '@/types/common/token';
import { IPNID, IPNIDMethods } from '@/types/mongoose/pnid';
import { SafeQs } from '@/types/common/safe-qs';
let s3: S3;
@ -52,49 +53,7 @@ export function nintendoBase64Encode(decoded: string | Buffer): string {
return encoded.replaceAll('+', '.').replaceAll('/', '-').replaceAll('=', '*');
}
export function generateOAuthTokens(systemType: SystemType, pnid: HydratedPNIDDocument, options?: OAuthTokenOptions): OAuthTokenGenerationResponse {
const accessTokenExpiresInSecs = options?.accessExpiresIn ?? 60 * 60; // * 1 hour
const refreshTokenExpiresInSecs = options?.refreshExpiresIn ?? 24 * 60 * 60; // * 24 hours
const accessTokenOptions: TokenOptions = {
system_type: systemType,
token_type: TokenType.OAUTH_ACCESS,
pid: pnid.pid,
access_level: pnid.access_level,
expire_time: BigInt(Date.now() + (accessTokenExpiresInSecs * 1000))
};
const refreshTokenOptions: TokenOptions = {
system_type: systemType,
token_type: TokenType.OAUTH_REFRESH,
pid: pnid.pid,
access_level: pnid.access_level,
expire_time: BigInt(Date.now() + (refreshTokenExpiresInSecs * 1000))
};
const accessToken = generateToken(config.aes_key, accessTokenOptions).toString('hex');
const refreshToken = generateToken(config.aes_key, refreshTokenOptions).toString('hex');
return {
accessToken,
refreshToken,
expiresInSecs: {
access: accessTokenExpiresInSecs,
refresh: refreshTokenExpiresInSecs
}
};
}
export function isSystemType(value: number): value is SystemType {
return (Object.values(SystemType) as number[]).includes(value);
}
export function isTokenType(value: number): value is TokenType {
return (Object.values(TokenType) as number[]).includes(value);
}
export function generateToken(key: string, options: TokenOptions): Buffer {
export function generateToken(key: string, options: TokenOptions): Buffer | null {
let dataBuffer = Buffer.alloc(1 + 1 + 4 + 8);
dataBuffer.writeUInt8(options.system_type, 0x0);
@ -102,16 +61,19 @@ export function generateToken(key: string, options: TokenOptions): Buffer {
dataBuffer.writeUInt32LE(options.pid, 0x2);
dataBuffer.writeBigUInt64LE(options.expire_time, 0x6);
if ((options.token_type !== TokenType.OAUTH_ACCESS && options.token_type !== TokenType.OAUTH_REFRESH) || options.system_type === SystemType.API) {
if ((options.token_type !== 0x1 && options.token_type !== 0x2) || options.system_type === 0x3) {
// * Access and refresh tokens have smaller bodies due to size constraints
// * The API does not have this restraint, however
if (options.title_id === undefined || options.access_level === undefined) {
return null;
}
dataBuffer = Buffer.concat([
dataBuffer,
Buffer.alloc(8 + 1)
]);
dataBuffer.writeBigUInt64LE(options.title_id ?? BigInt(0), 0xE);
dataBuffer.writeBigUInt64LE(options.title_id, 0xE);
dataBuffer.writeInt8(options.access_level, 0x16);
}
@ -125,7 +87,7 @@ export function generateToken(key: string, options: TokenOptions): Buffer {
let final = encrypted;
if ((options.token_type !== TokenType.OAUTH_ACCESS && options.token_type !== TokenType.OAUTH_REFRESH) || options.system_type === SystemType.API) {
if ((options.token_type !== 0x1 && options.token_type !== 0x2) || options.system_type === 0x3) {
// * Access and refresh tokens don't get a checksum due to size constraints
const checksum = crc32(dataBuffer);
@ -170,20 +132,14 @@ export function decryptToken(token: Buffer, key?: string): Buffer {
}
export function unpackToken(token: Buffer): Token {
const systemType = token.readUInt8(0x0);
const tokenType = token.readUInt8(0x1);
if (!isSystemType(systemType)) throw new Error('Invalid system type');
if (!isTokenType(tokenType)) throw new Error('Invalid token type');
const unpacked: Token = {
system_type: systemType,
token_type: tokenType,
system_type: token.readUInt8(0x0),
token_type: token.readUInt8(0x1),
pid: token.readUInt32LE(0x2),
expire_time: token.readBigUInt64LE(0x6)
};
if (unpacked.token_type !== TokenType.OAUTH_ACCESS && unpacked.token_type !== TokenType.OAUTH_REFRESH) {
if (unpacked.token_type !== 0x1 && unpacked.token_type !== 0x2) {
unpacked.title_id = token.readBigUInt64LE(0xE);
unpacked.access_level = token.readInt8(0x16);
}
@ -281,16 +237,19 @@ export async function sendEmailConfirmedParentalControlsEmail(pnid: mongoose.Hyd
}
export async function sendForgotPasswordEmail(pnid: mongoose.HydratedDocument<IPNID, IPNIDMethods>): Promise<void> {
const tokenOptions: TokenOptions = {
system_type: SystemType.API,
token_type: TokenType.PASSWORD_RESET,
const tokenOptions = {
system_type: 0xF, // * API
token_type: 0x5, // * Password reset
pid: pnid.pid,
access_level: pnid.access_level,
title_id: BigInt(0),
expire_time: BigInt(Date.now() + (24 * 60 * 60 * 1000)) // * Only valid for 24 hours
};
const passwordResetToken = generateToken(config.aes_key, tokenOptions).toString('hex');
const tokenBuffer = await generateToken(config.aes_key, tokenOptions);
const passwordResetToken = tokenBuffer ? tokenBuffer.toString('hex') : '';
// TODO - Handle null token
const mailerOptions = {
to: pnid.email.address,