import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect";import { createHash, randomUUID } from "node:crypto";import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse, HttpServer, HttpServerRequest, HttpServerResponse,} from "effect/unstable/http"; export class OAuthTestServerAddressError extends Data.TaggedError("OAuthTestServerAddressError")<{ readonly address: unknown;}> {} export class OAuthTestServerFlowError extends Data.TaggedError("OAuthTestServerFlowError")<{ readonly message: string; readonly cause?: unknown;}> {} export interface OAuthAuthorizationCompletion { readonly callbackUrl: string; readonly code: string; readonly state: string;} export interface OAuthTokenSet { readonly accessToken: string; readonly refreshToken?: string; readonly tokenType: string; readonly expiresIn?: number; readonly scope?: string;} export interface OAuthTestServerRequest { readonly method: string; readonly url: string; readonly path: string; readonly headers: Readonly<Record<string, string>>; readonly body: string; readonly query: Readonly<Record<string, string>>;} export interface OAuthTestServerOptions { readonly users?: Readonly<Record<string, string>>; readonly defaultUsername?: string; readonly defaultPassword?: string; readonly defaultClientId?: string; readonly defaultClientSecret?: string; readonly clients?: Readonly<Record<string, string | null>>; readonly scopes?: readonly string[]; readonly omitTokenResponseScopes?: readonly string[]; readonly supportRefresh?: boolean; readonly tokenExpiresInSeconds?: number; readonly invalidRefreshTokenDescription?: string; /** RFC 6749 error code returned when a refresh-token grant is rejected. * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror * authorization servers that reject dead refresh tokens with other codes. */ readonly invalidRefreshTokenErrorCode?: string; readonly idTokenClaims?: Readonly<Record<string, unknown>>; readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, * `/register` returns `400 invalid_redirect_uri` unless every requested * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. * Vercel) that only accept loopback redirect URIs for anonymous DCR. */ readonly approveRedirectUri?: (uri: string) => boolean; /** Gate Dynamic Client Registration on the requested `client_name`. When set, * `/register` returns `400 invalid_client_metadata` unless the requested * name is approved. Mirrors authorization servers (e.g. Mercury) that * reject third-party client names containing their own brand. */ readonly approveClientName?: (name: string) => boolean;} export interface OAuthTestServerShape { readonly issuerUrl: string; readonly authorizationEndpoint: string; readonly tokenEndpoint: string; readonly registrationEndpoint: string; readonly protectedResourceMetadataUrl: string; readonly resourceUrl: string; readonly mcpResourceUrl: string; readonly completeAuthorizationCodeFlow: (input: { readonly authorizationUrl: string; readonly username?: string; readonly password?: string; }) => Effect.Effect<OAuthAuthorizationCompletion, OAuthTestServerFlowError>; readonly completeAuthorizationCodeTokenFlow: (input?: { readonly username?: string; readonly password?: string; readonly clientId?: string; readonly clientSecret?: string; readonly redirectUrl?: string; readonly scopes?: readonly string[]; readonly resource?: string; }) => Effect.Effect<OAuthTokenSet, OAuthTestServerFlowError>; readonly requests: Effect.Effect<readonly OAuthTestServerRequest[]>; readonly clearRequests: Effect.Effect<void>; readonly issuedAccessTokens: Effect.Effect<readonly string[]>; readonly acceptsAccessToken: (token: string) => Effect.Effect<boolean>; readonly acceptsAuthorizationHeader: ( authorization: string | null | undefined, ) => Effect.Effect<boolean>;} interface ClientRecord { readonly clientSecret: string | null; readonly redirectUris: ReadonlySet<string>; readonly tokenEndpointAuthMethod: string;} interface AuthorizationTransaction { readonly clientId: string; readonly redirectUri: string; readonly state: string; readonly codeChallenge: string; readonly scope: string | null; readonly resource: string | null;} interface AuthorizationCodeRecord extends AuthorizationTransaction { readonly username: string;} interface RefreshTokenRecord { readonly clientId: string; readonly username: string; readonly scope: string | null; readonly resource: string | null;} const JsonObject = Schema.Record(Schema.String, Schema.Unknown);const decodeJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(JsonObject));const TokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), token_type: Schema.String, expires_in: Schema.optional(Schema.Number), scope: Schema.optional(Schema.String), id_token: Schema.optional(Schema.String),});const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); const defaultScopes = ["read", "write"] as const; const jsonResponse = ( status: number, body: Readonly<Record<string, unknown>>, headers: Readonly<Record<string, string>> = {},): HttpServerResponse.HttpServerResponse => HttpServerResponse.jsonUnsafe(body, { status, headers }); const textResponse = ( status: number, body: string, headers: Readonly<Record<string, string>> = {},): HttpServerResponse.HttpServerResponse => HttpServerResponse.text(body, { status, headers, contentType: "text/plain; charset=utf-8", }); const redirectResponse = (location: string): HttpServerResponse.HttpServerResponse => HttpServerResponse.redirect(location); const parseJsonObject = (body: string): Readonly<Record<string, unknown>> | null => { const result = decodeJsonObject(body); return Option.isSome(result) ? result.value : null;}; const arrayOfStrings = (value: unknown): readonly string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; const decodeBasicAuthorization = ( value: string | undefined,): { readonly username: string; readonly password: string } | null => { if (!value) return null; const match = /^Basic\s+(.+)$/i.exec(value); if (!match) return null; const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); const separator = decoded.indexOf(":"); if (separator < 0) return null; return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1), };}; const codeChallengeForVerifier = (verifier: string): string => createHash("sha256").update(verifier).digest("base64url"); const jwtPart = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url"); const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string => `${jwtPart({ alg: "RS256", typ: "JWT" })}.${jwtPart(claims)}.sig`; const oauthError = (status: number, error: string, errorDescription: string) => jsonResponse( status, { error, error_description: errorDescription, }, status === 401 ? { "www-authenticate": 'Basic realm="OAuth test server"' } : {}, ); const manualRedirectHttpClientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" })),); const executeManualRedirect = ( request: HttpClientRequest.HttpClientRequest, requestUrl: string,): Effect.Effect<HttpClientResponse.HttpClientResponse, OAuthTestServerFlowError> => HttpClient.execute(request).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: `OAuth test flow request failed for ${requestUrl}`, cause, }), ), Effect.provide(manualRedirectHttpClientLayer), ); const executeOAuthHttp = ( request: HttpClientRequest.HttpClientRequest, requestUrl: string,): Effect.Effect<HttpClientResponse.HttpClientResponse, OAuthTestServerFlowError> => HttpClient.execute(request).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: `OAuth test flow request failed for ${requestUrl}`, cause, }), ), Effect.provide(FetchHttpClient.layer), ); const requiredRedirectLocation = ( response: HttpClientResponse.HttpClientResponse, requestUrl: string,): Effect.Effect<string, OAuthTestServerFlowError> => Effect.gen(function* () { if (response.status < 300 || response.status >= 400) { return yield* new OAuthTestServerFlowError({ message: `Expected redirect from ${requestUrl}, got HTTP ${response.status}`, }); } const location = response.headers.location; if (!location) { return yield* new OAuthTestServerFlowError({ message: `Expected Location header from ${requestUrl}`, }); } return new URL(location, requestUrl).toString(); }); const serveOAuthTestHttpApp = ( handler: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect<HttpServerResponse.HttpServerResponse>,): Effect.Effect<{ readonly baseUrl: string }, OAuthTestServerAddressError, Scope.Scope> => Effect.gen(function* () { const context = yield* Layer.build( Layer.fresh( HttpServer.serve( HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler)), ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), ), ).pipe(Effect.mapError((address) => new OAuthTestServerAddressError({ address }))); const server = Context.get(context, HttpServer.HttpServer); const address = server.address; if (!Predicate.isTagged(address, "TcpAddress")) { return yield* new OAuthTestServerAddressError({ address }); } return { baseUrl: `http://127.0.0.1:${address.port}` }; }); const requestBodyText = (request: HttpServerRequest.HttpServerRequest): Effect.Effect<string> => request.text.pipe(Effect.catch(() => Effect.succeed(""))); const completeAuthorizationCodeFlow = (defaults: { readonly username: string; readonly password: string }) => (input: { readonly authorizationUrl: string; readonly username?: string; readonly password?: string; }): Effect.Effect<OAuthAuthorizationCompletion, OAuthTestServerFlowError> => Effect.gen(function* () { const loginResponse = yield* executeManualRedirect( HttpClientRequest.get(input.authorizationUrl), input.authorizationUrl, ); const loginUrl = yield* requiredRedirectLocation(loginResponse, input.authorizationUrl); const credentials = Buffer.from( `${input.username ?? defaults.username}:${input.password ?? defaults.password}`, ).toString("base64"); const callbackResponse = yield* executeManualRedirect( HttpClientRequest.post(loginUrl).pipe( HttpClientRequest.setHeader("authorization", `Basic ${credentials}`), ), loginUrl, ); const callbackUrl = yield* requiredRedirectLocation(callbackResponse, loginUrl); const parsed = new URL(callbackUrl); const state = parsed.searchParams.get("state"); const code = parsed.searchParams.get("code"); if (!state || !code) { return yield* new OAuthTestServerFlowError({ message: "OAuth callback did not include both state and code", }); } return { callbackUrl, state, code }; }); const completeAuthorizationCodeTokenFlow = (defaults: { readonly username: string; readonly password: string; readonly clientId: string; readonly clientSecret: string; readonly authorizationEndpoint: string; readonly tokenEndpoint: string; }) => ( input: { readonly username?: string; readonly password?: string; readonly clientId?: string; readonly clientSecret?: string; readonly redirectUrl?: string; readonly scopes?: readonly string[]; readonly resource?: string; } = {}, ): Effect.Effect<OAuthTokenSet, OAuthTestServerFlowError> => Effect.gen(function* () { const clientId = input.clientId ?? defaults.clientId; const clientSecret = input.clientSecret ?? defaults.clientSecret; const redirectUrl = input.redirectUrl ?? "http://127.0.0.1/callback"; const codeVerifier = `verifier_${randomUUID()}`; const state = `state_${randomUUID()}`; const authorizationUrl = new URL(defaults.authorizationEndpoint); authorizationUrl.searchParams.set("response_type", "code"); authorizationUrl.searchParams.set("client_id", clientId); authorizationUrl.searchParams.set("redirect_uri", redirectUrl); authorizationUrl.searchParams.set("state", state); authorizationUrl.searchParams.set("code_challenge", codeChallengeForVerifier(codeVerifier)); authorizationUrl.searchParams.set("code_challenge_method", "S256"); if (input.scopes && input.scopes.length > 0) { authorizationUrl.searchParams.set("scope", input.scopes.join(" ")); } if (input.resource) { authorizationUrl.searchParams.set("resource", input.resource); } const callback = yield* completeAuthorizationCodeFlow({ username: defaults.username, password: defaults.password, })({ authorizationUrl: authorizationUrl.toString(), username: input.username, password: input.password, }); const tokenResponse = yield* executeOAuthHttp( HttpClientRequest.post(defaults.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams({ grant_type: "authorization_code", code: callback.code, redirect_uri: redirectUrl, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier, }), ), defaults.tokenEndpoint, ); if (tokenResponse.status !== 200) { const body = yield* tokenResponse.text.pipe( Effect.catch(() => Effect.succeed("<unavailable>")), ); return yield* new OAuthTestServerFlowError({ message: `Expected token response HTTP 200, got HTTP ${tokenResponse.status}: ${body}`, }); } const raw = yield* tokenResponse.json.pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: "OAuth token response was not valid JSON", cause, }), ), ); const token = yield* decodeTokenResponse(raw).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: "OAuth token response did not match the expected shape", cause, }), ), ); return { accessToken: token.access_token, refreshToken: token.refresh_token, tokenType: token.token_type, expiresIn: token.expires_in, scope: token.scope, }; }); /** Parse the `scope` query param from an authorize URL into an ordered list * (empty when the parameter is absent or blank). */export const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => { const raw = new URL(authorizationUrl).searchParams.get("scope"); return raw == null || raw.length === 0 ? [] : raw.split(" ");}; export const serveOAuthTestServer = ( options: OAuthTestServerOptions = {},): Effect.Effect<OAuthTestServerShape, OAuthTestServerAddressError, Scope.Scope> => Effect.gen(function* () { const requests = yield* Ref.make<readonly OAuthTestServerRequest[]>([]); const issuedAccessTokens = yield* Ref.make<ReadonlySet<string>>(new Set()); const users = { [options.defaultUsername ?? "alice"]: options.defaultPassword ?? "password", ...(options.users ?? {}), }; const supportRefresh = options.supportRefresh ?? true; const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600; const invalidRefreshTokenDescription = options.invalidRefreshTokenDescription ?? "Unknown refresh token"; const invalidRefreshTokenErrorCode = options.invalidRefreshTokenErrorCode ?? "invalid_grant"; const scopes = options.scopes ?? defaultScopes; const omittedTokenResponseScopes = new Set(options.omitTokenResponseScopes ?? []); const tokenResponseScope = (scope: string | null): string | undefined => { if (!scope) return undefined; const filtered = scope .split(/\s+/) .filter((item) => item.length > 0 && !omittedTokenResponseScopes.has(item)); return filtered.length > 0 ? filtered.join(" ") : undefined; }; const clients = new Map<string, ClientRecord>(); const transactions = new Map<string, AuthorizationTransaction>(); const authorizationCodes = new Map<string, AuthorizationCodeRecord>(); const refreshTokens = new Map<string, RefreshTokenRecord>(); const defaultClientId = options.defaultClientId ?? "test-client"; const defaultClientSecret = options.defaultClientSecret ?? "test-secret"; clients.set(defaultClientId, { clientSecret: defaultClientSecret, redirectUris: new Set(), tokenEndpointAuthMethod: "client_secret_post", }); for (const [clientId, clientSecret] of Object.entries(options.clients ?? {})) { clients.set(clientId, { clientSecret, redirectUris: new Set(), tokenEndpointAuthMethod: clientSecret === null ? "none" : "client_secret_post", }); } let issuerUrl = ""; const server = yield* serveOAuthTestHttpApp((request) => Effect.gen(function* () { const currentIssuerUrl = issuerUrl || "http://127.0.0.1"; const requestUrl = new URL(request.url, currentIssuerUrl); const body = yield* requestBodyText(request); const headers = request.headers; yield* Ref.update(requests, (all) => [ ...all, { method: request.method, url: requestUrl.toString(), path: requestUrl.pathname, headers, body, query: Object.fromEntries(requestUrl.searchParams.entries()), }, ]); if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) { const suffix = requestUrl.pathname.slice("/.well-known/oauth-protected-resource".length); const resource = `${currentIssuerUrl}${suffix}`; return jsonResponse(200, { resource, authorization_servers: [currentIssuerUrl], bearer_methods_supported: ["header"], scopes_supported: scopes, }); } if ( requestUrl.pathname === "/.well-known/oauth-authorization-server" ||Lines 1–500 of 804. Load more
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Context, Data, Effect, Layer, Option, Predicate, Ref, Schema, Scope } from "effect"; import { createHash, randomUUID } from "node:crypto"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse, HttpServer, HttpServerRequest, HttpServerResponse, } from "effect/unstable/http"; export class OAuthTestServerAddressError extends Data.TaggedError("OAuthTestServerAddressError")<{ readonly address: unknown; }> {} export class OAuthTestServerFlowError extends Data.TaggedError("OAuthTestServerFlowError")<{ readonly message: string; readonly cause?: unknown; }> {} export interface OAuthAuthorizationCompletion { readonly callbackUrl: string; readonly code: string; readonly state: string; } export interface OAuthTokenSet { readonly accessToken: string; readonly refreshToken?: string; readonly tokenType: string; readonly expiresIn?: number; readonly scope?: string; } export interface OAuthTestServerRequest { readonly method: string; readonly url: string; readonly path: string; readonly headers: Readonly<Record<string, string>>; readonly body: string; readonly query: Readonly<Record<string, string>>; } export interface OAuthTestServerOptions { readonly users?: Readonly<Record<string, string>>; readonly defaultUsername?: string; readonly defaultPassword?: string; readonly defaultClientId?: string; readonly defaultClientSecret?: string; readonly clients?: Readonly<Record<string, string | null>>; readonly scopes?: readonly string[]; readonly omitTokenResponseScopes?: readonly string[]; readonly supportRefresh?: boolean; readonly tokenExpiresInSeconds?: number; readonly invalidRefreshTokenDescription?: string; /** RFC 6749 error code returned when a refresh-token grant is rejected. * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror * authorization servers that reject dead refresh tokens with other codes. */ readonly invalidRefreshTokenErrorCode?: string; readonly idTokenClaims?: Readonly<Record<string, unknown>>; readonly refreshIdTokenClaims?: Readonly<Record<string, unknown>>; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, * `/register` returns `400 invalid_redirect_uri` unless every requested * `redirect_uris` entry is approved. Mirrors authorization servers (e.g. * Vercel) that only accept loopback redirect URIs for anonymous DCR. */ readonly approveRedirectUri?: (uri: string) => boolean; /** Gate Dynamic Client Registration on the requested `client_name`. When set, * `/register` returns `400 invalid_client_metadata` unless the requested * name is approved. Mirrors authorization servers (e.g. Mercury) that * reject third-party client names containing their own brand. */ readonly approveClientName?: (name: string) => boolean; } export interface OAuthTestServerShape { readonly issuerUrl: string; readonly authorizationEndpoint: string; readonly tokenEndpoint: string; readonly registrationEndpoint: string; readonly protectedResourceMetadataUrl: string; readonly resourceUrl: string; readonly mcpResourceUrl: string; readonly completeAuthorizationCodeFlow: (input: { readonly authorizationUrl: string; readonly username?: string; readonly password?: string; }) => Effect.Effect<OAuthAuthorizationCompletion, OAuthTestServerFlowError>; readonly completeAuthorizationCodeTokenFlow: (input?: { readonly username?: string; readonly password?: string; readonly clientId?: string; readonly clientSecret?: string; readonly redirectUrl?: string; readonly scopes?: readonly string[]; readonly resource?: string; }) => Effect.Effect<OAuthTokenSet, OAuthTestServerFlowError>; readonly requests: Effect.Effect<readonly OAuthTestServerRequest[]>; readonly clearRequests: Effect.Effect<void>; readonly issuedAccessTokens: Effect.Effect<readonly string[]>; readonly acceptsAccessToken: (token: string) => Effect.Effect<boolean>; readonly acceptsAuthorizationHeader: ( authorization: string | null | undefined, ) => Effect.Effect<boolean>; } interface ClientRecord { readonly clientSecret: string | null; readonly redirectUris: ReadonlySet<string>; readonly tokenEndpointAuthMethod: string; } interface AuthorizationTransaction { readonly clientId: string; readonly redirectUri: string; readonly state: string; readonly codeChallenge: string; readonly scope: string | null; readonly resource: string | null; } interface AuthorizationCodeRecord extends AuthorizationTransaction { readonly username: string; } interface RefreshTokenRecord { readonly clientId: string; readonly username: string; readonly scope: string | null; readonly resource: string | null; } const JsonObject = Schema.Record(Schema.String, Schema.Unknown); const decodeJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(JsonObject)); const TokenResponse = Schema.Struct({ access_token: Schema.String, refresh_token: Schema.optional(Schema.String), token_type: Schema.String, expires_in: Schema.optional(Schema.Number), scope: Schema.optional(Schema.String), id_token: Schema.optional(Schema.String), }); const decodeTokenResponse = Schema.decodeUnknownEffect(TokenResponse); const defaultScopes = ["read", "write"] as const; const jsonResponse = ( status: number, body: Readonly<Record<string, unknown>>, headers: Readonly<Record<string, string>> = {}, ): HttpServerResponse.HttpServerResponse => HttpServerResponse.jsonUnsafe(body, { status, headers }); const textResponse = ( status: number, body: string, headers: Readonly<Record<string, string>> = {}, ): HttpServerResponse.HttpServerResponse => HttpServerResponse.text(body, { status, headers, contentType: "text/plain; charset=utf-8", }); const redirectResponse = (location: string): HttpServerResponse.HttpServerResponse => HttpServerResponse.redirect(location); const parseJsonObject = (body: string): Readonly<Record<string, unknown>> | null => { const result = decodeJsonObject(body); return Option.isSome(result) ? result.value : null; }; const arrayOfStrings = (value: unknown): readonly string[] => Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; const decodeBasicAuthorization = ( value: string | undefined, ): { readonly username: string; readonly password: string } | null => { if (!value) return null; const match = /^Basic\s+(.+)$/i.exec(value); if (!match) return null; const decoded = Buffer.from(match[1]!, "base64").toString("utf8"); const separator = decoded.indexOf(":"); if (separator < 0) return null; return { username: decoded.slice(0, separator), password: decoded.slice(separator + 1), }; }; const codeChallengeForVerifier = (verifier: string): string => createHash("sha256").update(verifier).digest("base64url"); const jwtPart = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url"); const unsignedJwt = (claims: Readonly<Record<string, unknown>>): string => `${jwtPart({ alg: "RS256", typ: "JWT" })}.${jwtPart(claims)}.sig`; const oauthError = (status: number, error: string, errorDescription: string) => jsonResponse( status, { error, error_description: errorDescription, }, status === 401 ? { "www-authenticate": 'Basic realm="OAuth test server"' } : {}, ); const manualRedirectHttpClientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.RequestInit, { redirect: "manual" })), ); const executeManualRedirect = ( request: HttpClientRequest.HttpClientRequest, requestUrl: string, ): Effect.Effect<HttpClientResponse.HttpClientResponse, OAuthTestServerFlowError> => HttpClient.execute(request).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: `OAuth test flow request failed for ${requestUrl}`, cause, }), ), Effect.provide(manualRedirectHttpClientLayer), ); const executeOAuthHttp = ( request: HttpClientRequest.HttpClientRequest, requestUrl: string, ): Effect.Effect<HttpClientResponse.HttpClientResponse, OAuthTestServerFlowError> => HttpClient.execute(request).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: `OAuth test flow request failed for ${requestUrl}`, cause, }), ), Effect.provide(FetchHttpClient.layer), ); const requiredRedirectLocation = ( response: HttpClientResponse.HttpClientResponse, requestUrl: string, ): Effect.Effect<string, OAuthTestServerFlowError> => Effect.gen(function* () { if (response.status < 300 || response.status >= 400) { return yield* new OAuthTestServerFlowError({ message: `Expected redirect from ${requestUrl}, got HTTP ${response.status}`, }); } const location = response.headers.location; if (!location) { return yield* new OAuthTestServerFlowError({ message: `Expected Location header from ${requestUrl}`, }); } return new URL(location, requestUrl).toString(); }); const serveOAuthTestHttpApp = ( handler: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect<HttpServerResponse.HttpServerResponse>, ): Effect.Effect<{ readonly baseUrl: string }, OAuthTestServerAddressError, Scope.Scope> => Effect.gen(function* () { const context = yield* Layer.build( Layer.fresh( HttpServer.serve( HttpServerRequest.HttpServerRequest.asEffect().pipe(Effect.flatMap(handler)), ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), ), ).pipe(Effect.mapError((address) => new OAuthTestServerAddressError({ address }))); const server = Context.get(context, HttpServer.HttpServer); const address = server.address; if (!Predicate.isTagged(address, "TcpAddress")) { return yield* new OAuthTestServerAddressError({ address }); } return { baseUrl: `http://127.0.0.1:${address.port}` }; }); const requestBodyText = (request: HttpServerRequest.HttpServerRequest): Effect.Effect<string> => request.text.pipe(Effect.catch(() => Effect.succeed(""))); const completeAuthorizationCodeFlow = (defaults: { readonly username: string; readonly password: string }) => (input: { readonly authorizationUrl: string; readonly username?: string; readonly password?: string; }): Effect.Effect<OAuthAuthorizationCompletion, OAuthTestServerFlowError> => Effect.gen(function* () { const loginResponse = yield* executeManualRedirect( HttpClientRequest.get(input.authorizationUrl), input.authorizationUrl, ); const loginUrl = yield* requiredRedirectLocation(loginResponse, input.authorizationUrl); const credentials = Buffer.from( `${input.username ?? defaults.username}:${input.password ?? defaults.password}`, ).toString("base64"); const callbackResponse = yield* executeManualRedirect( HttpClientRequest.post(loginUrl).pipe( HttpClientRequest.setHeader("authorization", `Basic ${credentials}`), ), loginUrl, ); const callbackUrl = yield* requiredRedirectLocation(callbackResponse, loginUrl); const parsed = new URL(callbackUrl); const state = parsed.searchParams.get("state"); const code = parsed.searchParams.get("code"); if (!state || !code) { return yield* new OAuthTestServerFlowError({ message: "OAuth callback did not include both state and code", }); } return { callbackUrl, state, code }; }); const completeAuthorizationCodeTokenFlow = (defaults: { readonly username: string; readonly password: string; readonly clientId: string; readonly clientSecret: string; readonly authorizationEndpoint: string; readonly tokenEndpoint: string; }) => ( input: { readonly username?: string; readonly password?: string; readonly clientId?: string; readonly clientSecret?: string; readonly redirectUrl?: string; readonly scopes?: readonly string[]; readonly resource?: string; } = {}, ): Effect.Effect<OAuthTokenSet, OAuthTestServerFlowError> => Effect.gen(function* () { const clientId = input.clientId ?? defaults.clientId; const clientSecret = input.clientSecret ?? defaults.clientSecret; const redirectUrl = input.redirectUrl ?? "http://127.0.0.1/callback"; const codeVerifier = `verifier_${randomUUID()}`; const state = `state_${randomUUID()}`; const authorizationUrl = new URL(defaults.authorizationEndpoint); authorizationUrl.searchParams.set("response_type", "code"); authorizationUrl.searchParams.set("client_id", clientId); authorizationUrl.searchParams.set("redirect_uri", redirectUrl); authorizationUrl.searchParams.set("state", state); authorizationUrl.searchParams.set("code_challenge", codeChallengeForVerifier(codeVerifier)); authorizationUrl.searchParams.set("code_challenge_method", "S256"); if (input.scopes && input.scopes.length > 0) { authorizationUrl.searchParams.set("scope", input.scopes.join(" ")); } if (input.resource) { authorizationUrl.searchParams.set("resource", input.resource); } const callback = yield* completeAuthorizationCodeFlow({ username: defaults.username, password: defaults.password, })({ authorizationUrl: authorizationUrl.toString(), username: input.username, password: input.password, }); const tokenResponse = yield* executeOAuthHttp( HttpClientRequest.post(defaults.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams({ grant_type: "authorization_code", code: callback.code, redirect_uri: redirectUrl, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier, }), ), defaults.tokenEndpoint, ); if (tokenResponse.status !== 200) { const body = yield* tokenResponse.text.pipe( Effect.catch(() => Effect.succeed("<unavailable>")), ); return yield* new OAuthTestServerFlowError({ message: `Expected token response HTTP 200, got HTTP ${tokenResponse.status}: ${body}`, }); } const raw = yield* tokenResponse.json.pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: "OAuth token response was not valid JSON", cause, }), ), ); const token = yield* decodeTokenResponse(raw).pipe( Effect.mapError( (cause) => new OAuthTestServerFlowError({ message: "OAuth token response did not match the expected shape", cause, }), ), ); return { accessToken: token.access_token, refreshToken: token.refresh_token, tokenType: token.token_type, expiresIn: token.expires_in, scope: token.scope, }; }); /** Parse the `scope` query param from an authorize URL into an ordered list * (empty when the parameter is absent or blank). */ export const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => { const raw = new URL(authorizationUrl).searchParams.get("scope"); return raw == null || raw.length === 0 ? [] : raw.split(" "); }; export const serveOAuthTestServer = ( options: OAuthTestServerOptions = {}, ): Effect.Effect<OAuthTestServerShape, OAuthTestServerAddressError, Scope.Scope> => Effect.gen(function* () { const requests = yield* Ref.make<readonly OAuthTestServerRequest[]>([]); const issuedAccessTokens = yield* Ref.make<ReadonlySet<string>>(new Set()); const users = { [options.defaultUsername ?? "alice"]: options.defaultPassword ?? "password", ...(options.users ?? {}), }; const supportRefresh = options.supportRefresh ?? true; const tokenExpiresInSeconds = options.tokenExpiresInSeconds ?? 3600; const invalidRefreshTokenDescription = options.invalidRefreshTokenDescription ?? "Unknown refresh token"; const invalidRefreshTokenErrorCode = options.invalidRefreshTokenErrorCode ?? "invalid_grant"; const scopes = options.scopes ?? defaultScopes; const omittedTokenResponseScopes = new Set(options.omitTokenResponseScopes ?? []); const tokenResponseScope = (scope: string | null): string | undefined => { if (!scope) return undefined; const filtered = scope .split(/\s+/) .filter((item) => item.length > 0 && !omittedTokenResponseScopes.has(item)); return filtered.length > 0 ? filtered.join(" ") : undefined; }; const clients = new Map<string, ClientRecord>(); const transactions = new Map<string, AuthorizationTransaction>(); const authorizationCodes = new Map<string, AuthorizationCodeRecord>(); const refreshTokens = new Map<string, RefreshTokenRecord>(); const defaultClientId = options.defaultClientId ?? "test-client"; const defaultClientSecret = options.defaultClientSecret ?? "test-secret"; clients.set(defaultClientId, { clientSecret: defaultClientSecret, redirectUris: new Set(), tokenEndpointAuthMethod: "client_secret_post", }); for (const [clientId, clientSecret] of Object.entries(options.clients ?? {})) { clients.set(clientId, { clientSecret, redirectUris: new Set(), tokenEndpointAuthMethod: clientSecret === null ? "none" : "client_secret_post", }); } let issuerUrl = ""; const server = yield* serveOAuthTestHttpApp((request) => Effect.gen(function* () { const currentIssuerUrl = issuerUrl || "http://127.0.0.1"; const requestUrl = new URL(request.url, currentIssuerUrl); const body = yield* requestBodyText(request); const headers = request.headers; yield* Ref.update(requests, (all) => [ ...all, { method: request.method, url: requestUrl.toString(), path: requestUrl.pathname, headers, body, query: Object.fromEntries(requestUrl.searchParams.entries()), }, ]); if (requestUrl.pathname.startsWith("/.well-known/oauth-protected-resource")) { const suffix = requestUrl.pathname.slice("/.well-known/oauth-protected-resource".length); const resource = `${currentIssuerUrl}${suffix}`; return jsonResponse(200, { resource, authorization_servers: [currentIssuerUrl], bearer_methods_supported: ["header"], scopes_supported: scopes, }); } if ( requestUrl.pathname === "/.well-known/oauth-authorization-server" ||