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
8 changes: 8 additions & 0 deletions packages/api/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# @listee/api

## 0.3.2

### Patch Changes

- Updated dependencies
- @listee/auth@0.5.0
- @listee/types@0.5.0

## 0.3.1

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@listee/api",
"version": "0.3.1",
"version": "0.3.2",
"type": "module",
"publishConfig": {
"access": "public",
Expand Down
11 changes: 11 additions & 0 deletions packages/auth/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# @listee/auth

## 0.5.0

### Minor Changes

- Add the Supabase Auth REST client and allow Supabase audience config arrays so Listee API can consume the shared client.

### Patch Changes

- Updated dependencies
- @listee/types@0.5.0

## 0.4.0

### Minor Changes
Expand Down
20 changes: 19 additions & 1 deletion packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ npm install @listee/auth

## Features

- Supabase Auth REST client via `createSupabaseAuthClient`
- Supabase JWT verification via `createSupabaseAuthentication`
- Account provisioning wrapper `createProvisioningSupabaseAuthentication`
- Strongly typed `AuthenticatedUser` and `AuthenticationContext` exports

## Quick start

### Verify Supabase JWTs

```ts
import { createSupabaseAuthentication } from "@listee/auth";

Expand All @@ -30,7 +33,22 @@ 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.
### Call Supabase Auth REST endpoints

```ts
import { createSupabaseAuthClient } from "@listee/auth";

const auth = createSupabaseAuthClient({
projectUrl: "https://<project>.supabase.co",
publishableKey: process.env.SUPABASE_PUBLISHABLE_KEY!,
});

await auth.signup({ email: "user@example.com", password: "secret" });
const tokens = await auth.login({ email: "user@example.com", password: "secret" });
const refreshed = await auth.refresh({ refreshToken: tokens.refreshToken });
```

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

## Development

Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@listee/auth",
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"publishConfig": {
"access": "public",
Expand Down
5 changes: 3 additions & 2 deletions packages/auth/src/authentication/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ export function createSupabaseAuthentication(
}

if (audience !== undefined) {
verifyOptions.audience = audience;
verifyOptions.audience =
typeof audience === "string" ? audience : [...audience];
}

if (clockTolerance !== undefined) {
Expand Down Expand Up @@ -112,7 +113,7 @@ export function createSupabaseAuthentication(
}

const additionalClaims = payload as Record<string, unknown>;
const normalizedAudience =
const normalizedAudience: string | string[] =
typeof audienceClaim === "string" ? audienceClaim : [...audienceClaim];

const token: SupabaseToken = {
Expand Down
9 changes: 9 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,12 @@ export {
createProvisioningSupabaseAuthentication,
createSupabaseAuthentication,
} from "./authentication/index.js";
export type {
SupabaseAuthClient,
SupabaseAuthClientOptions,
SupabaseTokenPayload,
} from "./supabase/index.js";
export {
createSupabaseAuthClient,
SupabaseAuthError,
} from "./supabase/index.js";
163 changes: 163 additions & 0 deletions packages/auth/src/supabase/auth-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, test } from "bun:test";
import { createSupabaseAuthClient, SupabaseAuthError } from "./index.js";

type MockHandler = (request: Request) => Promise<Response> | Response;

const ensureValue = <T>(value: T | null | undefined, message: string): T => {
if (value === null || value === undefined) {
throw new Error(message);
}
return value;
};

const createMockFetch = (handler: MockHandler): typeof fetch => {
const fetchFn = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
return await handler(request);
};

const fetchWithPreconnect: typeof fetch = Object.assign(fetchFn, {
async preconnect() {
return;
},
});

return fetchWithPreconnect;
};

describe("createSupabaseAuthClient", () => {
test("login normalizes token payloads", async () => {
let capturedBody: unknown;
const client = createSupabaseAuthClient({
projectUrl: "https://example.supabase.co",
publishableKey: "anon-key",
fetch: createMockFetch(async (request) => {
capturedBody = await request.json();
return new Response(
JSON.stringify({
access_token: "access-123",
refresh_token: "refresh-456",
token_type: "bearer",
expires_in: 3600,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}),
});

const result = await client.login({
email: "user@example.com",
password: "secret",
});

expect(result).toEqual({
accessToken: "access-123",
refreshToken: "refresh-456",
tokenType: "bearer",
expiresIn: 3600,
});
expect(capturedBody).toEqual({
email: "user@example.com",
password: "secret",
});
});

test("refresh handles nested data payloads", async () => {
const client = createSupabaseAuthClient({
projectUrl: "https://example.supabase.co",
publishableKey: "anon-key",
fetch: createMockFetch(async () => {
return new Response(
JSON.stringify({
data: {
access_token: "access-nested",
refresh_token: "refresh-nested",
token_type: "bearer",
expires_in: 1800,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}),
});

const result = await client.refresh({ refreshToken: "refresh-token" });
expect(result.accessToken).toBe("access-nested");
});

test("signup forwards redirect URL", async () => {
let receivedUrl: string | null = null;
const client = createSupabaseAuthClient({
projectUrl: "https://example.supabase.co",
publishableKey: "anon-key",
fetch: createMockFetch(async (request) => {
receivedUrl = request.url;
return new Response(null, { status: 200 });
}),
});

await client.signup({
email: "user@example.com",
password: "secret",
redirectUrl: "https://app.example.dev/callback",
});

const resolvedUrl = ensureValue<string>(
receivedUrl,
"Expected signup to issue an HTTP request.",
);

expect(resolvedUrl).toBe(
"https://example.supabase.co/auth/v1/signup?redirect_to=" +
encodeURIComponent("https://app.example.dev/callback"),
);
});

test("propagates Supabase error payloads", async () => {
const client = createSupabaseAuthClient({
projectUrl: "https://example.supabase.co",
publishableKey: "anon-key",
fetch: createMockFetch(async () => {
return new Response(JSON.stringify({ error: "Invalid login" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}),
});

await expect(
client.login({ email: "user@example.com", password: "bad" }),
).rejects.toThrow("Invalid login");
});

test("wraps network failures", async () => {
const client = createSupabaseAuthClient({
projectUrl: "https://example.supabase.co",
publishableKey: "anon-key",
fetch: createMockFetch(async () => {
throw new Error("connection reset");
}),
});

await expect(
client.refresh({ refreshToken: "refresh-token" }),
).rejects.toThrow(SupabaseAuthError);
});

test("validates project URL", () => {
expect(() => {
createSupabaseAuthClient({
projectUrl: "",
publishableKey: "anon-key",
});
}).toThrowErrorMatchingInlineSnapshot(
'"Supabase project URL must not be empty."',
);
});
});
Loading