Skip to content

Commit 94463c7

Browse files
authored
Merge pull request #39 from listee-dev/feat/jwt-auth-only
feat(auth): enforce supabase jwt authentication
2 parents 9ecfac2 + 0211827 commit 94463c7

11 files changed

Lines changed: 249 additions & 173 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030

3131
### `@listee/auth`
3232
- Exposes reusable authentication providers under `packages/auth/src/authentication/`.
33-
- `createHeaderAuthentication` performs lightweight header extraction suitable for development stubs.
3433
- `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.
3534
- Shared utilities (`shared.ts`, `errors.ts`) handle predictable error surfaces; tests live beside the implementation (`supabase.test.ts`) and exercise positive/negative paths.
3635
- The package emits declarations from `src/` only; test files are excluded from `dist/` via `tsconfig.json`.

packages/api/src/app.test.ts

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,61 @@
11
import { describe, expect, test } from "bun:test";
2-
import { createHeaderAuthentication } from "@listee/auth";
2+
import { AuthenticationError } from "@listee/auth";
33
import type {
4+
AuthenticationProvider,
45
Category,
56
CategoryQueries,
67
ListCategoriesResult,
8+
SupabaseToken,
79
Task,
810
TaskQueries,
911
} from "@listee/types";
1012
import { createApp } from "./app";
1113

14+
const BASE_CLAIMS = {
15+
iss: "https://example.supabase.co/auth/v1",
16+
aud: "authenticated" as const,
17+
exp: 1_700_000_000,
18+
iat: 1_700_000_000,
19+
};
20+
1221
function createRequest(path: string, init: RequestInit = {}): Request {
1322
return new Request(`http://localhost${path}`, init);
1423
}
1524

