Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
71 changes: 57 additions & 14 deletions packages/api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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];

Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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];

Expand All @@ -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];

Expand All @@ -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;

Expand All @@ -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];

Expand All @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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];

Expand All @@ -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];

Expand Down
18 changes: 8 additions & 10 deletions packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -20,16 +19,15 @@ npm install @listee/auth
```ts
import { createSupabaseAuthentication } from "@listee/auth";

const authenticate = createSupabaseAuthentication({
jwksUrl: new URL("https://<project>.supabase.co/auth/v1/.well-known/jwks.json"),
expectedAudience: "authenticated",
const authentication = createSupabaseAuthentication({
projectUrl: "https://<project>.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.
Expand Down
36 changes: 0 additions & 36 deletions packages/auth/src/authentication/header.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/auth/src/authentication/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { AuthenticationError } from "./errors.js";
export { createHeaderAuthentication } from "./header.js";
export {
createProvisioningSupabaseAuthentication,
createSupabaseAuthentication,
Expand Down
73 changes: 27 additions & 46 deletions packages/auth/src/authentication/supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
import type {
AuthenticatedToken,
AuthenticationProvider,
HeaderToken,
SupabaseAuthenticationOptions,
SupabaseToken,
} from "@listee/types";
Expand All @@ -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<SupabaseToken>,
"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({
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
Loading