diff --git a/backend/scripts/generate-openapi.ts b/backend/scripts/generate-openapi.ts index add9966..3d17e5a 100644 --- a/backend/scripts/generate-openapi.ts +++ b/backend/scripts/generate-openapi.ts @@ -11,6 +11,9 @@ async function generate(): Promise { process.env.STORAGE_ROOT ??= join(process.cwd(), '..', 'runtime', 'storage'); process.env.PUBLIC_BASE_URL ??= 'http://localhost:3007'; 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'; const { createApp } = await import('../src/app.factory'); diff --git a/backend/src/modules/auth/access-token.service.spec.ts b/backend/src/modules/auth/access-token.service.spec.ts new file mode 100644 index 0000000..67e25ca --- /dev/null +++ b/backend/src/modules/auth/access-token.service.spec.ts @@ -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, + secret = signingSecret, + header: Record = { 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, + ); + }); +}); diff --git a/backend/src/modules/auth/access-token.service.ts b/backend/src/modules/auth/access-token.service.ts new file mode 100644 index 0000000..eb8782e --- /dev/null +++ b/backend/src/modules/auth/access-token.service.ts @@ -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; + +@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 { + 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 { + 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'); + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 3a56c26..ccdf99b 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware'; import { RequestContextModule } from '../../infrastructure/request-context/request-context.module'; +import { AccessTokenService } from './access-token.service'; import { DeviceAuthGuard } from './device-auth.guard'; import { DeviceAuthService } from './device-auth.service'; import { OptionalDeviceAuthGuard } from './optional-device-auth.guard'; @@ -11,6 +12,7 @@ import { SessionService } from './session.service'; @Module({ imports: [PrismaModule, RequestContextModule], providers: [ + AccessTokenService, DeviceAuthService, DeviceAuthGuard, OptionalDeviceAuthGuard, @@ -18,6 +20,7 @@ import { SessionService } from './session.service'; SessionService, ], exports: [ + AccessTokenService, DeviceAuthService, DeviceAuthGuard, OptionalDeviceAuthGuard, diff --git a/backend/src/modules/config/config.service.ts b/backend/src/modules/config/config.service.ts index f1a0122..807acad 100644 --- a/backend/src/modules/config/config.service.ts +++ b/backend/src/modules/config/config.service.ts @@ -29,6 +29,14 @@ export class AppConfigService { 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 { return Number(this.required('MAX_UPLOAD_SIZE_BYTES')); } diff --git a/backend/src/modules/config/environment.spec.ts b/backend/src/modules/config/environment.spec.ts index 50c9c7b..7b3ef54 100644 --- a/backend/src/modules/config/environment.spec.ts +++ b/backend/src/modules/config/environment.spec.ts @@ -8,11 +8,14 @@ describe('validateEnvironment', () => { DATABASE_URL: 'postgresql://velody:velody@localhost:5432/velody?schema=public', STORAGE_ROOT: '/tmp/velody', 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', }); expect(result.PORT).toBe(3007); + expect(result.ACCOUNT_ACCESS_TOKEN_TTL_SECONDS).toBe(900); expect(result.MAX_UPLOAD_SIZE_BYTES).toBe(1024); }); @@ -23,4 +26,21 @@ describe('validateEnvironment', () => { }), ).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/); + }); }); diff --git a/backend/src/modules/config/environment.ts b/backend/src/modules/config/environment.ts index 1ffc9ff..76c4541 100644 --- a/backend/src/modules/config/environment.ts +++ b/backend/src/modules/config/environment.ts @@ -37,6 +37,14 @@ class EnvironmentVariables { @IsNotEmpty() DEVICE_BOOTSTRAP_SECRET!: string; + @IsString() + @IsNotEmpty() + ACCOUNT_ACCESS_TOKEN_SECRET!: string; + + @IsInt() + @Min(1) + ACCOUNT_ACCESS_TOKEN_TTL_SECONDS!: number; + @IsInt() @Min(1) MAX_UPLOAD_SIZE_BYTES!: number; @@ -59,6 +67,15 @@ export function validateEnvironment(config: Record) { ); } + 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; } diff --git a/backend/test/setup-env.ts b/backend/test/setup-env.ts index 1568c1e..09a45cc 100644 --- a/backend/test/setup-env.ts +++ b/backend/test/setup-env.ts @@ -11,5 +11,9 @@ process.env.PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ?? 'http://localhost:3007'; process.env.DEVICE_BOOTSTRAP_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 ?? '1073741824'; diff --git a/infra/docker/env/backend.env.example b/infra/docker/env/backend.env.example index 5cc1d18..ed397ef 100644 --- a/infra/docker/env/backend.env.example +++ b/infra/docker/env/backend.env.example @@ -4,4 +4,6 @@ DATABASE_URL=postgresql://velody:velody@postgres:5432/velody?schema=public STORAGE_ROOT=/app/runtime/storage PUBLIC_BASE_URL=http://localhost:3007 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