Skip to content

Commit 2be1f48

Browse files
authored
Merge pull request #42 from listee-dev/feat/supabase-auth-client
feat(auth): add Supabase Auth REST client
2 parents 0956a04 + 73fcdf7 commit 2be1f48

14 files changed

Lines changed: 464 additions & 7 deletions

File tree

packages/api/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @listee/api
22

3+
## 0.3.2
4+
5+
### Patch Changes
6+
7+
- Updated dependencies
8+
- @listee/auth@0.5.0
9+
- @listee/types@0.5.0
10+
311
## 0.3.1
412

513
### Patch Changes

packages/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@listee/api",
3-
"version": "0.3.1",
3+
"version": "0.3.2",
44
"type": "module",
55
"publishConfig": {
66
"access": "public",

packages/auth/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @listee/auth
22

3+
## 0.5.0
4+
5+
### Minor Changes
6+
7+
- Add the Supabase Auth REST client and allow Supabase audience config arrays so Listee API can consume the shared client.
8+
9+
### Patch Changes
10+
11+
- Updated dependencies
12+
- @listee/types@0.5.0
13+
314
## 0.4.0
415

516
### Minor Changes

packages/auth/README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ npm install @listee/auth
1010

1111
## Features
1212

13+
- Supabase Auth REST client via `createSupabaseAuthClient`
1314
- Supabase JWT verification via `createSupabaseAuthentication`
1415
- Account provisioning wrapper `createProvisioningSupabaseAuthentication`
1516
- Strongly typed `AuthenticatedUser` and `AuthenticationContext` exports
1617

1718
## Quick start
1819

20+
### Verify Supabase JWTs
21+
1922
```ts
2023
import { createSupabaseAuthentication } from "@listee/auth";
2124

@@ -30,7 +33,22 @@ const user = result.user;
3033
// Continue handling the request with user.id and user.token
3134
```
3235

33-
See `src/authentication/` for additional adapters and tests demonstrating error handling scenarios.
36+
### Call Supabase Auth REST endpoints
37+
38+
```ts
39+
import { createSupabaseAuthClient } from "@listee/auth";
40+
41+
const auth = createSupabaseAuthClient({
42+
projectUrl: "https://<project>.supabase.co",
43+
publishableKey: process.env.SUPABASE_PUBLISHABLE_KEY!,
44+
});
45+
46+
await auth.signup({ email: "user@example.com", password: "secret" });
47+
const tokens = await auth.login({ email: "user@example.com", password: "secret" });
48+
const refreshed = await auth.refresh({ refreshToken: tokens.refreshToken });
49+
```
50+
51+
See `src/authentication/` and `src/supabase/` for additional adapters and tests demonstrating error handling scenarios.
3452

3553
## Development
3654

packages/auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@listee/auth",
3-
"version": "0.4.0",
3+
"version": "0.5.0",
44
"type": "module",
55
"publishConfig": {
66
"access": "public",

packages/auth/src/authentication/supabase.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ export function createSupabaseAuthentication(
7070
}
7171

7272
if (audience !== undefined) {
73-
verifyOptions.audience = audience;
73+
verifyOptions.audience =
74+
typeof audience === "string" ? audience : [...audience];
7475
}
7576

7677
if (clockTolerance !== undefined) {
@@ -112,7 +113,7 @@ export function createSupabaseAuthentication(
112113
}
113114

114115
const additionalClaims = payload as Record<string, unknown>;
115-
const normalizedAudience =
116+
const normalizedAudience: string | string[] =
116117
typeof audienceClaim === "string" ? audienceClaim : [...audienceClaim];
117118

118119
const token: SupabaseToken = {

packages/auth/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,12 @@ export {
1515
createProvisioningSupabaseAuthentication,
1616
createSupabaseAuthentication,
1717
} from "./authentication/index.js";
18+
export type {
19+
SupabaseAuthClient,
20+
SupabaseAuthClientOptions,
21+
SupabaseTokenPayload,
22+
} from "./supabase/index.js";
23+
export {
24+
createSupabaseAuthClient,
25+
SupabaseAuthError,
26+
} from "./supabase/index.js";
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createSupabaseAuthClient, SupabaseAuthError } from "./index.js";
3+
4+
type MockHandler = (request: Request) => Promise<Response> | Response;
5+
6+
const ensureValue = <T>(value: T | null | undefined, message: string): T => {
7+
if (value === null || value === undefined) {
8+
throw new Error(message);
9+
}
10+
return value;
11+
};
12+
13+
const createMockFetch = (handler: MockHandler): typeof fetch => {
14+
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
15+
const request = input instanceof Request ? input : new Request(input, init);
16+
return await handler(request);
17+
};
18+
19+
const fetchWithPreconnect: typeof fetch = Object.assign(fetchFn, {
20+
async preconnect() {
21+
return;
22+
},
23+
});
24+
25+
return fetchWithPreconnect;
26+
};
27+
28+
describe("createSupabaseAuthClient", () => {
29+
test("login normalizes token payloads", async () => {
30+
let capturedBody: unknown;
31+
const client = createSupabaseAuthClient({
32+
projectUrl: "https://example.supabase.co",
33+
publishableKey: "anon-key",
34+
fetch: createMockFetch(async (request) => {
35+
capturedBody = await request.json();
36+
return new Response(
37+
JSON.stringify({
38+
access_token: "access-123",
39+
refresh_token: "refresh-456",
40+
token_type: "bearer",
41+
expires_in: 3600,
42+
}),
43+
{
44+
status: 200,
45+
headers: { "Content-Type": "application/json" },
46+
},
47+
);
48+
}),
49+
});
50+
51+
const result = await client.login({
52+
email: "user@example.com",
53+
password: "secret",
54+
});
55+
56+
expect(result).toEqual({
57+
accessToken: "access-123",
58+
refreshToken: "refresh-456",
59+
tokenType: "bearer",
60+
expiresIn: 3600,
61+
});
62+
expect(capturedBody).toEqual({
63+
email: "user@example.com",
64+
password: "secret",
65+
});
66+
});
67+
68+
test("refresh handles nested data payloads", async () => {
69+
const client = createSupabaseAuthClient({
70+
projectUrl: "https://example.supabase.co",
71+
publishableKey: "anon-key",
72+
fetch: createMockFetch(async () => {
73+
return new Response(
74+
JSON.stringify({
75+
data: {
76+
access_token: "access-nested",
77+
refresh_token: "refresh-nested",
78+
token_type: "bearer",
79+
expires_in: 1800,
80+
},
81+
}),
82+
{
83+
status: 200,
84+
headers: { "Content-Type": "application/json" },
85+
},
86+
);
87+
}),
88+
});
89+
90+
const result = await client.refresh({ refreshToken: "refresh-token" });
91+
expect(result.accessToken).toBe("access-nested");
92+
});
93+
94+
test("signup forwards redirect URL", async () => {
95+
let receivedUrl: string | null = null;
96+
const client = createSupabaseAuthClient({
97+
projectUrl: "https://example.supabase.co",
98+
publishableKey: "anon-key",
99+
fetch: createMockFetch(async (request) => {
100+
receivedUrl = request.url;
101+
return new Response(null, { status: 200 });
102+
}),
103+
});
104+
105+
await client.signup({
106+
email: "user@example.com",
107+
password: "secret",
108+
redirectUrl: "https://app.example.dev/callback",
109+
});
110+
111+
const resolvedUrl = ensureValue<string>(
112+
receivedUrl,
113+
"Expected signup to issue an HTTP request.",
114+
);
115+
116+
expect(resolvedUrl).toBe(
117+
"https://example.supabase.co/auth/v1/signup?redirect_to=" +
118+
encodeURIComponent("https://app.example.dev/callback"),
119+
);
120+
});
121+
122+
test("propagates Supabase error payloads", async () => {
123+
const client = createSupabaseAuthClient({
124+
projectUrl: "https://example.supabase.co",
125+
publishableKey: "anon-key",
126+
fetch: createMockFetch(async () => {
127+
return new Response(JSON.stringify({ error: "Invalid login" }), {
128+
status: 400,
129+
headers: { "Content-Type": "application/json" },
130+
});
131+
}),
132+
});
133+
134+
await expect(
135+
client.login({ email: "user@example.com", password: "bad" }),
136+
).rejects.toThrow("Invalid login");
137+
});
138+
139+
test("wraps network failures", async () => {
140+
const client = createSupabaseAuthClient({
141+
projectUrl: "https://example.supabase.co",
142+
publishableKey: "anon-key",
143+
fetch: createMockFetch(async () => {
144+
throw new Error("connection reset");
145+
}),
146+
});
147+
148+
await expect(
149+
client.refresh({ refreshToken: "refresh-token" }),
150+
).rejects.toThrow(SupabaseAuthError);
151+
});
152+
153+
test("validates project URL", () => {
154+
expect(() => {
155+
createSupabaseAuthClient({
156+
projectUrl: "",
157+
publishableKey: "anon-key",
158+
});
159+
}).toThrowErrorMatchingInlineSnapshot(
160+
'"Supabase project URL must not be empty."',
161+
);
162+
});
163+
});

0 commit comments

Comments
 (0)