feat: add OAuth service with PKCE, token management, and tests

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 09:20:09 +02:00
parent f47e1d74ae
commit 7309c080df
2 changed files with 449 additions and 0 deletions

View File

@@ -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<boolean> {
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();
}

View File

@@ -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<typeof createTestDb>;
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();
});
});
});