feat(auth): hand a browser sign-in over to the native app

The native apps show the server's own UI in a WebView, where neither SSO nor
passkeys can run. They sign in through the system browser instead and need a
way to carry that session back.

The app keeps a random verifier and sends only its sha256 into the browser.
Once signed in there, the browser trades that challenge for a one-time code
(cookie session only, so an API or MCP token cannot mint itself a browser
session), and the WebView redeems the code together with the verifier. The
code rides through a URL scheme any app could claim, so on its own it is
worthless; it is single use and good for a minute.

Also: /api/health/features reports nativeApp so the apps can refuse servers
that predate this, and hashed files under assets/ are served immutable,
which the iOS app needs to start offline without a service worker.
This commit is contained in:
Maurice
2026-09-24 12:19:10 +02:00
parent 14825df993
commit 9832d12751
17 changed files with 545 additions and 5 deletions
+2 -1
View File
@@ -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
@@ -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',
@@ -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,
};
}
}
@@ -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 };
}
}
@@ -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) {}
@@ -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 {}
@@ -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<string, PendingHandoff>();
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 };
}
}
@@ -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');
}
},
}),
+135
View File
@@ -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<ReturnType<typeof build>>;
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);
});
});
@@ -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', () => {
@@ -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));
});
});
@@ -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<typeof build>) => {
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();
});
});
@@ -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<typeof vi.fn> };
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', () => {
+3
View File
@@ -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 },
+1
View File
@@ -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';
@@ -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);
});
});
@@ -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<typeof nativeHandoffRequestSchema>;
export const nativeHandoffResponseSchema = z.object({
code: z.string(),
});
export type NativeHandoffResponse = z.infer<typeof nativeHandoffResponseSchema>;
export const nativeExchangeRequestSchema = z.object({
code: z.string().min(1).max(128),
verifier: z.string().min(43).max(128),
});
export type NativeExchangeRequest = z.infer<typeof nativeExchangeRequestSchema>;