From 7309c080df824bc628757d670779d820bd9cbcb5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 4 Apr 2026 09:20:09 +0200 Subject: [PATCH] feat: add OAuth service with PKCE, token management, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements client registration, authorization code flow with PKCE (S256), access/refresh token generation/verification, and cleanup utilities. Follows TDD — all 12 service-level tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/server/services/oauth.service.ts | 159 +++++++++++++++ tests/services/oauth.service.test.ts | 290 +++++++++++++++++++++++++++ 2 files changed, 449 insertions(+) create mode 100644 src/server/services/oauth.service.ts create mode 100644 tests/services/oauth.service.test.ts diff --git a/src/server/services/oauth.service.ts b/src/server/services/oauth.service.ts new file mode 100644 index 0000000..85020c9 --- /dev/null +++ b/src/server/services/oauth.service.ts @@ -0,0 +1,159 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { and, eq, lt } from "drizzle-orm"; +import { db as prodDb } from "../../db/index.ts"; +import { oauthClients, oauthCodes, oauthTokens } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +// ── Client Registration ────────────────────────────────────────────── + +export function registerClient( + db: Db = prodDb, + clientName: string, + redirectUris: string[], +): { clientId: string } { + const clientId = randomUUID(); + const redirectUrisJson = JSON.stringify(redirectUris); + + db.insert(oauthClients) + .values({ clientId, clientName, redirectUris: redirectUrisJson }) + .run(); + + return { clientId }; +} + +export function getClient(db: Db = prodDb, clientId: string) { + return ( + db + .select() + .from(oauthClients) + .where(eq(oauthClients.clientId, clientId)) + .get() ?? null + ); +} + +// ── Authorization Code ─────────────────────────────────────────────── + +export function createAuthorizationCode( + db: Db = prodDb, + clientId: string, + codeChallenge: string, + codeChallengeMethod: string, + redirectUri: string, +): { code: string } { + const code = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + db.insert(oauthCodes) + .values({ code, clientId, codeChallenge, codeChallengeMethod, redirectUri, expiresAt }) + .run(); + + return { code }; +} + +export async function exchangeCode( + db: Db = prodDb, + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string, +): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> { + const record = db + .select() + .from(oauthCodes) + .where(eq(oauthCodes.code, code)) + .get(); + + if (!record) return null; + if (record.used !== 0) return null; + if (record.clientId !== clientId) return null; + if (record.redirectUri !== redirectUri) return null; + if (record.expiresAt < new Date()) return null; + + // Verify PKCE: SHA-256(verifier) encoded as base64url must match stored challenge + const computedChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + + if (computedChallenge !== record.codeChallenge) return null; + + // Mark code as used + db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.code, code)).run(); + + return generateTokens(db, clientId); +} + +// ── Token Management ───────────────────────────────────────────────── + +function generateTokens( + db: Db, + clientId: string, +): { accessToken: string; refreshToken: string; expiresIn: number } { + const accessToken = randomBytes(32).toString("hex"); + const refreshToken = randomBytes(32).toString("hex"); + + const accessTokenHash = createHash("sha256").update(accessToken).digest("hex"); + const refreshTokenHash = createHash("sha256").update(refreshToken).digest("hex"); + + const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour + const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + + db.insert(oauthTokens) + .values({ accessTokenHash, refreshTokenHash, clientId, expiresAt, refreshExpiresAt }) + .run(); + + return { accessToken, refreshToken, expiresIn: 3600 }; +} + +export async function verifyAccessToken( + db: Db = prodDb, + token: string, +): Promise { + const tokenHash = createHash("sha256").update(token).digest("hex"); + + const record = db + .select() + .from(oauthTokens) + .where(eq(oauthTokens.accessTokenHash, tokenHash)) + .get(); + + if (!record) return false; + if (record.expiresAt < new Date()) return false; + + return true; +} + +export async function refreshAccessToken( + db: Db = prodDb, + refreshToken: string, + clientId: string, +): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> { + const tokenHash = createHash("sha256").update(refreshToken).digest("hex"); + + const record = db + .select() + .from(oauthTokens) + .where( + and( + eq(oauthTokens.refreshTokenHash, tokenHash), + eq(oauthTokens.clientId, clientId), + ), + ) + .get(); + + if (!record) return null; + if (record.refreshExpiresAt < new Date()) return null; + + // Delete old token pair + db.delete(oauthTokens).where(eq(oauthTokens.id, record.id)).run(); + + return generateTokens(db, clientId); +} + +// ── Cleanup ────────────────────────────────────────────────────────── + +export function cleanExpiredOAuthData(db: Db = prodDb): void { + const now = new Date(); + db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)).run(); + db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)).run(); +} diff --git a/tests/services/oauth.service.test.ts b/tests/services/oauth.service.test.ts new file mode 100644 index 0000000..8172ec4 --- /dev/null +++ b/tests/services/oauth.service.test.ts @@ -0,0 +1,290 @@ +import { createHash, randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it } from "bun:test"; +import { + createAuthorizationCode, + exchangeCode, + getClient, + refreshAccessToken, + registerClient, + verifyAccessToken, +} from "../../src/server/services/oauth.service.ts"; +import { createTestDb } from "../helpers/db.ts"; + +function generatePkce() { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +describe("OAuth Service", () => { + let db: ReturnType; + + beforeEach(() => { + db = createTestDb(); + }); + + describe("Client Registration", () => { + it("registers a client and returns clientId (string, non-empty)", () => { + const result = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + + expect(result).toBeDefined(); + expect(typeof result.clientId).toBe("string"); + expect(result.clientId.length).toBeGreaterThan(0); + }); + + it("getClient returns registered client with correct clientName and redirectUris (JSON parsed)", () => { + const redirectUris = ["http://localhost:8080/callback"]; + const { clientId } = registerClient(db, "Test App", redirectUris); + + const client = getClient(db, clientId); + + expect(client).not.toBeNull(); + expect(client!.clientName).toBe("Test App"); + expect(JSON.parse(client!.redirectUris)).toEqual(redirectUris); + }); + + it("getClient returns null for unknown client", () => { + const client = getClient(db, "unknown-client-id"); + + expect(client).toBeNull(); + }); + }); + + describe("Authorization Code + PKCE", () => { + it("creates an authorization code (non-empty)", () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { challenge } = generatePkce(); + + const result = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + expect(result).toBeDefined(); + expect(typeof result.code).toBe("string"); + expect(result.code.length).toBeGreaterThan(0); + }); + + it("exchanges code for tokens with valid PKCE verifier (returns accessToken, refreshToken, expiresIn=3600)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + + expect(tokens).not.toBeNull(); + expect(typeof tokens!.accessToken).toBe("string"); + expect(tokens!.accessToken.length).toBeGreaterThan(0); + expect(typeof tokens!.refreshToken).toBe("string"); + expect(tokens!.refreshToken.length).toBeGreaterThan(0); + expect(tokens!.expiresIn).toBe(3600); + }); + + it("rejects code exchange with wrong PKCE verifier (returns null)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + "wrongverifier", + clientId, + "http://localhost:8080/callback", + ); + + expect(tokens).toBeNull(); + }); + + it("rejects code exchange with wrong redirect_uri (returns null)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:9999/wrong", + ); + + expect(tokens).toBeNull(); + }); + + it("rejects replayed code - single use (second exchange returns null)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + // First exchange succeeds + const first = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + expect(first).not.toBeNull(); + + // Second exchange is rejected + const second = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + expect(second).toBeNull(); + }); + }); + + describe("Token Verification", () => { + it("verifies a valid access token (returns true)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + + const isValid = await verifyAccessToken(db, tokens!.accessToken); + expect(isValid).toBe(true); + }); + + it("rejects an unknown token (returns false)", async () => { + const isValid = await verifyAccessToken(db, "unknowntoken12345678"); + expect(isValid).toBe(false); + }); + }); + + describe("Token Refresh", () => { + it("refreshes a valid refresh token and returns new tokens (different accessToken)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + + const newTokens = await refreshAccessToken( + db, + tokens!.refreshToken, + clientId, + ); + + expect(newTokens).not.toBeNull(); + expect(newTokens!.accessToken).not.toBe(tokens!.accessToken); + expect(typeof newTokens!.refreshToken).toBe("string"); + expect(newTokens!.expiresIn).toBe(3600); + }); + + it("rejects refresh with wrong clientId (returns null)", async () => { + const { clientId } = registerClient(db, "Test App", [ + "http://localhost:8080/callback", + ]); + const { verifier, challenge } = generatePkce(); + + const { code } = createAuthorizationCode( + db, + clientId, + challenge, + "S256", + "http://localhost:8080/callback", + ); + + const tokens = await exchangeCode( + db, + code, + verifier, + clientId, + "http://localhost:8080/callback", + ); + + const newTokens = await refreshAccessToken( + db, + tokens!.refreshToken, + "wrong-client-id", + ); + + expect(newTokens).toBeNull(); + }); + }); +});