Files
TREK/server/tests/unit/nest/native-auth.controller.test.ts
T
Maurice 9832d12751 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.
2026-09-24 12:19:10 +02:00

74 lines
3.4 KiB
TypeScript

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));
});
});