From c58c3823c65e4802791916fa33ae546cce9f9836 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sat, 8 Nov 2025 16:56:50 +0900 Subject: [PATCH 1/3] feat(auth): add Supabase Auth REST client --- packages/auth/README.md | 20 +- packages/auth/src/authentication/supabase.ts | 5 +- packages/auth/src/index.ts | 9 + .../auth/src/supabase/auth-client.test.ts | 143 +++++++++++ packages/auth/src/supabase/auth-client.ts | 226 ++++++++++++++++++ packages/auth/src/supabase/errors.ts | 8 + packages/auth/src/supabase/index.ts | 7 + packages/types/src/authentication.ts | 2 +- 8 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/supabase/auth-client.test.ts create mode 100644 packages/auth/src/supabase/auth-client.ts create mode 100644 packages/auth/src/supabase/errors.ts create mode 100644 packages/auth/src/supabase/index.ts diff --git a/packages/auth/README.md b/packages/auth/README.md index 0df0237..71e661f 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -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"; @@ -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://.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 diff --git a/packages/auth/src/authentication/supabase.ts b/packages/auth/src/authentication/supabase.ts index 8f84229..f0dcf78 100644 --- a/packages/auth/src/authentication/supabase.ts +++ b/packages/auth/src/authentication/supabase.ts @@ -70,7 +70,8 @@ export function createSupabaseAuthentication( } if (audience !== undefined) { - verifyOptions.audience = audience; + verifyOptions.audience = + typeof audience === "string" ? audience : [...audience]; } if (clockTolerance !== undefined) { @@ -112,7 +113,7 @@ export function createSupabaseAuthentication( } const additionalClaims = payload as Record; - const normalizedAudience = + const normalizedAudience: string | string[] = typeof audienceClaim === "string" ? audienceClaim : [...audienceClaim]; const token: SupabaseToken = { diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9ce9c07..1c5e0ed 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -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"; diff --git a/packages/auth/src/supabase/auth-client.test.ts b/packages/auth/src/supabase/auth-client.test.ts new file mode 100644 index 0000000..045007a --- /dev/null +++ b/packages/auth/src/supabase/auth-client.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { createSupabaseAuthClient, SupabaseAuthError } from "./index.js"; + +type MockHandler = (request: Request) => Promise | Response; + +const createMockFetch = (handler: MockHandler): typeof fetch => { + return async (input: RequestInfo, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + return await handler(request); + }; +}; + +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", + }); + + expect(receivedUrl).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."', + ); + }); +}); diff --git a/packages/auth/src/supabase/auth-client.ts b/packages/auth/src/supabase/auth-client.ts new file mode 100644 index 0000000..e97fabf --- /dev/null +++ b/packages/auth/src/supabase/auth-client.ts @@ -0,0 +1,226 @@ +import { SupabaseAuthError } from "./errors.js"; + +export type SupabaseAuthClientOptions = { + readonly projectUrl: string; + readonly publishableKey: string; + readonly fetch?: typeof fetch; +}; + +export type SupabaseTokenPayload = { + readonly accessToken: string; + readonly refreshToken: string; + readonly tokenType: string; + readonly expiresIn: number; +}; + +export type SupabaseAuthClient = { + signup(params: { + readonly email: string; + readonly password: string; + readonly redirectUrl?: string; + }): Promise; + login(params: { + readonly email: string; + readonly password: string; + }): Promise; + refresh(params: { + readonly refreshToken: string; + }): Promise; +}; + +type SupabaseRestToken = { + readonly access_token: string; + readonly refresh_token: string; + readonly token_type: string; + readonly expires_in: number; +}; + +type RequestBody = Record; + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const isSupabaseRestToken = (value: unknown): value is SupabaseRestToken => { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.access_token === "string" && + typeof value.refresh_token === "string" && + typeof value.token_type === "string" && + typeof value.expires_in === "number" && + Number.isFinite(value.expires_in) + ); +}; + +const toTokenPayload = (token: SupabaseRestToken): SupabaseTokenPayload => { + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + tokenType: token.token_type, + expiresIn: token.expires_in, + }; +}; + +const normalizeProjectUrl = (value: string): URL => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error("Supabase project URL must not be empty."); + } + + try { + return new URL(trimmed); + } catch { + throw new Error("Supabase project URL must be a valid absolute URL."); + } +}; + +const normalizePublishableKey = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error("Supabase publishable key must not be empty."); + } + return trimmed; +}; + +const formatSupabaseError = (payload: unknown, fallback: string): string => { + if (isRecord(payload)) { + const candidates = [ + payload.error, + payload.error_description, + payload.message, + payload.msg, + ]; + + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + } + + return fallback; +}; + +const readJsonPayload = async (response: Response): Promise => { + const text = await response.text(); + if (text.trim().length === 0) { + return null; + } + + try { + return JSON.parse(text); + } catch (error) { + const details = error instanceof Error ? error.message : "unknown error"; + throw new SupabaseAuthError( + response.status || 500, + `Failed to parse Supabase response: ${details}`, + ); + } +}; + +export const createSupabaseAuthClient = ( + options: SupabaseAuthClientOptions, +): SupabaseAuthClient => { + const projectUrl = normalizeProjectUrl(options.projectUrl); + const publishableKey = normalizePublishableKey(options.publishableKey); + const fetchImpl = options.fetch ?? globalThis.fetch; + + if (fetchImpl === undefined) { + throw new Error( + "Fetch API is not available in this environment. Provide a custom fetch implementation.", + ); + } + + const requestSupabase = async ( + path: string, + body: RequestBody, + ): Promise => { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(normalizedPath, projectUrl); + + let response: Response; + try { + response = await fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + apikey: publishableKey, + Authorization: `Bearer ${publishableKey}`, + }, + body: JSON.stringify(body), + }); + } catch (error) { + const message = + error instanceof Error + ? `Supabase request failed: ${error.message}` + : "Supabase request failed due to an unknown error."; + throw new SupabaseAuthError(500, message); + } + + const payload = await readJsonPayload(response); + if (!response.ok) { + const message = formatSupabaseError( + payload, + `Supabase request failed with status ${response.status}`, + ); + throw new SupabaseAuthError(response.status, message); + } + + return payload; + }; + + const extractTokenResponse = (payload: unknown | null): SupabaseRestToken => { + if (isSupabaseRestToken(payload)) { + return payload; + } + + if (isRecord(payload) && isSupabaseRestToken(payload.data)) { + return payload.data; + } + + throw new SupabaseAuthError( + 502, + "Supabase response did not include token details.", + ); + }; + + return { + async signup(params) { + const redirectSuffix = + params.redirectUrl === undefined + ? "" + : `?redirect_to=${encodeURIComponent(params.redirectUrl)}`; + await requestSupabase(`/auth/v1/signup${redirectSuffix}`, { + email: params.email, + password: params.password, + }); + }, + + async login(params) { + const payload = await requestSupabase( + "/auth/v1/token?grant_type=password", + { + email: params.email, + password: params.password, + }, + ); + + return toTokenPayload(extractTokenResponse(payload)); + }, + + async refresh(params) { + const payload = await requestSupabase( + "/auth/v1/token?grant_type=refresh_token", + { + refresh_token: params.refreshToken, + }, + ); + + return toTokenPayload(extractTokenResponse(payload)); + }, + } satisfies SupabaseAuthClient; +}; diff --git a/packages/auth/src/supabase/errors.ts b/packages/auth/src/supabase/errors.ts new file mode 100644 index 0000000..6b24bd2 --- /dev/null +++ b/packages/auth/src/supabase/errors.ts @@ -0,0 +1,8 @@ +export class SupabaseAuthError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/packages/auth/src/supabase/index.ts b/packages/auth/src/supabase/index.ts new file mode 100644 index 0000000..51a9267 --- /dev/null +++ b/packages/auth/src/supabase/index.ts @@ -0,0 +1,7 @@ +export { + createSupabaseAuthClient, + type SupabaseAuthClient, + type SupabaseAuthClientOptions, + type SupabaseTokenPayload, +} from "./auth-client.js"; +export { SupabaseAuthError } from "./errors.js"; diff --git a/packages/types/src/authentication.ts b/packages/types/src/authentication.ts index fa5df89..78bfa8f 100644 --- a/packages/types/src/authentication.ts +++ b/packages/types/src/authentication.ts @@ -21,7 +21,7 @@ export interface AuthenticationProvider { export interface SupabaseAuthenticationOptions { readonly projectUrl: string; - readonly audience?: string; + readonly audience?: string | readonly string[]; readonly issuer?: string; readonly requiredRole?: string; readonly clockToleranceSeconds?: number; From a6c9ce3bed5ee2dcd31d817bb3f8191cf26456ac Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 11 Nov 2025 16:09:56 +0900 Subject: [PATCH 2/3] chore(release): bump auth and types to 0.5.0 --- packages/api/CHANGELOG.md | 8 ++++++++ packages/api/package.json | 2 +- packages/auth/CHANGELOG.md | 11 +++++++++++ packages/auth/package.json | 2 +- packages/types/CHANGELOG.md | 6 ++++++ packages/types/package.json | 2 +- 6 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md index c990fce..7978e49 100644 --- a/packages/api/CHANGELOG.md +++ b/packages/api/CHANGELOG.md @@ -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 diff --git a/packages/api/package.json b/packages/api/package.json index c941bb3..1cd15bd 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@listee/api", - "version": "0.3.1", + "version": "0.3.2", "type": "module", "publishConfig": { "access": "public", diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md index 4408eaa..fa1d4b5 100644 --- a/packages/auth/CHANGELOG.md +++ b/packages/auth/CHANGELOG.md @@ -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 diff --git a/packages/auth/package.json b/packages/auth/package.json index a35d994..29339a9 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@listee/auth", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "publishConfig": { "access": "public", diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index 2accd33..a9812e0 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,11 @@ # @listee/types +## 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. + ## 0.4.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index 53b162a..115fce0 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@listee/types", - "version": "0.4.0", + "version": "0.5.0", "type": "module", "publishConfig": { "access": "public", From 73fcdf7376460cbfbe923773cbe75514bd9f39fa Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 11 Nov 2025 22:20:24 +0900 Subject: [PATCH 3/3] test(auth): fix supabase auth client tests --- .../auth/src/supabase/auth-client.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/auth/src/supabase/auth-client.test.ts b/packages/auth/src/supabase/auth-client.test.ts index 045007a..93b310a 100644 --- a/packages/auth/src/supabase/auth-client.test.ts +++ b/packages/auth/src/supabase/auth-client.test.ts @@ -3,11 +3,26 @@ import { createSupabaseAuthClient, SupabaseAuthError } from "./index.js"; type MockHandler = (request: Request) => Promise | Response; +const ensureValue = (value: T | null | undefined, message: string): T => { + if (value === null || value === undefined) { + throw new Error(message); + } + return value; +}; + const createMockFetch = (handler: MockHandler): typeof fetch => { - return async (input: RequestInfo, init?: RequestInit) => { + 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", () => { @@ -93,7 +108,12 @@ describe("createSupabaseAuthClient", () => { redirectUrl: "https://app.example.dev/callback", }); - expect(receivedUrl).toBe( + const resolvedUrl = ensureValue( + 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"), );