File
Blob: tests/worker/helpers.ts
| 1 | import { env, exports } from "cloudflare:workers"; |
| 2 | |
| 3 | import { makeAuth } from "@/worker/auth"; |
| 4 | import { encodeBase64Url, sha256 } from "@/worker/services/crypto"; |
| 5 | |
| 6 | export const SELF = exports.default; |
| 7 | export const ISSUER = "http://localhost"; |
| 8 | export const DEFAULT_PASSWORD = "correct-horse-battery-staple"; |
| 9 | export const DEFAULT_REDIRECT_URI = "http://127.0.0.1:0/cb"; |
| 10 | |
| 11 | export interface TestCredential { |
| 12 | email: string; |
| 13 | password: string; |
| 14 | name: string; |
| 15 | } |
| 16 | |
| 17 | export interface CreatedOAuthClient { |
| 18 | client_id: string; |
| 19 | client_secret: string; |
| 20 | } |
| 21 | |
| 22 | export interface OAuthAuthorizeResult { |
| 23 | status: number; |
| 24 | location: string | null; |
| 25 | code: string | null; |
| 26 | verifier: string; |
| 27 | } |
| 28 | |
| 29 | export interface OAuthTokenResponse { |
| 30 | access_token?: string; |
| 31 | id_token?: string; |
| 32 | refresh_token?: string; |
| 33 | token_type?: string; |
| 34 | expires_in?: number; |
| 35 | } |
| 36 | |
| 37 | export interface JsonErrorBody { |
| 38 | error?: string; |
| 39 | code?: string; |
| 40 | message?: string; |
| 41 | } |
| 42 | |
| 43 | export interface CountRow { |
| 44 | c: number; |
| 45 | } |
| 46 | |
| 47 | export type OAuthTokenTable = "oauth_access_tokens" | "oauth_refresh_tokens"; |
| 48 | |
| 49 | export interface CreateOAuthClientOptions { |
| 50 | name: string; |
| 51 | redirectUris?: string[]; |
| 52 | skipConsent?: boolean; |
| 53 | uri?: string; |
| 54 | } |
| 55 | |
| 56 | export interface PkcePair { |
| 57 | verifier: string; |
| 58 | challenge: string; |
| 59 | } |
| 60 | |
| 61 | export const testHeaders = (overrides: Record<string, string> = {}): Record<string, string> => ({ |
| 62 | "content-type": "application/json", |
| 63 | origin: ISSUER, |
| 64 | ...overrides, |
| 65 | }); |
| 66 | |
| 67 | // Workers' Headers conflates multiple Set-Cookie values into one comma-joined |
| 68 | // string. Splitting on comma-not-followed-by-an-attribute keeps `Expires=Wed, |
| 69 | // 01 Jan ...` from being read as a cookie boundary. |
| 70 | export const extractSessionCookie = (res: Response): string | null => { |
| 71 | const setCookie = res.headers.get("set-cookie"); |
| 72 | if (!setCookie) return null; |
| 73 | const cookie = setCookie |
| 74 | .split(/,(?=\s*[^\s,]+=)/) |
| 75 | .map((c) => c.split(";")[0]?.trim()) |
| 76 | .filter((c): c is string => Boolean(c) && /better-auth\./.test(c)) |
| 77 | .join("; "); |
| 78 | return cookie || null; |
| 79 | }; |
| 80 | |
| 81 | // `ip` controls the rate-limit bucket. Sign-ins within a suite share a bucket, |
| 82 | // so pass per-suite IPs to keep bursts under the per-IP threshold. |
| 83 | export const signInForCookie = async (email: string, password: string, ip: string = "10.0.0.1"): Promise<string> => { |
| 84 | const res = await SELF.fetch(`${ISSUER}/api/sign-in`, { |
| 85 | method: "POST", |
| 86 | headers: testHeaders({ "CF-Connecting-IP": ip }), |
| 87 | body: JSON.stringify({ email, password, turnstileToken: "loopback" }), |
| 88 | }); |
| 89 | if (res.status !== 200) { |
| 90 | throw new Error(`sign-in failed: ${res.status} ${await res.text()}`); |
| 91 | } |
| 92 | const cookie = extractSessionCookie(res); |
| 93 | if (!cookie) throw new Error("sign-in returned no session cookie"); |
| 94 | return cookie; |
| 95 | }; |
| 96 | |
| 97 | export const makeTestAuth = () => makeAuth(env, { baseURL: ISSUER, issuer: ISSUER }); |
| 98 | |
| 99 | export const signUpTestUser = async (credential: TestCredential): Promise<void> => { |
| 100 | const auth = makeTestAuth(); |
| 101 | await auth.api.signUpEmail({ body: credential, asResponse: false }); |
| 102 | }; |
| 103 | |
| 104 | export const promoteUserToAdmin = async (email: string): Promise<void> => { |
| 105 | await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin", email).run(); |
| 106 | }; |
| 107 | |
| 108 | export const signUpAdmin = async (credential: TestCredential): Promise<void> => { |
| 109 | await signUpTestUser(credential); |
| 110 | await promoteUserToAdmin(credential.email); |
| 111 | }; |
| 112 | |
| 113 | export const createOAuthClientAsAdmin = async ( |
| 114 | adminCookie: string, |
| 115 | options: CreateOAuthClientOptions, |
| 116 | ): Promise<CreatedOAuthClient> => { |
| 117 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 118 | method: "POST", |
| 119 | headers: testHeaders({ cookie: adminCookie }), |
| 120 | body: JSON.stringify({ |
| 121 | redirectUris: [DEFAULT_REDIRECT_URI], |
| 122 | ...options, |
| 123 | }), |
| 124 | }); |
| 125 | if (!res.ok) { |
| 126 | throw new Error(`setup create-client failed: ${res.status} ${await res.text()}`); |
| 127 | } |
| 128 | return (await res.json()) as CreatedOAuthClient; |
| 129 | }; |
| 130 | |
| 131 | export const createPkcePair = async (): Promise<PkcePair> => { |
| 132 | const verifier = encodeBase64Url(crypto.getRandomValues(new Uint8Array(32))); |
| 133 | const challenge = await sha256(verifier); |
| 134 | return { verifier, challenge }; |
| 135 | }; |
| 136 | |
| 137 | export const buildAuthorizeUrl = (options: { |
| 138 | clientId: string; |
| 139 | redirectUri?: string; |
| 140 | scope?: string; |
| 141 | state?: string; |
| 142 | challenge: string; |
| 143 | }): URL => { |
| 144 | const url = new URL(`${ISSUER}/api/auth/oauth2/authorize`); |
| 145 | url.searchParams.set("response_type", "code"); |
| 146 | url.searchParams.set("client_id", options.clientId); |
| 147 | url.searchParams.set("redirect_uri", options.redirectUri ?? DEFAULT_REDIRECT_URI); |
| 148 | url.searchParams.set("scope", options.scope ?? "openid profile email"); |
| 149 | url.searchParams.set("state", options.state ?? "test-state"); |
| 150 | url.searchParams.set("code_challenge", options.challenge); |
| 151 | url.searchParams.set("code_challenge_method", "S256"); |
| 152 | return url; |
| 153 | }; |
| 154 | |
| 155 | export const authorizeWithPkce = async (options: { |
| 156 | clientId: string; |
| 157 | cookie?: string; |
| 158 | redirectUri?: string; |
| 159 | scope?: string; |
| 160 | state?: string; |
| 161 | }): Promise<OAuthAuthorizeResult> => { |
| 162 | const { verifier, challenge } = await createPkcePair(); |
| 163 | const url = buildAuthorizeUrl({ |
| 164 | clientId: options.clientId, |
| 165 | redirectUri: options.redirectUri, |
| 166 | scope: options.scope, |
| 167 | state: options.state, |
| 168 | challenge, |
| 169 | }); |
| 170 | const res = await SELF.fetch(url, { |
| 171 | headers: options.cookie ? { cookie: options.cookie } : undefined, |
| 172 | redirect: "manual", |
| 173 | }); |
| 174 | const location = res.headers.get("location"); |
| 175 | let code: string | null = null; |
| 176 | if (location) { |
| 177 | try { |
| 178 | code = new URL(location, ISSUER).searchParams.get("code"); |
| 179 | } catch { |
| 180 | code = null; |
| 181 | } |
| 182 | } |
| 183 | return { status: res.status, location, code, verifier }; |
| 184 | }; |
| 185 | |
| 186 | export const exchangeAuthorizationCode = async (options: { |
| 187 | code: string; |
| 188 | verifier: string; |
| 189 | clientId: string; |
| 190 | clientSecret: string; |
| 191 | redirectUri?: string; |
| 192 | }): Promise<Response> => |
| 193 | SELF.fetch(`${ISSUER}/api/auth/oauth2/token`, { |
| 194 | method: "POST", |
| 195 | headers: { |
| 196 | "content-type": "application/x-www-form-urlencoded", |
| 197 | authorization: `Basic ${btoa(`${options.clientId}:${options.clientSecret}`)}`, |
| 198 | }, |
| 199 | body: new URLSearchParams({ |
| 200 | grant_type: "authorization_code", |
| 201 | code: options.code, |
| 202 | redirect_uri: options.redirectUri ?? DEFAULT_REDIRECT_URI, |
| 203 | code_verifier: options.verifier, |
| 204 | }), |
| 205 | }); |
| 206 | |
| 207 | export const getUserIdByEmail = async (email: string): Promise<string> => { |
| 208 | const row = await env.DB.prepare("SELECT id FROM users WHERE email = ?").bind(email).first<{ id: string }>(); |
| 209 | if (!row) throw new Error(`user row missing for ${email}`); |
| 210 | return row.id; |
| 211 | }; |
| 212 | |
| 213 | export const getLatestSessionIdForUser = async (userId: string): Promise<string> => { |
| 214 | const row = await env.DB.prepare("SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1") |
| 215 | .bind(userId) |
| 216 | .first<{ id: string }>(); |
| 217 | if (!row) throw new Error(`session row missing for user ${userId}`); |
| 218 | return row.id; |
| 219 | }; |
| 220 | |
| 221 | export const countOAuthTokensForClient = async (table: OAuthTokenTable, clientId: string): Promise<number> => { |
| 222 | const row = await env.DB.prepare(`SELECT COUNT(*) AS c FROM ${table} WHERE client_id = ?`) |
| 223 | .bind(clientId) |
| 224 | .first<CountRow>(); |
| 225 | return row?.c ?? 0; |
| 226 | }; |