Add access token foundation

This commit is contained in:
diyaa 2026-06-28 02:10:53 +02:00
parent 6bf096f6c2
commit 4e743df7f1
9 changed files with 487 additions and 1 deletions

View File

@ -11,6 +11,9 @@ async function generate(): Promise<void> {
process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage'); process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage');
process.env.PUBLIC_BASE_URL ??= 'http://localhost:3007'; process.env.PUBLIC_BASE_URL ??= 'http://localhost:3007';
process.env.DEVICE_BOOTSTRAP_SECRET ??= 'openapi-placeholder-secret'; process.env.DEVICE_BOOTSTRAP_SECRET ??= 'openapi-placeholder-secret';
process.env.ACCOUNT_ACCESS_TOKEN_SECRET ??=
'openapi-placeholder-account-access-secret';
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ??= '900';
process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000'; process.env.MAX_UPLOAD_SIZE_BYTES ??= '524288000';
const { createApp } = await import('../src/app.factory'); const { createApp } = await import('../src/app.factory');

View File

@ -0,0 +1,243 @@
import { randomUUID, createHmac } from 'node:crypto';
import { UnauthorizedException } from '@nestjs/common';
import { AccountSessionStatus } from '@prisma/client';
import { AppConfigService } from '../config/config.service';
import {
AccessTokenService,
AccountAccessTokenClaims,
} from './access-token.service';
import { SessionService } from './session.service';
describe('AccessTokenService', () => {
const signingSecret = 'test-account-access-signing-secret';
const now = new Date('2026-06-28T12:00:00.000Z');
const userId = randomUUID();
const sessionId = randomUUID();
const signPayload = (
payload: Record<string, unknown>,
secret = signingSecret,
header: Record<string, unknown> = { alg: 'HS256', typ: 'JWT' },
): string => {
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
'base64url',
);
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
'base64url',
);
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = createHmac('sha256', secret)
.update(signingInput)
.digest('base64url');
return `${signingInput}.${signature}`;
};
const buildService = () => {
let currentAccessTokenVersion = 1;
let sessionStatus: AccountSessionStatus = AccountSessionStatus.ACTIVE;
const sessionService = {
validateSession: jest.fn().mockImplementation(async (input) => {
if (
sessionStatus !== AccountSessionStatus.ACTIVE ||
input.sessionId !== sessionId ||
input.accessTokenVersion !== currentAccessTokenVersion
) {
throw new UnauthorizedException('Session is invalid');
}
return {
id: sessionId,
userId,
refreshTokenHash: 'stored-refresh-token-hash',
status: sessionStatus,
accessTokenVersion: currentAccessTokenVersion,
expiresAt: new Date('2026-07-28T12:00:00.000Z'),
lastRefreshedAt: null,
revokedAt: null,
createdAt: now,
updatedAt: now,
};
}),
} as unknown as SessionService;
const configService = {
accountAccessTokenSecret: signingSecret,
accountAccessTokenTtlSeconds: 900,
} as AppConfigService;
const service = new AccessTokenService(configService, sessionService);
return {
service,
sessionService,
setCurrentAccessTokenVersion: (version: number) => {
currentAccessTokenVersion = version;
},
setSessionStatus: (status: AccountSessionStatus) => {
sessionStatus = status;
},
};
};
const issueToken = (service: AccessTokenService): string =>
service.issueAccessToken(
{
userId,
sessionId,
accessTokenVersion: 1,
},
now,
);
it('issues a signed token with the required account claims', () => {
const { service } = buildService();
const token = issueToken(service);
const payload = service.decodeAccessToken(token);
expect(payload).toEqual({
sub: userId,
sid: sessionId,
ver: 1,
typ: 'account_access',
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(now.getTime() / 1000) + 900,
});
});
it('verifies a valid token and its backing session', async () => {
const { service, sessionService } = buildService();
const token = issueToken(service);
await expect(service.verifyAccessToken(token, now)).resolves.toMatchObject({
sub: userId,
sid: sessionId,
ver: 1,
typ: 'account_access',
});
expect(sessionService.validateSession).toHaveBeenCalledWith(
{
sessionId,
accessTokenVersion: 1,
},
now,
);
});
it('rejects an expired token before session validation', async () => {
const { service, sessionService } = buildService();
const token = issueToken(service);
const expiration = new Date(now.getTime() + 900_000);
await expect(
service.verifyAccessToken(token, expiration),
).rejects.toThrow(UnauthorizedException);
expect(sessionService.validateSession).not.toHaveBeenCalled();
});
it('rejects a token with the wrong account token type', async () => {
const { service } = buildService();
const token = signPayload({
sub: userId,
sid: sessionId,
ver: 1,
typ: 'device_access',
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(now.getTime() / 1000) + 900,
});
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
it('rejects a stale token version after session refresh', async () => {
const { service, setCurrentAccessTokenVersion } = buildService();
const token = issueToken(service);
setCurrentAccessTokenVersion(2);
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
it('rejects a token backed by a revoked session', async () => {
const { service, setSessionStatus } = buildService();
const token = issueToken(service);
setSessionStatus(AccountSessionStatus.REVOKED);
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
it('rejects a token backed by an expired session', async () => {
const { service, setSessionStatus } = buildService();
const token = issueToken(service);
setSessionStatus(AccountSessionStatus.EXPIRED);
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
it('rejects a token signed with a different secret', async () => {
const { service, sessionService } = buildService();
const payload: AccountAccessTokenClaims = {
sub: userId,
sid: sessionId,
ver: 1,
typ: 'account_access',
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(now.getTime() / 1000) + 900,
};
const token = signPayload({ ...payload }, 'different-signing-secret');
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
expect(sessionService.validateSession).not.toHaveBeenCalled();
});
it('does not expose email, password, or refresh token data', () => {
const { service } = buildService();
const payload = service.decodeAccessToken(issueToken(service));
expect(payload).not.toHaveProperty('email');
expect(payload).not.toHaveProperty('password');
expect(payload).not.toHaveProperty('passwordHash');
expect(payload).not.toHaveProperty('refreshToken');
expect(payload).not.toHaveProperty('refreshTokenHash');
});
it.each([
['sub', { sid: sessionId }],
['sid', { sub: userId }],
])('rejects a token missing %s', async (_claim, identityClaims) => {
const { service } = buildService();
const token = signPayload({
...identityClaims,
ver: 1,
typ: 'account_access',
iat: Math.floor(now.getTime() / 1000),
exp: Math.floor(now.getTime() / 1000) + 900,
});
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
it('rejects a valid session that belongs to a different subject', async () => {
const { service, sessionService } = buildService();
(sessionService.validateSession as jest.Mock).mockResolvedValue({
id: sessionId,
userId: randomUUID(),
});
const token = issueToken(service);
await expect(service.verifyAccessToken(token, now)).rejects.toThrow(
UnauthorizedException,
);
});
});

View File

@ -0,0 +1,186 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { AppConfigService } from '../config/config.service';
import { SessionService } from './session.service';
const ACCOUNT_ACCESS_TOKEN_TYPE = 'account_access' as const;
const JWT_ALGORITHM = 'HS256' as const;
interface JwtHeader {
alg: typeof JWT_ALGORITHM;
typ: 'JWT';
}
export interface IssueAccessTokenInput {
userId: string;
sessionId: string;
accessTokenVersion: number;
}
export interface AccountAccessTokenClaims {
sub: string;
sid: string;
ver: number;
typ: typeof ACCOUNT_ACCESS_TOKEN_TYPE;
iat: number;
exp: number;
}
export type DecodedAccessTokenPayload = Record<string, unknown>;
@Injectable()
export class AccessTokenService {
constructor(
private readonly configService: AppConfigService,
private readonly sessionService: SessionService,
) {}
issueAccessToken(input: IssueAccessTokenInput, now = new Date()): string {
const issuedAt = Math.floor(now.getTime() / 1000);
const claims: AccountAccessTokenClaims = {
sub: input.userId,
sid: input.sessionId,
ver: input.accessTokenVersion,
typ: ACCOUNT_ACCESS_TOKEN_TYPE,
iat: issuedAt,
exp: issuedAt + this.configService.accountAccessTokenTtlSeconds,
};
const header: JwtHeader = {
alg: JWT_ALGORITHM,
typ: 'JWT',
};
const encodedHeader = this.encodeJson(header);
const encodedPayload = this.encodeJson(claims);
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = this.sign(signingInput);
return `${signingInput}.${signature}`;
}
async verifyAccessToken(
token: string,
now = new Date(),
): Promise<AccountAccessTokenClaims> {
const [encodedHeader, encodedPayload, encodedSignature] =
this.splitToken(token);
const header = this.decodeJson(encodedHeader);
if (header.alg !== JWT_ALGORITHM || header.typ !== 'JWT') {
throw this.invalidToken();
}
const expectedSignature = this.sign(
`${encodedHeader}.${encodedPayload}`,
);
const suppliedSignature = Buffer.from(encodedSignature, 'utf8');
const expectedSignatureBytes = Buffer.from(expectedSignature, 'utf8');
if (
suppliedSignature.length !== expectedSignatureBytes.length ||
!timingSafeEqual(suppliedSignature, expectedSignatureBytes)
) {
throw this.invalidToken();
}
const claims = this.assertClaims(this.decodeJson(encodedPayload), now);
await this.assertAccessTokenSession(claims, now);
return claims;
}
decodeAccessToken(token: string): DecodedAccessTokenPayload {
const [, encodedPayload] = this.splitToken(token);
return this.decodeJson(encodedPayload);
}
async assertAccessTokenSession(
claims: AccountAccessTokenClaims,
now = new Date(),
): Promise<void> {
const session = await this.sessionService.validateSession(
{
sessionId: claims.sid,
accessTokenVersion: claims.ver,
},
now,
);
if (session.userId !== claims.sub) {
throw this.invalidToken();
}
}
private assertClaims(
payload: DecodedAccessTokenPayload,
now: Date,
): AccountAccessTokenClaims {
if (
payload.typ !== ACCOUNT_ACCESS_TOKEN_TYPE ||
typeof payload.sub !== 'string' ||
payload.sub.length === 0 ||
typeof payload.sid !== 'string' ||
payload.sid.length === 0 ||
typeof payload.ver !== 'number' ||
!Number.isInteger(payload.ver) ||
payload.ver < 1 ||
typeof payload.iat !== 'number' ||
!Number.isInteger(payload.iat) ||
typeof payload.exp !== 'number' ||
!Number.isInteger(payload.exp) ||
payload.exp <= Math.floor(now.getTime() / 1000)
) {
throw this.invalidToken();
}
return payload as unknown as AccountAccessTokenClaims;
}
private splitToken(token: string): [string, string, string] {
const segments = token.split('.');
if (
segments.length !== 3 ||
!segments[0] ||
!segments[1] ||
!segments[2]
) {
throw this.invalidToken();
}
return [segments[0], segments[1], segments[2]];
}
private decodeJson(segment: string): DecodedAccessTokenPayload {
try {
const decoded: unknown = JSON.parse(
Buffer.from(segment, 'base64url').toString('utf8'),
);
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) {
throw this.invalidToken();
}
return decoded as DecodedAccessTokenPayload;
} catch {
throw this.invalidToken();
}
}
private encodeJson(value: JwtHeader | AccountAccessTokenClaims): string {
return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
}
private sign(signingInput: string): string {
return createHmac(
'sha256',
this.configService.accountAccessTokenSecret,
)
.update(signingInput)
.digest('base64url');
}
private invalidToken(): UnauthorizedException {
return new UnauthorizedException('Invalid account access token');
}
}

View File

@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { PrismaModule } from '../../infrastructure/database/prisma.module';
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware'; import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module'; import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
import { AccessTokenService } from './access-token.service';
import { DeviceAuthGuard } from './device-auth.guard'; import { DeviceAuthGuard } from './device-auth.guard';
import { DeviceAuthService } from './device-auth.service'; import { DeviceAuthService } from './device-auth.service';
import { OptionalDeviceAuthGuard } from './optional-device-auth.guard'; import { OptionalDeviceAuthGuard } from './optional-device-auth.guard';
@ -11,6 +12,7 @@ import { SessionService } from './session.service';
@Module({ @Module({
imports: [PrismaModule, RequestContextModule], imports: [PrismaModule, RequestContextModule],
providers: [ providers: [
AccessTokenService,
DeviceAuthService, DeviceAuthService,
DeviceAuthGuard, DeviceAuthGuard,
OptionalDeviceAuthGuard, OptionalDeviceAuthGuard,
@ -18,6 +20,7 @@ import { SessionService } from './session.service';
SessionService, SessionService,
], ],
exports: [ exports: [
AccessTokenService,
DeviceAuthService, DeviceAuthService,
DeviceAuthGuard, DeviceAuthGuard,
OptionalDeviceAuthGuard, OptionalDeviceAuthGuard,

View File

@ -29,6 +29,14 @@ export class AppConfigService {
return this.required('DEVICE_BOOTSTRAP_SECRET'); return this.required('DEVICE_BOOTSTRAP_SECRET');
} }
get accountAccessTokenSecret(): string {
return this.required('ACCOUNT_ACCESS_TOKEN_SECRET');
}
get accountAccessTokenTtlSeconds(): number {
return Number(this.required('ACCOUNT_ACCESS_TOKEN_TTL_SECONDS'));
}
get maxUploadSizeBytes(): number { get maxUploadSizeBytes(): number {
return Number(this.required('MAX_UPLOAD_SIZE_BYTES')); return Number(this.required('MAX_UPLOAD_SIZE_BYTES'));
} }

View File

@ -8,11 +8,14 @@ describe('validateEnvironment', () => {
DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public', DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public',
STORAGE_ROOT: '/tmp/velody', STORAGE_ROOT: '/tmp/velody',
PUBLIC_BASE_URL: 'http://localhost:3007', PUBLIC_BASE_URL: 'http://localhost:3007',
DEVICE_BOOTSTRAP_SECRET: 'secret', DEVICE_BOOTSTRAP_SECRET: 'device-secret',
ACCOUNT_ACCESS_TOKEN_SECRET: 'account-secret',
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
MAX_UPLOAD_SIZE_BYTES: '1024', MAX_UPLOAD_SIZE_BYTES: '1024',
}); });
expect(result.PORT).toBe(3007); expect(result.PORT).toBe(3007);
expect(result.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS).toBe(900);
expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024); expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024);
}); });
@ -23,4 +26,21 @@ describe('validateEnvironment', () => {
}), }),
).toThrow(/Invalid environment configuration/); ).toThrow(/Invalid environment configuration/);
}); });
it('rejects reuse of the device-token secret for account tokens', () => {
expect(() =>
validateEnvironment({
NODE_ENV: 'test',
PORT: '3007',
DATABASE_URL:
'postgresql://velody:velody@localhost:5432/velody?schema=public',
STORAGE_ROOT: '/tmp/velody',
PUBLIC_BASE_URL: 'http://localhost:3007',
DEVICE_BOOTSTRAP_SECRET: 'shared-secret',
ACCOUNT_ACCESS_TOKEN_SECRET: 'shared-secret',
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS: '900',
MAX_UPLOAD_SIZE_BYTES: '1024',
}),
).toThrow(/account and device token secrets must be different/);
});
}); });

