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>
291 lines
7.0 KiB
TypeScript
291 lines
7.0 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|