mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
9832d12751
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.
88 lines
3.1 KiB
TypeScript
88 lines
3.1 KiB
TypeScript
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();
|
|
});
|
|
});
|