trails/apps/journal/app/lib/oauth.server.test.ts
Ullrich Schäfer 1ae406a8aa
Implement OAuth2 PKCE auth, discovery, and mobile API client
Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:41:40 +02:00

106 lines
3.3 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { createHash, randomBytes } from "node:crypto";
// Mock the database module before importing oauth
vi.mock("./db.ts", () => {
const store: Record<string, unknown[]> = {
oauth_clients: [],
oauth_codes: [],
oauth_tokens: [],
};
return {
getDb: () => ({
select: () => ({
from: () => ({
where: () => {
// Return empty array by default; tests override via __store
return [];
},
}),
}),
insert: () => ({
values: () => ({
onConflictDoUpdate: () => Promise.resolve(),
}),
}),
update: () => ({
set: () => ({
where: () => Promise.resolve(),
}),
}),
}),
__store: store,
};
});
/**
* Test PKCE verification logic directly.
* The full OAuth flow requires a real database, so we test the crypto
* primitives and error handling here.
*/
describe("PKCE verification", () => {
it("S256: verifier matches challenge", () => {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
// Re-derive the challenge from the verifier
const derived = createHash("sha256").update(verifier).digest("base64url");
expect(derived).toBe(challenge);
});
it("S256: wrong verifier does not match", () => {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const wrongVerifier = randomBytes(32).toString("base64url");
const wrongDerived = createHash("sha256").update(wrongVerifier).digest("base64url");
expect(wrongDerived).not.toBe(challenge);
});
it("S256: challenge is base64url-encoded SHA-256", () => {
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const challenge = createHash("sha256").update(verifier).digest("base64url");
// base64url has no padding, no +, no /
expect(challenge).not.toContain("=");
expect(challenge).not.toContain("+");
expect(challenge).not.toContain("/");
expect(challenge.length).toBeGreaterThan(0);
});
});
describe("OAuthError", () => {
it("has code and message", async () => {
const { OAuthError } = await import("./oauth.server.ts");
const err = new OAuthError("invalid_grant", "Bad code");
expect(err.code).toBe("invalid_grant");
expect(err.message).toBe("Bad code");
expect(err).toBeInstanceOf(Error);
});
});
describe("token generation", () => {
it("generates unique tokens", async () => {
const { randomBytes } = await import("node:crypto");
const tokens = new Set<string>();
for (let i = 0; i < 100; i++) {
tokens.add(randomBytes(32).toString("base64url"));
}
expect(tokens.size).toBe(100);
});
it("tokens are base64url encoded", async () => {
const token = randomBytes(32).toString("base64url");
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
expect(token.length).toBe(43); // 32 bytes → 43 base64url chars
});
});
describe("seedOAuthClient", () => {
it("can be imported and called", async () => {
const { seedOAuthClient } = await import("./oauth.server.ts");
// Should not throw with mocked DB
await seedOAuthClient("test-client", "testapp://callback", true);
});
});