mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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');
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user