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>
106 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|