diff --git a/packages/alchemy/src/Auth/AuthProvider.ts b/packages/alchemy/src/Auth/AuthProvider.ts index 205d32748..67513218d 100644 --- a/packages/alchemy/src/Auth/AuthProvider.ts +++ b/packages/alchemy/src/Auth/AuthProvider.ts @@ -5,6 +5,17 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { withLock } from "./Lock.ts"; +/** + * Canonical web host for OAuth provider-agnostic landing pages + * (`/auth/success`, `/auth/error`). The CLI's loopback server 302s the + * browser to one of these after handling the OAuth callback. Centralized + * here so the redirect target lives in exactly one place across all + * provider OAuth clients. + */ +export const AUTH_LANDING_HOST = "https://v2.alchemy.run"; +export const AUTH_SUCCESS_URL = `${AUTH_LANDING_HOST}/auth/success`; +export const AUTH_ERROR_URL = `${AUTH_LANDING_HOST}/auth/error`; + /** * Methods on an {@link AuthProviderImpl} that mutate (or could trigger * mutation of) on-disk credentials. The factory wraps these in a diff --git a/packages/alchemy/src/Cloudflare/Auth/OAuthClient.ts b/packages/alchemy/src/Cloudflare/Auth/OAuthClient.ts index 2e9f4c7c8..7c084eb40 100644 --- a/packages/alchemy/src/Cloudflare/Auth/OAuthClient.ts +++ b/packages/alchemy/src/Cloudflare/Auth/OAuthClient.ts @@ -2,6 +2,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import crypto from "node:crypto"; import http from "node:http"; +import { AUTH_ERROR_URL, AUTH_SUCCESS_URL } from "../../Auth/AuthProvider.ts"; import { OAUTH_CLIENT_ID, OAUTH_ENDPOINTS, @@ -224,7 +225,7 @@ function callbackPromise( const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description"); if (error) { - res.writeHead(302, { Location: "https://alchemy.run/auth/error" }); + res.writeHead(302, { Location: AUTH_ERROR_URL }); res.end(); cleanup(); reject( @@ -239,7 +240,7 @@ function callbackPromise( const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); if (!code || !state) { - res.writeHead(302, { Location: "https://alchemy.run/auth/error" }); + res.writeHead(302, { Location: AUTH_ERROR_URL }); res.end(); cleanup(); reject( @@ -252,7 +253,7 @@ function callbackPromise( } if (state !== authorization.state) { - res.writeHead(302, { Location: "https://alchemy.run/auth/error" }); + res.writeHead(302, { Location: AUTH_ERROR_URL }); res.end(); cleanup(); reject( @@ -268,14 +269,12 @@ function callbackPromise( const credentials = await Effect.runPromise( exchange(code, authorization.verifier), ); - res.writeHead(302, { - Location: "https://alchemy.run/auth/success", - }); + res.writeHead(302, { Location: AUTH_SUCCESS_URL }); res.end(); cleanup(); resolve(credentials); } catch (err) { - res.writeHead(302, { Location: "https://alchemy.run/auth/error" }); + res.writeHead(302, { Location: AUTH_ERROR_URL }); res.end(); cleanup(); reject(err); diff --git a/packages/alchemy/src/Planetscale/AuthProvider.ts b/packages/alchemy/src/Planetscale/AuthProvider.ts index c44e989b7..83fb3758f 100644 --- a/packages/alchemy/src/Planetscale/AuthProvider.ts +++ b/packages/alchemy/src/Planetscale/AuthProvider.ts @@ -1,7 +1,13 @@ +import * as PsCredentialsModule from "@distilled.cloud/planetscale/Credentials"; +import { listOrganizations } from "@distilled.cloud/planetscale/Operations"; import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Match from "effect/Match"; import * as Redacted from "effect/Redacted"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import { AuthError, AuthProviderLayer, @@ -14,6 +20,7 @@ import { retryOnce, } from "../Auth/Env.ts"; import * as Clank from "../Util/Clank.ts"; +import * as OAuthClient from "./OAuthClient.ts"; /** * Canonical name registered in {@link AuthProviders}. Use this key to look @@ -21,6 +28,66 @@ import * as Clank from "../Util/Clank.ts"; */ export const PLANETSCALE_AUTH_PROVIDER_NAME = "Planetscale"; +/** + * Provide PlanetScale `Credentials` + `HttpClient` to an Effect using a + * just-obtained OAuth access token. Used during configure to call + * org-discovery endpoints before the user has chosen an org. + * + * `organization` is required by the credential type but isn't consulted by + * `listOrganizations` (it's a user-scoped endpoint), so an empty string is + * fine here. + */ +const withOAuthCredentials = ( + accessToken: string, + effect: Effect.Effect< + A, + E, + PsCredentialsModule.Credentials | HttpClient.HttpClient + >, +): Effect.Effect => + Effect.provide( + effect, + Layer.mergeAll( + PsCredentialsModule.fromOAuth({ + accessToken, + organization: "", + }), + FetchHttpClient.layer, + ), + ); + +/** + * List the organizations the OAuth user belongs to and either auto-pick + * (one org) or prompt the user to choose. Returns the org's URL slug + * (`name` field, used as `{organization}` in API paths). + */ +const selectOrganization = (accessToken: string) => + Effect.gen(function* () { + const list = yield* listOrganizations; + const response = yield* list({}); + const orgs = response.data; + if (orgs.length === 0) { + return yield* new AuthError({ + message: "Planetscale: no organizations found for this credential.", + }); + } + if (orgs.length === 1) { + const org = orgs[0]!; + yield* Clank.info( + `Planetscale: using organization: ${org.name} (${org.id})`, + ); + return org.name; + } + return yield* Clank.select({ + message: "Select a Planetscale organization", + options: orgs.map((o) => ({ + value: o.name, + label: o.name, + hint: o.id, + })), + }).pipe(retryOnce); + }).pipe((e) => withOAuthCredentials(accessToken, e)); + const options: Array<{ value: PlanetscaleAuthConfig["method"]; label: string; @@ -31,12 +98,16 @@ const options: Array<{ label: "Environment Variables", hint: "PLANETSCALE_API_TOKEN_ID + PLANETSCALE_API_TOKEN + PLANETSCALE_ORGANIZATION", }, + { + value: "oauth", + label: "OAuth", + hint: "recommended — browser-based login with automatic token refresh", + }, { value: "stored", label: "Service Token", hint: "enter service token interactively, stored in ~/.alchemy/credentials", }, - //todo(pear): add planetscale oauth ]; /** @@ -44,13 +115,17 @@ const options: Array<{ * PlanetScale provider. * * - `env`: read credentials from environment variables at resolution time. - * - `stored`: read credentials from `~/.alchemy/credentials//planetscale-stored.json`. - * - * OAuth is intentionally not implemented because PlanetScale does not - * publish a redirect-based OAuth client; service tokens are the canonical - * credential. + * - `stored`: read service-token credentials from + * `~/.alchemy/credentials//planetscale-stored.json`. + * - `oauth`: browser-based login; the access/refresh tokens are stored at + * `~/.alchemy/credentials//planetscale-oauth.json` and refreshed + * on demand. PlanetScale has no PKCE flow, so the OAuth application's + * `client_secret` ships in the CLI — see {@link OAuthClient}. */ -export type PlanetscaleAuthConfig = { method: "env" } | { method: "stored" }; +export type PlanetscaleAuthConfig = + | { method: "env" } + | { method: "stored" } + | { method: "oauth"; organization: string }; /** * apiToken credentials persisted to disk for `method: "stored"`. @@ -65,18 +140,30 @@ export interface PlanetscaleStoredCredentials { /** * Resolved in-memory PlanetScale credentials returned by - * {@link AuthProviderImpl.read}. + * {@link AuthProviderImpl.read}. Either a service token (`tokenId`/`token`) + * or an OAuth access token. */ -export interface PlanetscaleResolvedCredentials { - type: "apiToken"; - tokenId: Redacted.Redacted; - token: Redacted.Redacted; - organization: string; - source: { - type: PlanetscaleAuthConfig["method"]; - details?: string; - }; -} +export type PlanetscaleResolvedCredentials = + | { + type: "apiToken"; + tokenId: Redacted.Redacted; + token: Redacted.Redacted; + organization: string; + source: { + type: PlanetscaleAuthConfig["method"]; + details?: string; + }; + } + | { + type: "oauth"; + accessToken: Redacted.Redacted; + expires: number; + organization: string; + source: { + type: PlanetscaleAuthConfig["method"]; + details?: string; + }; + }; /** * Layer that registers the PlanetScale {@link AuthProvider} into the @@ -87,6 +174,8 @@ export interface PlanetscaleResolvedCredentials { * - `env`: reads `PLANETSCALE_API_TOKEN_ID`/`PLANETSCALE_API_TOKEN`/`PLANETSCALE_ORGANIZATION`. * - `stored`: prompts for a service token interactively and writes it to * `~/.alchemy/credentials//planetscale-stored.json`. + * - `oauth`: browser-based login storing access/refresh tokens at + * `~/.alchemy/credentials//planetscale-oauth.json`. */ export const PlanetscaleAuth = AuthProviderLayer< PlanetscaleAuthConfig, @@ -96,6 +185,54 @@ export const PlanetscaleAuth = AuthProviderLayer< Effect.gen(function* () { const store = yield* CredentialsStore; + const oauthLogin = (profileName: string) => + Effect.gen(function* () { + const authorization = OAuthClient.authorize(); + + yield* Clank.info("Planetscale: opening browser for OAuth login..."); + yield* Clank.info(authorization.url); + yield* Clank.openUrl(authorization.url).pipe( + Effect.catch(() => + Clank.warn( + "Planetscale: could not open browser automatically. Please open the URL above manually.", + ), + ), + ); + yield* Clank.info( + "Planetscale: waiting for authorization (up to 5 minutes)...", + ); + + const credentials = yield* OAuthClient.callback(authorization); + yield* store.write(profileName, "planetscale-oauth", credentials); + yield* Clank.success("Planetscale: OAuth credentials saved."); + return credentials; + }); + + const configureOAuth = Effect.fnUntraced(function* (profileName: string) { + const oauthCreds = yield* oauthLogin(profileName); + + // Use the just-issued access token to list the user's orgs and let + // them pick (mirrors Cloudflare's selectAccount). Requires the + // `user:read_organizations` scope. If the call fails for any + // reason — missing scope, network, off-spec response — fall back + // to a manual prompt so login still completes. + const organization = yield* selectOrganization(oauthCreds.access).pipe( + Effect.catch((e) => + Effect.gen(function* () { + yield* Clank.warn( + `Planetscale: could not auto-list organizations (${String(e)}). Falling back to manual entry.`, + ); + return yield* Clank.text({ + message: "Planetscale Organization (URL slug)", + validate: (v) => (v.length === 0 ? "Required" : undefined), + }).pipe(retryOnce); + }), + ), + ); + + return { method: "oauth" as const, organization }; + }); + const loginStored = Effect.fnUntraced(function* (profileName: string) { const tokenId = yield* Clank.text({ message: "Planetscale Service Token ID", @@ -134,6 +271,7 @@ export const PlanetscaleAuth = AuthProviderLayer< Effect.flatMap((method) => Match.value(method).pipe( Match.when("env", () => Effect.succeed({ method: "env" as const })), + Match.when("oauth", () => configureOAuth(profileName)), Match.when("stored", () => loginStored(profileName)), Match.exhaustive, ), @@ -214,6 +352,48 @@ export const PlanetscaleAuth = AuthProviderLayer< ), ), ), + Match.when({ method: "oauth" }, (cfg) => + Effect.gen(function* () { + const creds = yield* store.read( + profileName, + "planetscale-oauth", + ); + if (creds == null || creds.type !== "oauth") { + return yield* Effect.fail( + new AuthError({ + message: + "Planetscale OAuth credentials not found. Run: alchemy login", + }), + ); + } + // Refresh proactively if the token has expired (or is within + // 10s of expiring). Persist the refreshed creds so subsequent + // resolves don't repeat the round-trip. + const fresh = + creds.expires > Date.now() + 10_000 + ? creds + : yield* OAuthClient.refresh(creds).pipe( + Effect.tap((refreshed) => + store.write(profileName, "planetscale-oauth", refreshed), + ), + Effect.mapError( + (e) => + new AuthError({ + message: + "Planetscale OAuth refresh failed. Run: alchemy login", + cause: e, + }), + ), + ); + return { + type: "oauth" as const, + accessToken: Redacted.make(fresh.access), + expires: fresh.expires, + organization: cfg.organization, + source: { type: "oauth" as const }, + } satisfies PlanetscaleResolvedCredentials; + }), + ), Match.exhaustive, ); @@ -229,6 +409,17 @@ export const PlanetscaleAuth = AuthProviderLayer< ), ), ), + // PlanetScale publishes no token-revocation endpoint, so logout just + // drops the locally stored tokens. + Match.when({ method: "oauth" }, () => + store + .delete(profileName, "planetscale-oauth") + .pipe( + Effect.andThen( + Clank.success("Planetscale: OAuth credentials removed."), + ), + ), + ), Match.exhaustive, ); @@ -248,6 +439,39 @@ export const PlanetscaleAuth = AuthProviderLayer< ), ), ), + Match.when({ method: "oauth" }, (c) => + Effect.gen(function* () { + const creds = yield* store.read( + profileName, + "planetscale-oauth", + ); + + if (creds?.type === "oauth") { + yield* Clank.info( + "Planetscale: refreshing OAuth credentials...", + ); + yield* OAuthClient.refresh(creds).pipe( + Effect.flatMap((refreshed) => + store + .write(profileName, "planetscale-oauth", refreshed) + .pipe( + Effect.andThen( + Clank.success( + "Planetscale: OAuth credentials refreshed.", + ), + ), + ), + ), + Effect.catchTag("OAuthError", () => + oauthLogin(profileName).pipe(Effect.asVoid), + ), + ); + return; + } + + yield* oauthLogin(profileName); + }), + ), Match.exhaustive, ) .pipe( @@ -262,12 +486,31 @@ export const PlanetscaleAuth = AuthProviderLayer< const sourceStr = creds.source.details ? `${creds.source.type} - ${creds.source.details}` : creds.source.type; - return Effect.all([ - Console.log(` tokenId: ${displayRedacted(creds.token, 3)}`), - Console.log(` token: ${displayRedacted(creds.token, 6)}`), - Console.log(` organization: ${creds.organization}`), - Console.log(` source: ${sourceStr}`), - ]); + return Match.value(creds).pipe( + Match.when({ type: "apiToken" }, (c) => + Effect.all([ + Console.log(` tokenId: ${displayRedacted(c.tokenId, 3)}`), + Console.log(` token: ${displayRedacted(c.token, 6)}`), + Console.log(` organization: ${c.organization}`), + Console.log(` source: ${sourceStr}`), + ]), + ), + Match.when({ type: "oauth" }, (c) => { + const remainingMs = c.expires - Date.now(); + const expiresAt = new Date(c.expires).toISOString(); + const expiresStr = + remainingMs <= 0 + ? `expired (${expiresAt})` + : `in ${Duration.format(Duration.millis(remainingMs))} (${expiresAt})`; + return Effect.all([ + Console.log(` accessToken: ${displayRedacted(c.accessToken)}`), + Console.log(` expires: ${expiresStr}`), + Console.log(` organization: ${c.organization}`), + Console.log(` source: ${sourceStr}`), + ]); + }), + Match.exhaustive, + ); }), Effect.catch((e) => Console.error(` Failed to retrieve credentials: ${e}`), diff --git a/packages/alchemy/src/Planetscale/Credentials.ts b/packages/alchemy/src/Planetscale/Credentials.ts index 95527ef70..82e894f99 100644 --- a/packages/alchemy/src/Planetscale/Credentials.ts +++ b/packages/alchemy/src/Planetscale/Credentials.ts @@ -1,4 +1,5 @@ import { + type Config as PlanetscaleClientConfig, Credentials, DEFAULT_API_BASE_URL, } from "@distilled.cloud/planetscale/Credentials"; @@ -79,12 +80,23 @@ export const fromAuthProvider = () => Effect.flatMap((config) => auth.read(profileName, config as PlanetscaleAuthConfig), ), - Effect.map((creds) => ({ - tokenId: creds.tokenId, - token: creds.token, - organization: creds.organization, - apiBaseUrl, - })), + Effect.map( + (creds): PlanetscaleClientConfig => + creds.type === "oauth" + ? { + type: "oauth", + accessToken: creds.accessToken, + organization: creds.organization, + apiBaseUrl, + } + : { + type: "serviceToken", + tokenId: creds.tokenId, + token: creds.token, + organization: creds.organization, + apiBaseUrl, + }, + ), Effect.mapError( (e) => new ConfigError({ diff --git a/packages/alchemy/src/Planetscale/OAuthClient.ts b/packages/alchemy/src/Planetscale/OAuthClient.ts new file mode 100644 index 000000000..be8082e46 --- /dev/null +++ b/packages/alchemy/src/Planetscale/OAuthClient.ts @@ -0,0 +1,307 @@ +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import crypto from "node:crypto"; +import http from "node:http"; +import { AUTH_ERROR_URL, AUTH_SUCCESS_URL } from "../Auth/AuthProvider.ts"; + +export class OAuthError extends Data.TaggedError("OAuthError")<{ + error: string; + errorDescription: string; +}> {} + +export interface OAuthCredentials { + type: "oauth"; + access: string; + refresh: string; + expires: number; + scopes: string[]; +} + +export interface Authorization { + url: string; + state: string; +} + +/** + * Registered PlanetScale OAuth application credentials. + * + * Unlike Cloudflare, PlanetScale OAuth has **no PKCE flow** — exchanging the + * authorization code (and refreshing the token) requires the application's + * `client_secret`. There is no way to keep that secret out of a distributed + * CLI, so it ships here: the exposure is the same posture as a public + * `client_id` (a stolen refresh token is usable, exactly like Cloudflare's + * secret-less refresh), and it can be rotated by cutting a new release. + * + * Registered at https://app.planetscale.com with redirect URI + * {@link OAUTH_REDIRECT_URI}. Scopes are configured on the application + * itself, not requested per-authorization. Rotate by registering a new + * secret and cutting a release. + */ +export const OAUTH_CLIENT_ID = "pscale_app_aa12e3938baebb788aac443f66e422da"; +export const OAUTH_CLIENT_SECRET = + "pscale_app_secret_yyZ3Q8oe99GP9_yA5wrA5er6RuN6Lz9dC66Bj1OJzpg"; + +export const OAUTH_REDIRECT_URI = "http://localhost:9976/auth/callback"; +export const OAUTH_ENDPOINTS = { + // PlanetScale's own .well-known OAuth discovery doc declares this as + // the authorization_endpoint — NOT auth.planetscale.com/oauth/authorize + // (which their public docs cite). The auth.planetscale.com alias does + // render a consent screen but emits codes whose resulting tokens lack + // a `sub` claim, so the resource API at api.planetscale.com rejects + // them as invalid. Use the canonical endpoint. + authorize: "https://app.planetscale.com/oauth/authorize", + token: "https://auth.planetscale.com/oauth/token", +}; + +function generateState(length = 32): string { + return crypto.randomBytes(length).toString("base64url"); +} + +function extractCredentials(json: { + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; +}): OAuthCredentials { + return { + type: "oauth", + access: json.access_token, + refresh: json.refresh_token, + expires: Date.now() + json.expires_in * 1000, + scopes: json.scope ? json.scope.split(" ") : [], + }; +} + +const tokenRequest = ( + params: Record, +): Effect.Effect => + Effect.gen(function* () { + // PlanetScale's docs show the token endpoint with all parameters in + // the query string (https://planetscale.com/docs/api/reference/oauth). + // Their .well-known discovery doc claims only client_secret_basic / + // client_secret_post are supported, but we've verified both forms of + // auth produce equivalent (and equivalently broken) tokens, so we + // follow the public docs literally. + const url = new URL(OAUTH_ENDPOINTS.token); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + + const res = yield* Effect.tryPromise({ + try: () => + fetch(url.toString(), { + method: "POST", + headers: { Accept: "application/json" }, + }), + catch: (err) => + new OAuthError({ + error: "network_error", + errorDescription: `Token request failed: ${err}`, + }), + }); + + if (!res.ok) { + const json = yield* Effect.tryPromise({ + try: () => + res.json() as Promise<{ error: string; error_description: string }>, + catch: () => + new OAuthError({ + error: "parse_error", + errorDescription: `Token endpoint returned ${res.status}`, + }), + }); + return yield* new OAuthError({ + error: json.error, + errorDescription: json.error_description, + }); + } + + const json = yield* Effect.tryPromise({ + try: () => + res.json() as Promise<{ + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; + }>, + catch: () => + new OAuthError({ + error: "parse_error", + errorDescription: "Failed to parse token response", + }), + }); + return extractCredentials(json); + }); + +/** + * Generate a PlanetScale authorization URL for the given scopes. + * + * Scope names MUST use the tier prefix (`user:`, `organization:`, + * `database:`, `branch:`) — bare names like `read_user` are rejected with + * "The requested scope is invalid, unknown, or malformed." Use the values + * from {@link ALL_SCOPES}, which carry the prefix. + * + * Pass an empty array to fall back to PlanetScale's implicit default set + * (`read_databases`, `read_user`, `read_organization` — the only place + * unprefixed names work). + */ +export function authorize(): Authorization { + const state = generateState(); + const url = new URL(OAUTH_ENDPOINTS.authorize); + url.searchParams.set("client_id", OAUTH_CLIENT_ID); + url.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", state); + return { url: url.toString(), state }; +} + +/** + * Exchange an authorization code for OAuth credentials. + */ +export const exchange = ( + code: string, +): Effect.Effect => + tokenRequest({ + grant_type: "authorization_code", + code, + client_id: OAUTH_CLIENT_ID, + client_secret: OAUTH_CLIENT_SECRET, + redirect_uri: OAUTH_REDIRECT_URI, + }); + +/** + * Refresh expired OAuth credentials. + */ +export const refresh = ( + credentials: OAuthCredentials, +): Effect.Effect => + tokenRequest({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: OAUTH_CLIENT_ID, + client_secret: OAUTH_CLIENT_SECRET, + }); + +/** + * Start a local HTTP server to listen for the OAuth callback, exchange + * the authorization code, and return the credentials. + * + * Times out after 5 minutes. + */ +export const callback = ( + authorization: Authorization, +): Effect.Effect => + Effect.tryPromise({ + try: () => callbackPromise(authorization), + catch: (err) => { + if (err instanceof OAuthError) return err; + return new OAuthError({ + error: "callback_error", + errorDescription: `OAuth callback failed: ${err}`, + }); + }, + }); + +function callbackPromise( + authorization: Authorization, +): Promise { + const { pathname, port } = new URL(OAUTH_REDIRECT_URI); + + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + + if (url.pathname !== pathname) { + res.statusCode = 404; + res.end("Not Found"); + return; + } + + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + if (error) { + res.writeHead(302, { Location: AUTH_ERROR_URL }); + res.end(); + cleanup(); + reject( + new OAuthError({ + error, + errorDescription: errorDescription ?? "An unknown error occurred.", + }), + ); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + res.writeHead(302, { Location: AUTH_ERROR_URL }); + res.end(); + cleanup(); + reject( + new OAuthError({ + error: "invalid_request", + errorDescription: "Missing code or state", + }), + ); + return; + } + + if (state !== authorization.state) { + res.writeHead(302, { Location: AUTH_ERROR_URL }); + res.end(); + cleanup(); + reject( + new OAuthError({ + error: "invalid_request", + errorDescription: "Invalid state", + }), + ); + return; + } + + try { + const credentials = await Effect.runPromise(exchange(code)); + res.writeHead(302, { Location: AUTH_SUCCESS_URL }); + res.end(); + cleanup(); + resolve(credentials); + } catch (err) { + res.writeHead(302, { Location: AUTH_ERROR_URL }); + res.end(); + cleanup(); + reject(err); + } + }); + + const timeout = setTimeout( + () => { + cleanup(); + reject( + new OAuthError({ + error: "timeout", + errorDescription: "The authorization process timed out.", + }), + ); + }, + 5 * 60 * 1000, + ); + + function cleanup() { + clearTimeout(timeout); + server.close(); + } + + server.on("error", (err) => { + cleanup(); + reject( + new OAuthError({ + error: "server_error", + errorDescription: `Failed to start callback server: ${err.message}`, + }), + ); + }); + + server.listen(Number(port)); + }); +} diff --git a/website/src/layouts/Auth.astro b/website/src/layouts/Auth.astro new file mode 100644 index 000000000..7329aa66d --- /dev/null +++ b/website/src/layouts/Auth.astro @@ -0,0 +1,162 @@ +--- +import "../styles/tokens.css"; +import ThemeProvider from "../components/ThemeProvider.astro"; +import Logo from "../components/marketing/Logo.astro"; + +export interface Props { + title: string; + message: string; + variant?: "success" | "error"; + primaryAction?: { text: string; href?: string; onClick?: string }; + secondaryAction?: { text: string; href?: string; onClick?: string }; +} + +const { + title, + message, + variant = "success", + primaryAction, + secondaryAction, +} = Astro.props; +--- + + + + + + + + {title} — Alchemy + + + +
+ + + + +

+ {title} +

+ +

+ + {(primaryAction || secondaryAction) && ( +

+ {primaryAction && ( + primaryAction.href ? ( + + {primaryAction.text} + + ) : ( + + ) + )} + {secondaryAction && ( + secondaryAction.href ? ( + + {secondaryAction.text} + + ) : ( + + ) + )} +
+ )} +
+ + + + + + diff --git a/website/src/pages/auth/error.astro b/website/src/pages/auth/error.astro new file mode 100644 index 000000000..9344d3c50 --- /dev/null +++ b/website/src/pages/auth/error.astro @@ -0,0 +1,10 @@ +--- +import Auth from "../../layouts/Auth.astro"; +--- + + diff --git a/website/src/pages/auth/success.astro b/website/src/pages/auth/success.astro new file mode 100644 index 000000000..deb6e1b25 --- /dev/null +++ b/website/src/pages/auth/success.astro @@ -0,0 +1,10 @@ +--- +import Auth from "../../layouts/Auth.astro"; +--- + +