mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +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.
74 lines
3.4 KiB
TypeScript
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));
|
|
});
|
|
});
|