diff --git a/README.md b/README.md index 13c8821..7feee03 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ ### `@listee/auth` - Exposes reusable authentication providers under `packages/auth/src/authentication/`. -- `createHeaderAuthentication` performs lightweight header extraction suitable for development stubs. - `createSupabaseAuthentication` validates Supabase-issued JWT access tokens against the project's JWKS (`/auth/v1/.well-known/jwks.json`), enforces issuer/audience/role constraints, and returns a typed `SupabaseToken` payload. - Shared utilities (`shared.ts`, `errors.ts`) handle predictable error surfaces; tests live beside the implementation (`supabase.test.ts`) and exercise positive/negative paths. - The package emits declarations from `src/` only; test files are excluded from `dist/` via `tsconfig.json`. diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index 2bdf43a..63e7640 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -1,18 +1,61 @@ import { describe, expect, test } from "bun:test"; -import { createHeaderAuthentication } from "@listee/auth"; +import { AuthenticationError } from "@listee/auth"; import type { + AuthenticationProvider, Category, CategoryQueries, ListCategoriesResult, + SupabaseToken, Task, TaskQueries, } from "@listee/types"; import { createApp } from "./app"; +const BASE_CLAIMS = { + iss: "https://example.supabase.co/auth/v1", + aud: "authenticated" as const, + exp: 1_700_000_000, + iat: 1_700_000_000, +}; + function createRequest(path: string, init: RequestInit = {}): Request { return new Request(`http://localhost${path}`, init); } +function createTestAuthentication(): AuthenticationProvider { + return { + async authenticate({ request }) { + const header = request.headers.get("authorization"); + if (header === null) { + throw new AuthenticationError("Missing authorization header"); + } + + const prefix = "Bearer "; + if (!header.startsWith(prefix)) { + throw new AuthenticationError("Invalid authorization scheme"); + } + + const tokenValue = header.slice(prefix.length).trim(); + if (tokenValue.length === 0) { + throw new AuthenticationError("Missing token value"); + } + + const token: SupabaseToken = { + ...BASE_CLAIMS, + sub: tokenValue, + role: "authenticated", + }; + + return { + user: { + id: tokenValue, + token, + }, + }; + }, + }; +} + describe("health routes", () => { test("returns ok status", async () => { const app = createApp(); @@ -61,7 +104,7 @@ describe("health routes", () => { describe("category routes", () => { test("lists categories for a user", async () => { const { categoryQueries, categories } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const response = await app.fetch( @@ -80,7 +123,7 @@ describe("category routes", () => { test("rejects invalid limit", async () => { const { categoryQueries } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const response = await app.fetch( @@ -96,7 +139,7 @@ describe("category routes", () => { test("finds category by id", async () => { const { categoryQueries, categories } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const target = categories[0]; @@ -113,7 +156,7 @@ describe("category routes", () => { test("returns 404 when category is missing", async () => { const { categoryQueries } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const response = await app.fetch( @@ -126,7 +169,7 @@ describe("category routes", () => { test("creates category for a user", async () => { const { categoryQueries } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const response = await app.fetch( @@ -148,7 +191,7 @@ describe("category routes", () => { test("updates category for a user", async () => { const { categoryQueries, categories } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const target = categories[0]; @@ -170,7 +213,7 @@ describe("category routes", () => { test("deletes category for a user", async () => { const { categoryQueries, categories } = createCategoryQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ categoryQueries, authentication }); const target = categories[0]; @@ -193,7 +236,7 @@ describe("category routes", () => { describe("task routes", () => { test("lists tasks for a category", async () => { const { taskQueries, tasks } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ taskQueries, authentication }); const categoryId = tasks[0].categoryId; @@ -211,7 +254,7 @@ describe("task routes", () => { test("finds task by id", async () => { const { taskQueries, tasks } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ taskQueries, authentication }); const target = tasks[0]; @@ -228,7 +271,7 @@ describe("task routes", () => { test("returns 404 when task is missing", async () => { const { taskQueries } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ taskQueries, authentication }); const response = await app.fetch( @@ -242,7 +285,7 @@ describe("task routes", () => { test("creates task for a category", async () => { const { categoryQueries } = createCategoryQueries(); const { taskQueries } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const category = await categoryQueries.findById({ categoryId: "category-1", userId: "user-1", @@ -277,7 +320,7 @@ describe("task routes", () => { test("updates task for a user", async () => { const { taskQueries, tasks } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ taskQueries, authentication }); const target = tasks[0]; @@ -299,7 +342,7 @@ describe("task routes", () => { test("deletes task for a user", async () => { const { taskQueries, tasks } = createTaskQueries(); - const authentication = createHeaderAuthentication(); + const authentication = createTestAuthentication(); const app = createApp({ taskQueries, authentication }); const target = tasks[0]; diff --git a/packages/auth/README.md b/packages/auth/README.md index 2d99f82..0df0237 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -10,8 +10,7 @@ npm install @listee/auth ## Features -- Header-based development authentication with `createHeaderAuthentication` -- Production-ready Supabase verifier via `createSupabaseAuthentication` +- Supabase JWT verification via `createSupabaseAuthentication` - Account provisioning wrapper `createProvisioningSupabaseAuthentication` - Strongly typed `AuthenticatedUser` and `AuthenticationContext` exports @@ -20,16 +19,15 @@ npm install @listee/auth ```ts import { createSupabaseAuthentication } from "@listee/auth"; -const authenticate = createSupabaseAuthentication({ - jwksUrl: new URL("https://.supabase.co/auth/v1/.well-known/jwks.json"), - expectedAudience: "authenticated", +const authentication = createSupabaseAuthentication({ + projectUrl: "https://.supabase.co", + audience: "authenticated", + requiredRole: "authenticated", }); -const result = await authenticate({ request, requiredRole: "authenticated" }); -if (result.type === "success") { - const user = result.user; - // continue with request handling -} +const result = await authentication.authenticate({ request }); +const user = result.user; +// Continue handling the request with user.id and user.token ``` See `src/authentication/` for additional adapters and tests demonstrating error handling scenarios. diff --git a/packages/auth/src/authentication/header.ts b/packages/auth/src/authentication/header.ts deleted file mode 100644 index 5cc9795..0000000 --- a/packages/auth/src/authentication/header.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { - AuthenticationContext, - AuthenticationProvider, - AuthenticationResult, - HeaderAuthenticationOptions, - HeaderToken, -} from "@listee/types"; -import { extractAuthorizationToken } from "./shared.js"; - -export function createHeaderAuthentication( - options: HeaderAuthenticationOptions = {}, -): AuthenticationProvider { - const headerName = options.headerName ?? "authorization"; - const scheme = options.scheme ?? "Bearer"; - - async function authenticate( - context: AuthenticationContext, - ): Promise { - const tokenValue = extractAuthorizationToken(context, headerName, scheme); - - const token: HeaderToken = { - type: "header", - scheme, - value: tokenValue, - }; - - return { - user: { - id: tokenValue, - token, - }, - }; - } - - return { authenticate }; -} diff --git a/packages/auth/src/authentication/index.ts b/packages/auth/src/authentication/index.ts index 5fc99eb..8906b59 100644 --- a/packages/auth/src/authentication/index.ts +++ b/packages/auth/src/authentication/index.ts @@ -1,5 +1,4 @@ export { AuthenticationError } from "./errors.js"; -export { createHeaderAuthentication } from "./header.js"; export { createProvisioningSupabaseAuthentication, createSupabaseAuthentication, diff --git a/packages/auth/src/authentication/supabase.test.ts b/packages/auth/src/authentication/supabase.test.ts index 8a97950..101f422 100644 --- a/packages/auth/src/authentication/supabase.test.ts +++ b/packages/auth/src/authentication/supabase.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"; import type { AuthenticatedToken, AuthenticationProvider, - HeaderToken, SupabaseAuthenticationOptions, SupabaseToken, } from "@listee/types"; @@ -13,6 +12,31 @@ import { createSupabaseAuthentication, } from "./index.js"; +const BASE_ISSUER = "https://example.supabase.co/auth/v1"; +const BASE_AUDIENCE = "authenticated"; +const BASE_TIME = 1_700_000_000; + +type TokenOverrides = Omit< + Partial, + "iss" | "aud" | "exp" | "iat" | "role" | "sub" +> & { + readonly sub: string; + readonly role?: string; +}; + +const buildToken = (overrides: TokenOverrides): SupabaseToken => { + const { sub, role, ...rest } = overrides; + return { + iss: BASE_ISSUER, + aud: BASE_AUDIENCE, + exp: BASE_TIME, + iat: BASE_TIME, + role: role ?? "authenticated", + sub, + ...rest, + }; +}; + describe("createSupabaseAuthentication", () => { test("returns user when token is valid", async () => { const helper = await createSupabaseTestHelper({ @@ -88,10 +112,7 @@ describe("createSupabaseAuthentication", () => { describe("createProvisioningSupabaseAuthentication", () => { test("invokes account provisioner after authentication", async () => { - const token: SupabaseToken = { - sub: "user-789", - email: "user@example.com", - }; + const token = buildToken({ sub: "user-789", email: "user@example.com" }); const baseProvider: AuthenticationProvider = { async authenticate() { @@ -133,9 +154,7 @@ describe("createProvisioningSupabaseAuthentication", () => { }); test("passes null email when token does not include it", async () => { - const token: SupabaseToken = { - sub: "user-555", - }; + const token = buildToken({ sub: "user-555" }); const baseProvider: AuthenticationProvider = { async authenticate() { @@ -167,44 +186,6 @@ describe("createProvisioningSupabaseAuthentication", () => { expect(received).toBeNull(); }); - - test("skips provisioning when token is not a Supabase token", async () => { - const token: HeaderToken = { - type: "header", - scheme: "Bearer", - value: "opaque-token", - }; - - const baseProvider: AuthenticationProvider = { - async authenticate() { - return { - user: { - id: "user-opaque", - token, - }, - }; - }, - }; - - let called = false; - - const authentication = createProvisioningSupabaseAuthentication( - { projectUrl: "https://example.supabase.co" }, - { - authenticationProvider: baseProvider, - accountProvisioner: { - async provision() { - called = true; - }, - }, - }, - ); - - const request = new Request("https://example.com/api"); - await authentication.authenticate({ request }); - - expect(called).toBe(false); - }); }); interface SupabaseTestHelperConfig { diff --git a/packages/auth/src/authentication/supabase.ts b/packages/auth/src/authentication/supabase.ts index c45d664..c0d34b3 100644 --- a/packages/auth/src/authentication/supabase.ts +++ b/packages/auth/src/authentication/supabase.ts @@ -1,5 +1,4 @@ import type { - AuthenticatedToken, AuthenticationContext, AuthenticationProvider, AuthenticationResult, @@ -98,15 +97,47 @@ export function createSupabaseAuthentication( verifyOptions, ); const subject = assertNonEmptyString(payload.sub, "Missing subject claim"); + const issuerClaim = assertNonEmptyString( + payload.iss, + "Missing issuer claim", + ); + + const audienceClaim = payload.aud; + if (typeof audienceClaim !== "string" && !Array.isArray(audienceClaim)) { + throw new AuthenticationError("Missing audience claim"); + } + + const expirationClaim = payload.exp; + if (typeof expirationClaim !== "number") { + throw new AuthenticationError("Missing exp claim"); + } + + const issuedAtClaim = payload.iat; + if (typeof issuedAtClaim !== "number") { + throw new AuthenticationError("Missing iat claim"); + } + + const roleClaim = assertNonEmptyString(payload.role, "Missing role claim"); if (requiredRole !== undefined) { - const role = assertNonEmptyString(payload.role, "Missing role claim"); - if (role !== requiredRole) { + if (roleClaim !== requiredRole) { throw new AuthenticationError("Role not allowed"); } } - const token: SupabaseToken = { ...payload }; + const additionalClaims = payload as Record; + const normalizedAudience = + typeof audienceClaim === "string" ? audienceClaim : [...audienceClaim]; + + const token: SupabaseToken = { + ...additionalClaims, + iss: issuerClaim, + sub: subject, + aud: normalizedAudience, + exp: expirationClaim, + iat: issuedAtClaim, + role: roleClaim, + }; return { user: { @@ -152,10 +183,6 @@ export function createProvisioningSupabaseAuthentication( context: AuthenticationContext, ): Promise { const result = await baseProvider.authenticate(context); - if (!isSupabaseToken(result.user.token)) { - return result; - } - const email = extractEmailFromToken(result.user.token); await accountProvisioner.provision({ @@ -169,11 +196,3 @@ export function createProvisioningSupabaseAuthentication( return { authenticate }; } - -function isSupabaseToken(token: AuthenticatedToken): token is SupabaseToken { - if (typeof token !== "object" || token === null) { - return false; - } - - return "sub" in token; -} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index cc8da4d..9ce9c07 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -12,7 +12,6 @@ export type { export { createAccountProvisioner } from "./account/provision-account.js"; export { AuthenticationError, - createHeaderAuthentication, createProvisioningSupabaseAuthentication, createSupabaseAuthentication, } from "./authentication/index.js"; diff --git a/packages/db/src/index.test.ts b/packages/db/src/index.test.ts index 44bd3e4..7563a6b 100644 --- a/packages/db/src/index.test.ts +++ b/packages/db/src/index.test.ts @@ -217,6 +217,14 @@ describe("createPostgresConnection", () => { }); describe("createRlsClient", () => { + const BASE_CLAIMS = { + iss: "https://example.supabase.co/auth/v1", + aud: "authenticated" as const, + exp: 1_700_000_000, + iat: 1_700_000_000, + role: "authenticated" as const, + } satisfies Record; + function encodeSegment(value: Record): string { const json = JSON.stringify(value); return Buffer.from(json) @@ -228,12 +236,13 @@ describe("createRlsClient", () => { function createAccessToken(payload: Record): string { const header = encodeSegment({ alg: "none", typ: "JWT" }); - const body = encodeSegment(payload); + const body = encodeSegment({ ...BASE_CLAIMS, ...payload }); return `${header}.${body}.`; } test("wraps RLS setup and teardown around the transaction", async () => { const token = { + ...BASE_CLAIMS, sub: "user-123", role: "role-with-hyphen", extra: "value", @@ -289,6 +298,7 @@ describe("createRlsClient", () => { test("preserves a valid role value", async () => { const token = { + ...BASE_CLAIMS, sub: "user-999", role: "editor", }; @@ -311,6 +321,7 @@ describe("createRlsClient", () => { }); const token = { + ...BASE_CLAIMS, sub: "user-222", role: "authenticated", }; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d6cfbaa..6aee1ea 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -151,15 +151,30 @@ function sanitizeRole(role: unknown): string { return "anon"; } +// Supabase Auth JWT fields reference: https://supabase.com/docs/guides/auth/jwt-fields#typescriptjavascript +export type AuthenticatorAssuranceLevel = "aal1" | "aal2"; + export type SupabaseToken = { - iss?: string; - sub?: string; - aud?: string | Array; - exp?: number; - nbf?: number; - iat?: number; + iss: string; + sub: string; + aud: string | readonly string[]; + exp: number; + iat: number; + role: string; + aal?: AuthenticatorAssuranceLevel; + session_id?: string; + email?: string; + phone?: string; + is_anonymous?: boolean; jti?: string; - role?: string; + nbf?: number; + app_metadata?: Record; + user_metadata?: Record; + amr?: readonly { + readonly method: string; + readonly timestamp: number; + }[]; + ref?: string; } & Record; function isRecord(value: unknown): value is Record { @@ -179,65 +194,123 @@ function isSupabaseToken(value: unknown): value is SupabaseToken { return false; } + if (typeof value.iss !== "string") { + return false; + } + + if (typeof value.sub !== "string") { + return false; + } + + if (typeof value.aud !== "string" && !isStringArray(value.aud)) { + return false; + } + + if (typeof value.exp !== "number") { + return false; + } + + if (typeof value.iat !== "number") { + return false; + } + + if (typeof value.role !== "string") { + return false; + } + if ( - "iss" in value && - value.iss !== undefined && - typeof value.iss !== "string" + "aal" in value && + value.aal !== undefined && + value.aal !== "aal1" && + value.aal !== "aal2" ) { return false; } if ( - "sub" in value && - value.sub !== undefined && - typeof value.sub !== "string" + "session_id" in value && + value.session_id !== undefined && + typeof value.session_id !== "string" ) { return false; } - if ("aud" in value && value.aud !== undefined) { - const audience = value.aud; - if (typeof audience !== "string" && !isStringArray(audience)) { - return false; - } + if ( + "email" in value && + value.email !== undefined && + typeof value.email !== "string" + ) { + return false; } if ( - "exp" in value && - value.exp !== undefined && - typeof value.exp !== "number" + "phone" in value && + value.phone !== undefined && + typeof value.phone !== "string" ) { return false; } if ( - "nbf" in value && - value.nbf !== undefined && - typeof value.nbf !== "number" + "is_anonymous" in value && + value.is_anonymous !== undefined && + typeof value.is_anonymous !== "boolean" ) { return false; } if ( - "iat" in value && - value.iat !== undefined && - typeof value.iat !== "number" + "app_metadata" in value && + value.app_metadata !== undefined && + !isRecord(value.app_metadata) ) { return false; } if ( - "jti" in value && - value.jti !== undefined && - typeof value.jti !== "string" + "user_metadata" in value && + value.user_metadata !== undefined && + !isRecord(value.user_metadata) ) { return false; } + if ("amr" in value && value.amr !== undefined) { + if (!Array.isArray(value.amr)) { + return false; + } + + for (const item of value.amr) { + if ( + !isRecord(item) || + typeof item.method !== "string" || + typeof item.timestamp !== "number" + ) { + return false; + } + } + } + if ( - "role" in value && - value.role !== undefined && - typeof value.role !== "string" + "ref" in value && + value.ref !== undefined && + typeof value.ref !== "string" + ) { + return false; + } + + if ( + "nbf" in value && + value.nbf !== undefined && + typeof value.nbf !== "number" + ) { + return false; + } + + if ( + "jti" in value && + value.jti !== undefined && + typeof value.jti !== "string" ) { return false; } diff --git a/packages/types/src/authentication.ts b/packages/types/src/authentication.ts index ba39a1a..78bfa8f 100644 --- a/packages/types/src/authentication.ts +++ b/packages/types/src/authentication.ts @@ -1,12 +1,6 @@ import type { SupabaseToken } from "./db"; -export interface HeaderToken { - readonly type: "header"; - readonly scheme: string; - readonly value: string; -} - -export type AuthenticatedToken = SupabaseToken | HeaderToken; +export type AuthenticatedToken = SupabaseToken; export interface AuthenticatedUser { readonly id: string; @@ -25,17 +19,13 @@ export interface AuthenticationProvider { authenticate(context: AuthenticationContext): Promise; } -export interface HeaderAuthenticationOptions { - readonly headerName?: string; - readonly scheme?: string; -} - -export interface SupabaseAuthenticationOptions - extends HeaderAuthenticationOptions { +export interface SupabaseAuthenticationOptions { readonly projectUrl: string; readonly audience?: string | readonly string[]; readonly issuer?: string; readonly requiredRole?: string; readonly clockToleranceSeconds?: number; readonly jwksPath?: string; + readonly headerName?: string; + readonly scheme?: string; }