25+
function createTestAuthentication(): AuthenticationProvider {
26+
return {
27+
async authenticate({ request }) {
28+
const header = request.headers.get("authorization");
29+
if (header === null) {
30+
throw new AuthenticationError("Missing authorization header");
31+
}
32+
33+
const prefix = "Bearer ";
34+
if (!header.startsWith(prefix)) {
35+
throw new AuthenticationError("Invalid authorization scheme");
36+
}
37+
38+
const tokenValue = header.slice(prefix.length).trim();
39+
if (tokenValue.length === 0) {
40+
throw new AuthenticationError("Missing token value");
41+
}
42+
43+
const token: SupabaseToken = {
44+
...BASE_CLAIMS,
45+
sub: tokenValue,
46+
role: "authenticated",
47+
};
48+
49+
return {
50+
user: {
51+
id: tokenValue,
52+
token,
53+
},
54+
};
55+
},
56+
};
57+
}
58+
1659
describe("health routes", () => {
1760
test("returns ok status", async () => {
1861
const app = createApp();
@@ -61,7 +104,7 @@ describe("health routes", () => {
61104
describe("category routes", () => {
62105
test("lists categories for a user", async () => {
63106
const { categoryQueries, categories } = createCategoryQueries();
64-
const authentication = createHeaderAuthentication();
107+
const authentication = createTestAuthentication();
65108
const app = createApp({ categoryQueries, authentication });
66109

67110
const response = await app.fetch(
@@ -80,7 +123,7 @@ describe("category routes", () => {
80123

81124
test("rejects invalid limit", async () => {
82125
const { categoryQueries } = createCategoryQueries();
83-
const authentication = createHeaderAuthentication();
126+
const authentication = createTestAuthentication();
84127
const app = createApp({ categoryQueries, authentication });
85128

86129
const response = await app.fetch(
@@ -96,7 +139,7 @@ describe("category routes", () => {
96139

97140
test("finds category by id", async () => {
98141
const { categoryQueries, categories } = createCategoryQueries();
99-
const authentication = createHeaderAuthentication();
142+
const authentication = createTestAuthentication();
100143
const app = createApp({ categoryQueries, authentication });
101144
const target = categories[0];
102145

@@ -113,7 +156,7 @@ describe("category routes", () => {
113156

114157
test("returns 404 when category is missing", async () => {
115158
const { categoryQueries } = createCategoryQueries();
116-
const authentication = createHeaderAuthentication();
159+
const authentication = createTestAuthentication();
117160
const app = createApp({ categoryQueries, authentication });
118161

119162
const response = await app.fetch(
@@ -126,7 +169,7 @@ describe("category routes", () => {
126169

127170
test("creates category for a user", async () => {
128171
const { categoryQueries } = createCategoryQueries();
129-
const authentication = createHeaderAuthentication();
172+
const authentication = createTestAuthentication();
130173
const app = createApp({ categoryQueries, authentication });
131174

132175
const response = await app.fetch(
@@ -148,7 +191,7 @@ describe("category routes", () => {
148191

149192
test("updates category for a user", async () => {
150193
const { categoryQueries, categories } = createCategoryQueries();
151-
const authentication = createHeaderAuthentication();
194+
const authentication = createTestAuthentication();
152195
const app = createApp({ categoryQueries, authentication });
153196
const target = categories[0];
154197

@@ -170,7 +213,7 @@ describe("category routes", () => {
170213

171214
test("deletes category for a user", async () => {
172215
const { categoryQueries, categories } = createCategoryQueries();
173-
const authentication = createHeaderAuthentication();
216+
const authentication = createTestAuthentication();
174217
const app = createApp({ categoryQueries, authentication });
175218
const target = categories[0];
176219

@@ -193,7 +236,7 @@ describe("category routes", () => {
193236
describe("task routes", () => {
194237
test("lists tasks for a category", async () => {
195238
const { taskQueries, tasks } = createTaskQueries();
196-
const authentication = createHeaderAuthentication();
239+
const authentication = createTestAuthentication();
197240
const app = createApp({ taskQueries, authentication });
198241
const categoryId = tasks[0].categoryId;
199242

@@ -211,7 +254,7 @@ describe("task routes", () => {
211254

212255
test("finds task by id", async () => {
213256
const { taskQueries, tasks } = createTaskQueries();
214-
const authentication = createHeaderAuthentication();
257+
const authentication = createTestAuthentication();
215258
const app = createApp({ taskQueries, authentication });
216259
const target = tasks[0];
217260

@@ -228,7 +271,7 @@ describe("task routes", () => {
228271

229272
test("returns 404 when task is missing", async () => {
230273
const { taskQueries } = createTaskQueries();
231-
const authentication = createHeaderAuthentication();
274+
const authentication = createTestAuthentication();
232275
const app = createApp({ taskQueries, authentication });
233276

234277
const response = await app.fetch(
@@ -242,7 +285,7 @@ describe("task routes", () => {
242285
test("creates task for a category", async () => {
243286
const { categoryQueries } = createCategoryQueries();
244287
const { taskQueries } = createTaskQueries();
245-
const authentication = createHeaderAuthentication();
288+
const authentication = createTestAuthentication();
246289
const category = await categoryQueries.findById({
247290
categoryId: "category-1",
248291
userId: "user-1",
@@ -277,7 +320,7 @@ describe("task routes", () => {
277320

278321
test("updates task for a user", async () => {
279322
const { taskQueries, tasks } = createTaskQueries();
280-
const authentication = createHeaderAuthentication();
323+
const authentication = createTestAuthentication();
281324
const app = createApp({ taskQueries, authentication });
282325
const target = tasks[0];
283326

@@ -299,7 +342,7 @@ describe("task routes", () => {
299342

300343
test("deletes task for a user", async () => {
301344
const { taskQueries, tasks } = createTaskQueries();
302-
const authentication = createHeaderAuthentication();
345+
const authentication = createTestAuthentication();
303346
const app = createApp({ taskQueries, authentication });
304347
const target = tasks[0];
305348

packages/auth/README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ npm install @listee/auth
1010

1111
## Features
1212

13-
- Header-based development authentication with `createHeaderAuthentication`
14-
- Production-ready Supabase verifier via `createSupabaseAuthentication`
13+
- Supabase JWT verification via `createSupabaseAuthentication`
1514
- Account provisioning wrapper `createProvisioningSupabaseAuthentication`
1615
- Strongly typed `AuthenticatedUser` and `AuthenticationContext` exports
1716

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

23-
const authenticate = createSupabaseAuthentication({
24-
jwksUrl: new URL("https://<project>.supabase.co/auth/v1/.well-known/jwks.json"),
25-
expectedAudience: "authenticated",
22+
const authentication = createSupabaseAuthentication({
23+
projectUrl: "https://<project>.supabase.co",
24+
audience: "authenticated",
25+
requiredRole: "authenticated",
2626
});
2727

28-
const result = await authenticate({ request, requiredRole: "authenticated" });
29-
if (result.type === "success") {
30-
const user = result.user;
31-
// continue with request handling
32-
}
28+
const result = await authentication.authenticate({ request });
29+
const user = result.user;
30+
// Continue handling the request with user.id and user.token
3331
```
3432

3533
See `src/authentication/` for additional adapters and tests demonstrating error handling scenarios.

packages/auth/src/authentication/header.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.

packages/auth/src/authentication/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export { AuthenticationError } from "./errors.js";
2-
export { createHeaderAuthentication } from "./header.js";
32
export {
43
createProvisioningSupabaseAuthentication,
54
createSupabaseAuthentication,

packages/auth/src/authentication/supabase.test.ts

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test";
22
import type {
33
AuthenticatedToken,
44
AuthenticationProvider,
5-
HeaderToken,
65
SupabaseAuthenticationOptions,
76
SupabaseToken,
87
} from "@listee/types";
@@ -13,6 +12,31 @@ import {
1312
createSupabaseAuthentication,
1413
} from "./index.js";
1514

15+
const BASE_ISSUER = "https://example.supabase.co/auth/v1";
16+
const BASE_AUDIENCE = "authenticated";
17+
const BASE_TIME = 1_700_000_000;
18+
19+
type TokenOverrides = Omit<
20+
Partial<SupabaseToken>,
21+
"iss" | "aud" | "exp" | "iat" | "role" | "sub"
22+
> & {
23+
readonly sub: string;
24+
readonly role?: string;
25+
};
26+
27+
const buildToken = (overrides: TokenOverrides): SupabaseToken => {
28+
const { sub, role, ...rest } = overrides;
29+
return {
30+
iss: BASE_ISSUER,
31+
aud: BASE_AUDIENCE,
32+
exp: BASE_TIME,
33+
iat: BASE_TIME,
34+
role: role ?? "authenticated",
35+
sub,
36+
...rest,
37+
};
38+
};
39+
1640
describe("createSupabaseAuthentication", () => {
1741
test("returns user when token is valid", async () => {
1842
const helper = await createSupabaseTestHelper({
@@ -88,10 +112,7 @@ describe("createSupabaseAuthentication", () => {
88112

89113
describe("createProvisioningSupabaseAuthentication", () => {
90114
test("invokes account provisioner after authentication", async () => {
91-
const token: SupabaseToken = {
92-
sub: "user-789",
93-
email: "user@example.com",
94-
};
115+
const token = buildToken({ sub: "user-789", email: "user@example.com" });
95116

96117
const baseProvider: AuthenticationProvider = {
97118
async authenticate() {
@@ -133,9 +154,7 @@ describe("createProvisioningSupabaseAuthentication", () => {
133154
});
134155

135156
test("passes null email when token does not include it", async () => {
136-
const token: SupabaseToken = {
137-
sub: "user-555",
138-
};
157+
const token = buildToken({ sub: "user-555" });
139158

140159
const baseProvider: AuthenticationProvider = {
141160
async authenticate() {
@@ -167,44 +186,6 @@ describe("createProvisioningSupabaseAuthentication", () => {
167186

168187
expect(received).toBeNull();
169188
});
170-
171-
test("skips provisioning when token is not a Supabase token", async () => {
172-
const token: HeaderToken = {
173-
type: "header",
174-
scheme: "Bearer",
175-
value: "opaque-token",
176-
};
177-
178-
const baseProvider: AuthenticationProvider = {
179-
async authenticate() {
180-
return {
181-
user: {
182-
id: "user-opaque",
183-
token,
184-
},
185-
};
186-
},
187-
};
188-
189-
let called = false;
190-
191-
const authentication = createProvisioningSupabaseAuthentication(
192-
{ projectUrl: "https://example.supabase.co" },
193-
{
194-
authenticationProvider: baseProvider,
195-
accountProvisioner: {
196-
async provision() {
197-
called = true;
198-
},
199-
},
200-
},
201-
);
202-
203-
const request = new Request("https://example.com/api");
204-
await authentication.authenticate({ request });
205-
206-
expect(called).toBe(false);
207-
});
208189
});
209190

210191
interface SupabaseTestHelperConfig {

0 commit comments

Comments
 (0)