View File

@ -37,6 +37,14 @@ class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
DEVICE_BOOTSTRAP_SECRET!: string; DEVICE_BOOTSTRAP_SECRET!: string;
@IsString()
@IsNotEmpty()
ACCOUNT_ACCESS_TOKEN_SECRET!: string;
@IsInt()
@Min(1)
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS!: number;
@IsInt() @IsInt()
@Min(1) @Min(1)
MAX_UPLOAD_SIZE_BYTES!: number; MAX_UPLOAD_SIZE_BYTES!: number;
@ -59,6 +67,15 @@ export function validateEnvironment(config: Record<string, unknown>) {
); );
} }
if (
validated.ACCOUNT_ACCESS_TOKEN_SECRET ===
validated.DEVICE_BOOTSTRAP_SECRET
) {
throw new Error(
'Invalid environment configuration: account and device token secrets must be different',
);
}
return validated; return validated;
} }

View File

@ -11,5 +11,9 @@ process.env.PUBLIC_BASE_URL =
process.env.PUBLIC_BASE_URL ?? 'http://localhost:3007'; process.env.PUBLIC_BASE_URL ?? 'http://localhost:3007';
process.env.DEVICE_BOOTSTRAP_SECRET = process.env.DEVICE_BOOTSTRAP_SECRET =
process.env.DEVICE_BOOTSTRAP_SECRET ?? 'test-secret'; process.env.DEVICE_BOOTSTRAP_SECRET ?? 'test-secret';
process.env.ACCOUNT_ACCESS_TOKEN_SECRET =
process.env.ACCOUNT_ACCESS_TOKEN_SECRET ?? 'test-account-access-secret';
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS =
process.env.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS ?? '900';
process.env.MAX_UPLOAD_SIZE_BYTES = process.env.MAX_UPLOAD_SIZE_BYTES =
process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824'; process.env.MAX_UPLOAD_SIZE_BYTES ?? '1073741824';

View File

@ -4,4 +4,6 @@ DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public
STORAGE_ROOT=/app/runtime/storage STORAGE_ROOT=/app/runtime/storage
PUBLIC_BASE_URL=http://localhost:3007 PUBLIC_BASE_URL=http://localhost:3007
DEVICE_BOOTSTRAP_SECRET=replace-me DEVICE_BOOTSTRAP_SECRET=replace-me
ACCOUNT_ACCESS_TOKEN_SECRET=replace-with-a-distinct-secret
ACCOUNT_ACCESS_TOKEN_TTL_SECONDS=900
MAX_UPLOAD_SIZE_BYTES=524288000 MAX_UPLOAD_SIZE_BYTES=524288000