1import * as Data from "effect/Data";
2import * as Clock from "effect/Clock";
3import * as Crypto from "effect/Crypto";
4import { Base64Url } from "effect/encoding";
5import * as Effect from "effect/Effect";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import http from "node:http";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12import { AUTH_ERROR_URL, AUTH_SUCCESS_URL } from "./AuthProvider.ts";
13import * as FetchHttpClient from "effect/http/FetchHttpClient";
14
15export class OAuthError extends Data.TaggedError("OAuthError")<{
16 error: string;
17 errorDescription: string;
18}> {}
19
20/**
21 * The local loopback callback server could not start (typically the port is
22 * already taken). Distinct from {@link OAuthError} so callers can fall back to
23 * manual code entry instead of failing the whole flow.
24 */
25export class CallbackServerStartError extends Data.TaggedError(
26 "CallbackServerStartError",
27)<{
28 message: string;
29}> {}
30
31/**
32 * On-disk shape of OAuth credentials persisted under
33 * `~/.alchemy/credentials/{profile}/<provider>-oauth.json`.
34 *
35 * `clientId` is optional because files written before the client id was
36 * recorded must still parse — {@link OAuthClient.usesCurrentClient} treats a
37 * missing id as "issued to a previous client", which triggers a clean
38 * re-login.
39 */
40export const OAuthCredentials = Schema.Struct({
41 type: Schema.Literal("oauth"),
42 clientId: Schema.optional(Schema.String),
43 access: Schema.RedactedFromValue(Schema.String),
44 refresh: Schema.RedactedFromValue(Schema.String),
45 expires: Schema.Number,
46 scopes: Schema.mutable(Schema.Array(Schema.String)),
47});
48export type OAuthCredentials = typeof OAuthCredentials.Type;
49
50export interface Authorization {
51 url: string;
52 state: string;
53 /** PKCE verifier; present only for `auth: { kind: "pkce" }` clients. */
54 verifier?: string;
55}
56
57/**
58 * The provider-specific facts a browser OAuth flow needs. Everything else —
59 * state/PKCE generation, the loopback callback server, hosted-relay code
60 * extraction, token exchange, refresh, revoke — is shared.
61 */
62export interface OAuthClientSpec {
63 readonly clientId: string;
64 readonly endpoints: {
65 readonly authorize: string;
66 readonly token: string;
67 /** Providers without a revocation endpoint omit it; `revoke` then no-ops. */
68 readonly revoke?: string;
69 };
70 /** Hosted relay redirect URI registered with the OAuth application. */
71 readonly redirectUri: string;
72 /** Loopback URI the local callback server listens on. */
73 readonly localCallbackUri: string;
74 /**
75 * Client authentication. `pkce` for public clients; `clientSecret` for
76 * providers whose token endpoint requires client authentication for every
77 * grant (the secret ships in the CLI — same exposure posture as a public
78 * client id, rotated by cutting a release).
79 */
80 readonly auth:
81 | { readonly kind: "pkce" }
82 | {
83 readonly kind: "clientSecret";
84 readonly clientSecret: Redacted.Redacted<string>;
85 };
86 /**
87 * How token-request parameters travel: URL-encoded POST body (the OAuth 2
88 * standard, default) or the query string (PlanetScale's documented form).
89 */
90 readonly tokenTransport?: "body" | "query";
91}
92
93export interface OAuthClient {
94 readonly clientId: string;
95 /** Whether persisted credentials were issued to this client. */
96 readonly usesCurrentClient: (credentials: {
97 readonly clientId?: unknown;
98 }) => boolean;
99 /**
100 * Generate an authorization URL. Pass `scopes` only for providers that
101 * take them per-authorization; omit for providers whose scopes are
102 * configured on the application.
103 */
104 readonly authorize: (
105 scopes?: ReadonlyArray<string>,
106 ) => Effect.Effect<Authorization, OAuthError, Crypto.Crypto>;
107 /**
108 * Exchange an authorization code directly. `authorization` supplies the
109 * PKCE verifier; omit for non-PKCE clients (tests, relay-less flows).
110 */
111 readonly exchange: (
112 code: string,
113 authorization?: Authorization,
114 ) => Effect.Effect<OAuthCredentials, OAuthError, never>;
115 /**
116 * Exchange a code copied from the hosted relay page, or extract the code
117 * from either the hosted or loopback callback URL.
118 */
119 readonly exchangeCallbackInput: (
120 input: string,
121 authorization: Authorization,
122 ) => Effect.Effect<OAuthCredentials, OAuthError, never>;
123 /**
124 * Start a local HTTP server to listen for the OAuth callback, exchange
125 * the authorization code, and return the credentials. Times out after 5
126 * minutes.
127 */
128 readonly callback: (
129 authorization: Authorization,
130 ) => Effect.Effect<
131 OAuthCredentials,
132 OAuthError | CallbackServerStartError,
133 never
134 >;
135 /** Refresh expired OAuth credentials with the stored refresh token. */
136 readonly refresh: (
137 credentials: OAuthCredentials,
138 ) => Effect.Effect<OAuthCredentials, OAuthError, never>;
139 /** Revoke the refresh token; no-op when the spec has no revoke endpoint. */
140 readonly revoke: (
141 credentials: OAuthCredentials,
142 ) => Effect.Effect<void, OAuthError>;
143}
144
145const randomText = Effect.fn(function* (length: number) {
146 const crypto = yield* Crypto.Crypto;
147 const bytes = yield* crypto.randomBytes(length).pipe(
148 Effect.mapError(
149 (cause) =>
150 new OAuthError({
151 error: "crypto_error",
152 errorDescription: `Secure random generation failed: ${cause}`,
153 }),
154 ),
155 );
156 return Base64Url.encode(bytes);
157});
158
159const generatePKCE = Effect.fn(function* (length = 96) {
160 const crypto = yield* Crypto.Crypto;
161 const verifier = yield* randomText(length);
162 const challenge = yield* crypto
163 .digest("SHA-256", new TextEncoder().encode(verifier))
164 .pipe(
165 Effect.mapError(
166 (cause) =>
167 new OAuthError({
168 error: "crypto_error",
169 errorDescription: `PKCE digest failed: ${cause}`,
170 }),
171 ),
172 );
173 return { verifier, challenge: Base64Url.encode(challenge) };
174});
175
176const TokenResponse = Schema.Struct({
177 access_token: Schema.String,
178 refresh_token: Schema.optional(Schema.String),
179 expires_in: Schema.Number,
180 scope: Schema.optional(Schema.String),
181});
182
183const TokenErrorResponse = Schema.Struct({
184 error: Schema.String,
185 error_description: Schema.optional(Schema.String),
186});
187
188export const makeOAuthClient = (spec: OAuthClientSpec): OAuthClient => {
189 const provideHttp = <A, E>(
190 effect: Effect.Effect<A, E, HttpClient.HttpClient>,
191 ) => effect.pipe(Effect.provide(FetchHttpClient.layer));
192 const clientAuthParams = (): Record<string, string> =>
193 spec.auth.kind === "clientSecret"
194 ? { client_secret: Redacted.value(spec.auth.clientSecret) }
195 : {};
196
197 const extractCredentials = (
198 json: {
199 access_token: string;
200 refresh_token?: string;
201 expires_in: number;
202 scope?: string;
203 },
204 previous?: OAuthCredentials,
205 ): Effect.Effect<OAuthCredentials, OAuthError> => {
206 const refresh =
207 json.refresh_token === undefined
208 ? previous?.refresh
209 : Redacted.make(json.refresh_token);
210 if (!refresh) {
211 return Effect.fail(
212 new OAuthError({
213 error: "invalid_token_response",
214 errorDescription:
215 "The provider did not return a refresh token for this authorization.",
216 }),
217 );
218 }
219 return Effect.gen(function* () {
220 const now = yield* Clock.currentTimeMillis;
221 return {
222 type: "oauth" as const,
223 clientId: spec.clientId,
224 access: Redacted.make(json.access_token),
225 refresh,
226 expires: now + json.expires_in * 1000,
227 scopes: json.scope?.split(" ") ?? previous?.scopes ?? [],
228 };
229 });
230 };
231
232 const tokenRequest = Effect.fn(function* (
233 params: Record<string, string>,
234 previous?: OAuthCredentials,
235 ) {
236 const client = yield* HttpClient.HttpClient;
237 const transport = spec.tokenTransport ?? "body";
238 const request = HttpClientRequest.post(spec.endpoints.token).pipe(
239 HttpClientRequest.setHeader("Accept", "application/json"),
240 transport === "query"
241 ? HttpClientRequest.setUrlParams(params)
242 : HttpClientRequest.bodyUrlParams(params),
243 );
244 const response = yield* client.execute(request).pipe(
245 Effect.mapError(
246 (cause) =>
247 new OAuthError({
248 error: "network_error",
249 errorDescription: `Token request failed: ${cause}`,
250 }),
251 ),
252 );
253
254 if (response.status < 200 || response.status >= 300) {
255 const json = yield* HttpClientResponse.schemaBodyJson(TokenErrorResponse)(
256 response,
257 ).pipe(
258 Effect.mapError(
259 () =>
260 new OAuthError({
261 error: "parse_error",
262 errorDescription: `Token endpoint returned ${response.status}`,
263 }),
264 ),
265 );
266 return yield* new OAuthError({
267 error: json.error,
268 errorDescription:
269 json.error_description ??
270 `Token endpoint returned ${response.status}`,
271 });
272 }
273
274 const json = yield* HttpClientResponse.schemaBodyJson(TokenResponse)(
275 response,
276 ).pipe(
277 Effect.mapError(
278 () =>
279 new OAuthError({
280 error: "parse_error",
281 errorDescription: "Failed to parse token response",
282 }),
283 ),
284 );
285 return yield* extractCredentials(json, previous);
286 });
287
288 const authorize = Effect.fn(function* (scopes?: ReadonlyArray<string>) {
289 const state = yield* randomText(32);
290 const url = new URL(spec.endpoints.authorize);
291 url.searchParams.set("client_id", spec.clientId);
292 url.searchParams.set("redirect_uri", spec.redirectUri);
293 url.searchParams.set("response_type", "code");
294 if (scopes !== undefined) {
295 url.searchParams.set("scope", scopes.join(" "));
296 }
297 url.searchParams.set("state", state);
298 if (spec.auth.kind === "pkce") {
299 const pkce = yield* generatePKCE();
300 url.searchParams.set("code_challenge", pkce.challenge);
301 url.searchParams.set("code_challenge_method", "S256");
302 return { url: url.toString(), state, verifier: pkce.verifier };
303 }
304 return { url: url.toString(), state };
305 });
306
307 const exchange = Effect.fn(function (
308 code: string,
309 authorization?: Authorization,
310 ) {
311 return tokenRequest({
312 grant_type: "authorization_code",
313 code,
314 client_id: spec.clientId,
315 redirect_uri: spec.redirectUri,
316 ...clientAuthParams(),
317 ...(authorization?.verifier === undefined
318 ? {}
319 : { code_verifier: authorization.verifier }),
320 });
321 });
322
323 const refresh = Effect.fn(function (credentials: OAuthCredentials) {
324 return tokenRequest(
325 {
326 grant_type: "refresh_token",
327 refresh_token: Redacted.value(credentials.refresh),
328 client_id: spec.clientId,
329 ...clientAuthParams(),
330 },
331 credentials,
332 );
333 });
334
335 const revoke = Effect.fn(function* (credentials: OAuthCredentials) {
336 const endpoint = spec.endpoints.revoke;
337 if (endpoint === undefined) return;
338 const client = yield* HttpClient.HttpClient;
339 const request = HttpClientRequest.post(endpoint).pipe(
340 HttpClientRequest.setHeader("Accept", "application/json"),
341 HttpClientRequest.bodyUrlParams({
342 token: Redacted.value(credentials.refresh),
343 token_type_hint: "refresh_token",
344 client_id: spec.clientId,
345 ...clientAuthParams(),
346 }),
347 );
348 yield* client.execute(request).pipe(
349 Effect.mapError(
350 (cause) =>
351 new OAuthError({
352 error: "network_error",
353 errorDescription: `Revoke request failed: ${cause}`,
354 }),
355 ),
356 Effect.asVoid,
357 );
358 });
359
360 const exchangeCallbackInput = Effect.fn(function* (
361 input: string,
362 authorization: Authorization,
363 ) {
364 const value = input.trim();
365 let code = value;
366 let state: string | null = null;
367
368 try {
369 const url = new URL(value);
370 code = url.searchParams.get("code") ?? "";
371 state = url.searchParams.get("state");
372 } catch {
373 const separator = value.lastIndexOf("#");
374 if (separator >= 0) {
375 code = value.slice(0, separator);
376 state = value.slice(separator + 1);
377 }
378 }
379
380 if (!code) {
381 return yield* new OAuthError({
382 error: "invalid_request",
383 errorDescription: "The authorization code is missing.",
384 });
385 }
386 if (state !== null && state !== authorization.state) {
387 return yield* new OAuthError({
388 error: "invalid_request",
389 errorDescription: "The authorization state does not match.",
390 });
391 }
392 return yield* exchange(code, authorization);
393 });
394
395 const callback = Effect.fn(function* (authorization: Authorization) {
396 const { pathname, port, protocol } = new URL(spec.localCallbackUri);
397 const listenPort =
398 port === "" ? (protocol === "https:" ? 443 : 80) : Number(port);
399 const listen = Effect.callback<
400 OAuthCredentials,
401 OAuthError | CallbackServerStartError,
402 HttpClient.HttpClient
403 >((resume) => {
404 // The handler can run more than once (browser retries, extra tabs);
405 // resume exactly once and ignore everything after.
406 let settled = false;
407 const resolveOnce = (
408 effect: Effect.Effect<
409 OAuthCredentials,
410 OAuthError | CallbackServerStartError,
411 HttpClient.HttpClient
412 >,
413 ) => {
414 if (settled) return;
415 settled = true;
416 resume(effect);
417 };
418 const server = http.createServer((req, res) => {
419 const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
420
421 if (url.pathname === "/auth/ping") {
422 res.writeHead(req.method === "OPTIONS" ? 204 : 200, {
423 "Access-Control-Allow-Origin": new URL(spec.redirectUri).origin,
424 "Access-Control-Allow-Methods": "GET, OPTIONS",
425 "Access-Control-Allow-Private-Network": "true",
426 "Cache-Control": "no-store",
427 });
428 res.end();
429 return;
430 }
431
432 if (url.pathname !== pathname) {
433 res.statusCode = 404;
434 res.end("Not Found");
435 return;
436 }
437
438 const error = url.searchParams.get("error");
439 const errorDescription = url.searchParams.get("error_description");
440 if (error) {
441 res.writeHead(302, { Location: AUTH_ERROR_URL });
442 res.end();
443 resolveOnce(
444 Effect.fail(
445 new OAuthError({
446 error,
447 errorDescription:
448 errorDescription ?? "An unknown error occurred.",
449 }),
450 ),
451 );
452 return;
453 }
454
455 const code = url.searchParams.get("code");
456 const state = url.searchParams.get("state");
457 if (!code || !state) {
458 res.writeHead(302, { Location: AUTH_ERROR_URL });
459 res.end();
460 resolveOnce(
461 Effect.fail(
462 new OAuthError({
463 error: "invalid_request",
464 errorDescription: "Missing code or state",
465 }),
466 ),
467 );
468 return;
469 }
470
471 if (state !== authorization.state) {
472 res.writeHead(302, { Location: AUTH_ERROR_URL });
473 res.end();
474 resolveOnce(
475 Effect.fail(
476 new OAuthError({
477 error: "invalid_request",
478 errorDescription: "Invalid state",
479 }),
480 ),
481 );
482 return;
483 }
484 resolveOnce(
485 exchange(code, authorization).pipe(
486 Effect.tap(() =>
487 Effect.sync(() => {
488 res.writeHead(302, { Location: AUTH_SUCCESS_URL });
489 res.end();
490 }),
491 ),
492 Effect.tapError(() =>
493 Effect.sync(() => {
494 res.writeHead(302, { Location: AUTH_ERROR_URL });
495 res.end();
496 }),
497 ),
498 ),
499 );
500 });
501
502 server.on("error", (err) => {
503 resolveOnce(
504 Effect.fail(
505 server.listening
506 ? new OAuthError({
507 error: "server_error",
508 errorDescription: `Callback server failed: ${err.message}`,
509 })
510 : new CallbackServerStartError({
511 message: `Failed to start callback server: ${err.message}`,
512 }),
513 ),
514 );
515 });
516
517 server.listen(listenPort, "127.0.0.1");
518 return Effect.sync(() => {
519 server.close();
520 // `close` only stops new connections — a keep-alive browser
521 // connection would otherwise hold the process open until its
522 // socket timeout after login completes.
523 server.closeAllConnections();
524 });
525 });
526 return yield* listen.pipe(
527 Effect.timeoutOrElse({
528 duration: "5 minutes",
529 orElse: () =>
530 Effect.fail(
531 new OAuthError({
532 error: "timeout",
533 errorDescription: "The authorization process timed out.",
534 }),
535 ),
536 }),
537 );
538 });
539
540 return {
541 clientId: spec.clientId,
542 usesCurrentClient: (credentials) => credentials.clientId === spec.clientId,
543 authorize,
544 exchange: (...args) => provideHttp(exchange(...args)),
545 exchangeCallbackInput: (...args) =>
546 provideHttp(exchangeCallbackInput(...args)),
547 callback: (authorization) => provideHttp(callback(authorization)),
548 refresh: (credentials) => provideHttp(refresh(credentials)),
549 revoke: (credentials) => provideHttp(revoke(credentials)),
550 };
551};