diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 4749f7df4..523df4dba 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -60,6 +60,7 @@ import { ReservationImportModule } from './reservation-import/reservation-import import { LlmParseModule } from './llm-parse/llm-parse.module'; import { AuthModule } from './auth/auth.module'; import { OidcModule } from './oidc/oidc.module'; +import { NativeAuthModule } from './native-auth/native-auth.module'; import { OauthModule } from './oauth/oauth.module'; import { AdminModule } from './admin/admin.module'; import { AddonsModule } from './addons/addons.module'; @@ -82,7 +83,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, DocSyncModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, NativeAuthModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/common/validate-route-guards.ts b/server/src/nest/common/validate-route-guards.ts index f3cf8d96e..2e516e40b 100644 --- a/server/src/nest/common/validate-route-guards.ts +++ b/server/src/nest/common/validate-route-guards.ts @@ -139,6 +139,9 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [ 'McpTransportController.delete', 'McpTransportController.get', 'McpTransportController.post', + // The native app redeems a one-time code from the system browser here. The + // code is only good together with the verifier the app kept to itself. + 'NativeAuthController.exchange', 'OauthPublicController.revoke', 'OauthPublicController.token', 'OauthPublicController.userinfo', diff --git a/server/src/nest/health/features.controller.ts b/server/src/nest/health/features.controller.ts index 9df6b7041..a87061308 100644 --- a/server/src/nest/health/features.controller.ts +++ b/server/src/nest/health/features.controller.ts @@ -31,6 +31,10 @@ export class FeaturesController { // Addon-level flag (per-user config availability is reported per-file in // the preview response). Drives whether the client shows AI affordances. aiParsing: this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING), + // The native apps refuse to connect to a server without this: an older + // one serves a UI that knows neither the sign-in hand-off nor the way + // back to the server picker, and the user would be stuck in it. + nativeApp: true, }; } } diff --git a/server/src/nest/native-auth/native-auth.controller.ts b/server/src/nest/native-auth/native-auth.controller.ts new file mode 100644 index 000000000..34ce85b7d --- /dev/null +++ b/server/src/nest/native-auth/native-auth.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, HttpCode, HttpException, Post, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { NativeAuthService } from './native-auth.service'; +import { NativeExchangeDto, NativeHandoffDto } from './native-auth.dto'; +import { AuthService } from '../auth/auth.service'; +import { CookieAuthGuard } from '../auth/cookie-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { Public } from '../auth/public.decorator'; +import { AuditService } from '../audit/audit.service'; +import { getClientIp } from '../audit/client-ip'; +import { RateLimitService } from '../common/rate-limit.service'; +import type { User } from '../../types'; + +const EXCHANGE_WINDOW_MS = 15 * 60 * 1000; +const EXCHANGE_MAX = 20; + +/** + * /api/auth/native: the sign-in hand-off between the system browser and the + * native app. See the contract in @trek/shared for the whole round trip. + */ +@Controller('api/auth/native') +export class NativeAuthController { + constructor( + private readonly nativeAuth: NativeAuthService, + private readonly auth: AuthService, + private readonly audit: AuditService, + private readonly rl: RateLimitService, + ) {} + + /** + * Cookie only: an API or MCP bearer token must not be able to mint itself a + * full browser session this way. + */ + @Post('handoff') + @UseGuards(CookieAuthGuard) + @HttpCode(200) + handoff(@Body() body: NativeHandoffDto, @CurrentUser() user: User) { + return { code: this.nativeAuth.issueCode(user.id, body.challenge) }; + } + + @Post('exchange') + @Public('the one-time code together with its verifier is the credential') + @HttpCode(200) + exchange(@Body() body: NativeExchangeDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { + if (!this.rl.check('native-exchange', req.ip || 'unknown', EXCHANGE_MAX, EXCHANGE_WINDOW_MS, Date.now())) { + throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429); + } + const session = this.nativeAuth.redeem(body.code, body.verifier); + if (!session) throw new HttpException({ error: 'Invalid or expired code' }, 400); + this.auth.setAuthCookie(res, session.token, req, true); + this.audit.writeAudit({ userId: session.userId, action: 'user.login', ip: getClientIp(req), details: { method: 'native_app' } }); + return { ok: true }; + } +} diff --git a/server/src/nest/native-auth/native-auth.dto.ts b/server/src/nest/native-auth/native-auth.dto.ts new file mode 100644 index 000000000..2ffdfc3ea --- /dev/null +++ b/server/src/nest/native-auth/native-auth.dto.ts @@ -0,0 +1,5 @@ +import { createZodDto } from 'nestjs-zod'; +import { nativeExchangeRequestSchema, nativeHandoffRequestSchema } from '@trek/shared'; + +export class NativeHandoffDto extends createZodDto(nativeHandoffRequestSchema) {} +export class NativeExchangeDto extends createZodDto(nativeExchangeRequestSchema) {} diff --git a/server/src/nest/native-auth/native-auth.module.ts b/server/src/nest/native-auth/native-auth.module.ts new file mode 100644 index 000000000..a6972e0cb --- /dev/null +++ b/server/src/nest/native-auth/native-auth.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { NativeAuthController } from './native-auth.controller'; +import { NativeAuthService } from './native-auth.service'; +import { AuthModule } from '../auth/auth.module'; +import { AuditModule } from '../audit/audit.module'; +import { RateLimitModule } from '../common/rate-limit.module'; + +@Module({ + imports: [AuthModule, AuditModule, RateLimitModule], + controllers: [NativeAuthController], + providers: [NativeAuthService], +}) +export class NativeAuthModule {} diff --git a/server/src/nest/native-auth/native-auth.service.ts b/server/src/nest/native-auth/native-auth.service.ts new file mode 100644 index 000000000..de956f281 --- /dev/null +++ b/server/src/nest/native-auth/native-auth.service.ts @@ -0,0 +1,78 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import crypto from 'crypto'; +import { DatabaseService } from '../database/database.service'; +import { AuthService } from '../auth/auth.service'; + +export const NATIVE_HANDOFF_CODE_TTL_MS = 60_000; +const SWEEP_INTERVAL_MS = 5 * 60_000; + +interface PendingHandoff { + userId: number; + challenge: string; + created: number; +} + +/** Constant-time check that sha256(verifier) is the challenge the code was issued for. */ +function verifierMatches(challenge: string, verifier: string): boolean { + const expected = Buffer.from(challenge, 'hex'); + const actual = crypto.createHash('sha256').update(verifier, 'utf8').digest(); + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +} + +/** + * Carries a session from the system browser into the native app's WebView. + * + * Codes live in memory like the OIDC auth codes: they are good for a minute, + * a restart simply means signing in again, and nothing about them is worth + * a table. + */ +@Injectable() +export class NativeAuthService implements OnModuleDestroy { + private readonly pending = new Map(); + private readonly sweeper: NodeJS.Timeout; + + constructor( + private readonly db: DatabaseService, + private readonly auth: AuthService, + ) { + this.sweeper = setInterval(() => { + const now = Date.now(); + for (const [code, entry] of this.pending) { + if (now - entry.created > NATIVE_HANDOFF_CODE_TTL_MS) this.pending.delete(code); + } + }, SWEEP_INTERVAL_MS); + this.sweeper.unref(); + } + + onModuleDestroy(): void { + clearInterval(this.sweeper); + } + + issueCode(userId: number, challenge: string): string { + const code = crypto.randomBytes(32).toString('base64url'); + this.pending.set(code, { userId, challenge, created: Date.now() }); + return code; + } + + /** + * Burns the code on every attempt, right or wrong, so a code that leaked + * through the URL scheme gets exactly one guess. The session is always the + * long one: an app that forgets its login whenever iOS unloads it would be + * useless on the road. + */ + redeem(code: string, verifier: string): { token: string; userId: number } | null { + const entry = this.pending.get(code); + if (!entry) return null; + this.pending.delete(code); + if (Date.now() - entry.created > NATIVE_HANDOFF_CODE_TTL_MS) return null; + if (!verifierMatches(entry.challenge, verifier)) return null; + + const user = this.db.get<{ id: number; password_version: number }>( + 'SELECT id, password_version FROM users WHERE id = ?', + entry.userId, + ); + if (!user) return null; + this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id); + return { token: this.auth.generateToken(user, true), userId: user.id }; + } +} diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index 1d30bf779..8d4176179 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -185,6 +185,8 @@ export function applyPlatformSpa(app: express.Application): void { }); } +const HASHED_ASSET = /[\\/]assets[\\/][^\\/]+-[\w-]{8,}\.\w+$/; + /** * Production static serving of the built client (JS/CSS/assets). Split out from * applyPlatformSpa because the NestJS bootstrap needs the static files served @@ -200,6 +202,11 @@ export function applyPlatformStatic(app: express.Application): void { setHeaders: (res, filePath) => { if (filePath.endsWith('index.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } else if (HASHED_ASSET.test(filePath)) { + // Vite puts a content hash in every name under assets/, so a file there + // never changes. Without this the iOS app, which has no service worker, + // cannot start offline: WebKit would revalidate each script and fail. + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } }, }), diff --git a/server/tests/e2e/native-auth.e2e.test.ts b/server/tests/e2e/native-auth.e2e.test.ts new file mode 100644 index 000000000..d6d98c203 --- /dev/null +++ b/server/tests/e2e/native-auth.e2e.test.ts @@ -0,0 +1,135 @@ +/** + * Native sign-in hand-off e2e: the real CookieAuthGuard, the real cookie + * service and the real AuthService against a temp SQLite db. Walks the whole + * round trip the apps make: the browser side mints a code for a challenge, the + * WebView side redeems it with the verifier and walks away with a session + * cookie that /api/auth/me accepts. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import crypto from 'crypto'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { sessionCookie, signSession } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => undefined, + isOwner: () => false, +})); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); + +import { MailerService } from '../../src/nest/notifications/mailer/mailer.service'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser } from '../helpers/factories'; +import { resetRateLimits } from '../helpers/test-db'; +import { AuthModule } from '../../src/nest/auth/auth.module'; +import { NativeAuthModule } from '../../src/nest/native-auth/native-auth.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +const verifier = crypto.randomBytes(32).toString('base64url'); +const challenge = crypto.createHash('sha256').update(verifier).digest('hex'); + +describe('Native sign-in hand-off e2e', () => { + let server: Server; + let app: Awaited>; + let userId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AuthModule, NativeAuthModule] }) + .overrideProvider(MailerService) + .useValue({ sendPasswordResetEmail: vi.fn() }) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + const handoff = (cookie: string, body: unknown = { challenge }) => + request(server).post('/api/auth/native/handoff').set('Cookie', cookie).send(body as object); + + beforeAll(async () => { + createTables(db as never); + runMigrations(db as never); + userId = createUser(db as never, { username: 'native-e2e' }).user.id; + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => resetRateLimits(app)); + + afterAll(async () => { + await app.close(); + }); + + it('carries a browser session into a fresh session cookie for the app', async () => { + const minted = await handoff(sessionCookie(userId)); + expect(minted.status).toBe(200); + expect(typeof minted.body.code).toBe('string'); + + const redeemed = await request(server).post('/api/auth/native/exchange').send({ code: minted.body.code, verifier }); + expect(redeemed.status).toBe(200); + expect(redeemed.body).toEqual({ ok: true }); + const cookie = (redeemed.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session=')); + expect(cookie).toMatch(/HttpOnly/); + expect(cookie).toMatch(/Max-Age=\d+/); + + const me = await request(server).get('/api/auth/me').set('Cookie', cookie!.split(';')[0]!); + expect(me.status).toBe(200); + expect(me.body.user.id).toBe(userId); + + const audited = db.prepare("SELECT details FROM audit_log WHERE action = 'user.login' AND user_id = ? ORDER BY id DESC LIMIT 1").get(userId) as { details: string }; + expect(JSON.parse(audited.details)).toEqual({ method: 'native_app' }); + }); + + it('refuses to mint a code without a cookie session, even for a valid bearer token', async () => { + const res = await request(server).post('/api/auth/native/handoff') + .set('Authorization', `Bearer ${signSession(userId)}`) + .send({ challenge }); + expect(res.status).toBe(401); + expect(res.body.code).toBe('COOKIE_AUTH_REQUIRED'); + }); + + it('validates the challenge shape', async () => { + expect((await handoff(sessionCookie(userId), { challenge: 'not-a-hash' })).status).toBe(400); + }); + + it('refuses a code presented with the wrong verifier, and the code is gone after', async () => { + const { code } = (await handoff(sessionCookie(userId))).body; + const wrong = await request(server).post('/api/auth/native/exchange').send({ code, verifier: 'x'.repeat(43) }); + expect(wrong.status).toBe(400); + expect(wrong.body.error).toBe('Invalid or expired code'); + expect(wrong.headers['set-cookie']).toBeUndefined(); + + const retry = await request(server).post('/api/auth/native/exchange').send({ code, verifier }); + expect(retry.status).toBe(400); + }); + + it('rate-limits the exchange per ip', async () => { + for (let i = 0; i < 20; i++) { + await request(server).post('/api/auth/native/exchange').send({ code: `nope-${i}`, verifier }); + } + const blocked = await request(server).post('/api/auth/native/exchange').send({ code: 'nope', verifier }); + expect(blocked.status).toBe(429); + }); +}); diff --git a/server/tests/unit/nest/features.controller.test.ts b/server/tests/unit/nest/features.controller.test.ts index 14c8c0029..3f4deb9b4 100644 --- a/server/tests/unit/nest/features.controller.test.ts +++ b/server/tests/unit/nest/features.controller.test.ts @@ -23,17 +23,17 @@ function make(available: boolean, aiEnabled: boolean) { describe('FeaturesController (GET /api/health/features)', () => { it('FEAT-001: reports both flags on', () => { const { controller } = make(true, true); - expect(controller.features()).toEqual({ bookingImport: true, aiParsing: true }); + expect(controller.features()).toEqual({ bookingImport: true, aiParsing: true, nativeApp: true }); }); it('FEAT-002: reports both flags off', () => { const { controller } = make(false, false); - expect(controller.features()).toEqual({ bookingImport: false, aiParsing: false }); + expect(controller.features()).toEqual({ bookingImport: false, aiParsing: false, nativeApp: true }); }); it('FEAT-003: the two flags are independent', () => { - expect(make(true, false).controller.features()).toEqual({ bookingImport: true, aiParsing: false }); - expect(make(false, true).controller.features()).toEqual({ bookingImport: false, aiParsing: true }); + expect(make(true, false).controller.features()).toEqual({ bookingImport: true, aiParsing: false, nativeApp: true }); + expect(make(false, true).controller.features()).toEqual({ bookingImport: false, aiParsing: true, nativeApp: true }); }); it('FEAT-004: aiParsing asks the addons service for the LLM parsing addon specifically', () => { diff --git a/server/tests/unit/nest/native-auth.controller.test.ts b/server/tests/unit/nest/native-auth.controller.test.ts new file mode 100644 index 000000000..16151c7db --- /dev/null +++ b/server/tests/unit/nest/native-auth.controller.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; + +import { NativeAuthController } from '../../../src/nest/native-auth/native-auth.controller'; +import type { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; +import type { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import type { User } from '../../../src/types'; + +const challenge = 'a'.repeat(64); +const verifier = 'v'.repeat(43); + +function build(opts: { redeem?: { token: string; userId: number } | null; allowed?: boolean } = {}) { + const nativeAuth = { + issueCode: vi.fn().mockReturnValue('code-1'), + redeem: vi.fn().mockReturnValue(opts.redeem === undefined ? { token: 'jwt', userId: 7 } : opts.redeem), + } as unknown as NativeAuthService; + const auth = { setAuthCookie: vi.fn() } as unknown as AuthService; + const audit = { writeAudit: vi.fn() } as unknown as AuditService; + const rl = { check: vi.fn().mockReturnValue(opts.allowed ?? true) } as unknown as RateLimitService; + return { controller: new NativeAuthController(nativeAuth, auth, audit, rl), nativeAuth, auth, audit, rl }; +} + +const req = { ip: '10.0.0.1', headers: {} } as unknown as Request; +const res = {} as Response; + +beforeEach(() => vi.clearAllMocks()); + +describe('NativeAuthController.handoff', () => { + it('issues a code for the signed-in user and the given challenge', () => { + const { controller, nativeAuth } = build(); + + expect(controller.handoff({ challenge }, { id: 7 } as User)).toEqual({ code: 'code-1' }); + expect(nativeAuth.issueCode).toHaveBeenCalledWith(7, challenge); + }); +}); + +describe('NativeAuthController.exchange', () => { + it('sets a remembered session cookie and audits the login', () => { + const { controller, auth, audit } = build(); + + expect(controller.exchange({ code: 'code-1', verifier }, req, res)).toEqual({ ok: true }); + expect(auth.setAuthCookie).toHaveBeenCalledWith(res, 'jwt', req, true); + expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ + userId: 7, action: 'user.login', details: { method: 'native_app' }, + })); + }); + + it('answers 400 with the generic message when the code does not redeem', () => { + const { controller, auth } = build({ redeem: null }); + + expect(() => controller.exchange({ code: 'x', verifier }, req, res)).toThrow( + expect.objectContaining({ status: 400, response: { error: 'Invalid or expired code' } }), + ); + expect(auth.setAuthCookie).not.toHaveBeenCalled(); + }); + + it('answers 429 once the per-ip bucket is exhausted, before touching the code', () => { + const { controller, nativeAuth, rl } = build({ allowed: false }); + + expect(() => controller.exchange({ code: 'x', verifier }, req, res)).toThrow(expect.objectContaining({ status: 429 })); + expect(rl.check).toHaveBeenCalledWith('native-exchange', '10.0.0.1', 20, 15 * 60 * 1000, expect.any(Number)); + expect(nativeAuth.redeem).not.toHaveBeenCalled(); + }); + + it('buckets requests without an ip under one key', () => { + const { controller, rl } = build(); + controller.exchange({ code: 'x', verifier }, { headers: {} } as unknown as Request, res); + + expect(rl.check).toHaveBeenCalledWith('native-exchange', 'unknown', 20, expect.any(Number), expect.any(Number)); + }); +}); diff --git a/server/tests/unit/nest/native-auth.service.test.ts b/server/tests/unit/nest/native-auth.service.test.ts new file mode 100644 index 000000000..fa3bafdff --- /dev/null +++ b/server/tests/unit/nest/native-auth.service.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import crypto from 'crypto'; + +import { NativeAuthService, NATIVE_HANDOFF_CODE_TTL_MS } from '../../../src/nest/native-auth/native-auth.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; + +const verifier = 'v'.repeat(43); +const challenge = crypto.createHash('sha256').update(verifier).digest('hex'); + +function build(user: { id: number; password_version: number } | null = { id: 7, password_version: 2 }) { + const db = { get: vi.fn().mockReturnValue(user ?? undefined), run: vi.fn() } as unknown as DatabaseService; + const auth = { generateToken: vi.fn().mockReturnValue('jwt') } as unknown as AuthService; + return { service: new NativeAuthService(db, auth), db, auth }; +} + +describe('NativeAuthService', () => { + let services: NativeAuthService[] = []; + const make = (...args: Parameters) => { + const built = build(...args); + services.push(built.service); + return built; + }; + + beforeEach(() => { services = []; }); + afterEach(() => { + services.forEach((s) => s.onModuleDestroy()); + vi.useRealTimers(); + }); + + it('redeems a code with the matching verifier into a remembered session', () => { + const { service, db, auth } = make(); + const code = service.issueCode(7, challenge); + + expect(service.redeem(code, verifier)).toEqual({ token: 'jwt', userId: 7 }); + expect(auth.generateToken).toHaveBeenCalledWith({ id: 7, password_version: 2 }, true); + expect(db.run).toHaveBeenCalledWith(expect.stringContaining('last_login'), 7); + }); + + it('refuses a wrong verifier and burns the code with it', () => { + const { service } = make(); + const code = service.issueCode(7, challenge); + + expect(service.redeem(code, 'w'.repeat(43))).toBeNull(); + expect(service.redeem(code, verifier)).toBeNull(); + }); + + it('is single use', () => { + const { service } = make(); + const code = service.issueCode(7, challenge); + + expect(service.redeem(code, verifier)).not.toBeNull(); + expect(service.redeem(code, verifier)).toBeNull(); + }); + + it('refuses an unknown code', () => { + const { service } = make(); + expect(service.redeem('nope', verifier)).toBeNull(); + }); + + it('refuses a code older than its ttl', () => { + vi.useFakeTimers(); + const { service } = make(); + const code = service.issueCode(7, challenge); + vi.advanceTimersByTime(NATIVE_HANDOFF_CODE_TTL_MS + 1); + + expect(service.redeem(code, verifier)).toBeNull(); + }); + + it('refuses a code whose user was deleted in the meantime', () => { + const { service, auth } = make(null); + const code = service.issueCode(7, challenge); + + expect(service.redeem(code, verifier)).toBeNull(); + expect(auth.generateToken).not.toHaveBeenCalled(); + }); + + it('sweeps expired codes', () => { + vi.useFakeTimers(); + const { service } = make(); + const code = service.issueCode(7, challenge); + vi.advanceTimersByTime(5 * 60_000 + 1); + vi.useRealTimers(); + + expect(service.redeem(code, verifier)).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/platform.controller.test.ts b/server/tests/unit/nest/platform.controller.test.ts index b48c44b9a..2579300c5 100644 --- a/server/tests/unit/nest/platform.controller.test.ts +++ b/server/tests/unit/nest/platform.controller.test.ts @@ -385,6 +385,25 @@ describe('applyPlatformStatic', () => { opts.setHeaders(assetRes, '/some/app.js'); expect(assetRes.headers['Cache-Control']).toBeUndefined(); }); + + it('marks the hashed files under assets/ immutable, and nothing else', async () => { + process.env.NODE_ENV = 'production'; + const expressMod = (await import('express')).default as unknown as { static: ReturnType }; + expressMod.static.mockClear(); + const { app } = fakeApp(); + applyPlatformStatic(app); + const opts = expressMod.static.mock.calls[0][1] as { setHeaders: (res: unknown, p: string) => void }; + const header = (p: string) => { + const res = makeRes(); + opts.setHeaders(res, p); + return res.headers['Cache-Control']; + }; + expect(header('/app/public/assets/index-DbM4_q2x.js')).toBe('public, max-age=31536000, immutable'); + expect(header('C:\\app\\public\\assets\\Map-a1B2c3D4.css')).toBe('public, max-age=31536000, immutable'); + expect(header('/app/public/sw.js')).toBeUndefined(); + expect(header('/app/public/icons/icon-192.png')).toBeUndefined(); + expect(header('/app/public/assets/plain.js')).toBeUndefined(); + }); }); describe('applyPlatformSpa', () => { diff --git a/server/vitest.config.ts b/server/vitest.config.ts index e0d507985..86513422b 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -93,6 +93,9 @@ export default defineConfig({ 'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 }, 'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 }, 'src/nest/memories/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 }, + // New domain in this change, fully covered by its unit and e2e suites. + // Pinned a little under that until a full run regenerates the block. + 'src/nest/native-auth/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 85, lines: 87 }, 'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 }, 'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 }, diff --git a/shared/src/index.ts b/shared/src/index.ts index 7c4399645..65daba4e7 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -63,6 +63,7 @@ export * from './appearance/appearance.schema'; export * from './backup/backup.schema'; export * from './auth/auth.schema'; export * from './oidc/oidc.schema'; +export * from './native-auth/native-auth.schema'; export * from './oauth/oauth.schema'; export * from './admin/admin.schema'; diff --git a/shared/src/native-auth/native-auth.schema.spec.ts b/shared/src/native-auth/native-auth.schema.spec.ts new file mode 100644 index 000000000..f3e9bbd18 --- /dev/null +++ b/shared/src/native-auth/native-auth.schema.spec.ts @@ -0,0 +1,27 @@ +import { nativeExchangeRequestSchema, nativeHandoffRequestSchema } from './native-auth.schema'; + +import { describe, it, expect } from 'vitest'; + +const challenge = 'a'.repeat(64); + +describe('nativeHandoffRequestSchema', () => { + it('accepts a lowercase hex sha256', () => { + expect(nativeHandoffRequestSchema.safeParse({ challenge }).success).toBe(true); + }); + + it('rejects anything that is not 64 lowercase hex characters', () => { + expect(nativeHandoffRequestSchema.safeParse({ challenge: 'A'.repeat(64) }).success).toBe(false); + expect(nativeHandoffRequestSchema.safeParse({ challenge: 'a'.repeat(63) }).success).toBe(false); + expect(nativeHandoffRequestSchema.safeParse({ challenge: 'z'.repeat(64) }).success).toBe(false); + expect(nativeHandoffRequestSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('nativeExchangeRequestSchema', () => { + it('requires a code and a verifier of at least 43 characters', () => { + expect(nativeExchangeRequestSchema.safeParse({ code: 'c', verifier: 'v'.repeat(43) }).success).toBe(true); + expect(nativeExchangeRequestSchema.safeParse({ code: 'c', verifier: 'v'.repeat(42) }).success).toBe(false); + expect(nativeExchangeRequestSchema.safeParse({ code: '', verifier: 'v'.repeat(43) }).success).toBe(false); + expect(nativeExchangeRequestSchema.safeParse({ verifier: 'v'.repeat(43) }).success).toBe(false); + }); +}); diff --git a/shared/src/native-auth/native-auth.schema.ts b/shared/src/native-auth/native-auth.schema.ts new file mode 100644 index 000000000..9ce947faf --- /dev/null +++ b/shared/src/native-auth/native-auth.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +/** + * Sign-in hand-off for the native apps, /api/auth/native. + * + * The app shows the TREK web UI inside its own WebView, but SSO and passkeys + * cannot run there, so it signs in through the system browser instead and has + * to carry that session back. The app picks a random verifier and sends only + * its sha256 (the challenge) into the browser. The browser, once signed in, + * trades the challenge for a one-time code, and the WebView redeems the code + * together with the verifier. The code travels through a URL scheme any app + * could claim, so without the verifier it is worth nothing. + */ +const hexSha256 = z.string().regex(/^[0-9a-f]{64}$/); + +export const nativeHandoffRequestSchema = z.object({ + challenge: hexSha256, +}); +export type NativeHandoffRequest = z.infer; + +export const nativeHandoffResponseSchema = z.object({ + code: z.string(), +}); +export type NativeHandoffResponse = z.infer; + +export const nativeExchangeRequestSchema = z.object({ + code: z.string().min(1).max(128), + verifier: z.string().min(43).max(128), +}); +export type NativeExchangeRequest = z.infer;