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"; import { ID_JAG_GRANT_PROFILE_URN, ID_JAG_TOKEN_TYPE_URN, JWT_BEARER_GRANT_TYPE_URN, TOKEN_EXCHANGE_GRANT_TYPE_URN, createIdJagSigningKey, jwksDocumentFor, signIdJag, verifyIdJag,} from "./id-jag-test-support"; 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; /** Answer a rejected refresh-token grant with this EXACT response instead of * the conform §5.2 JSON envelope, byte for byte. Production token endpoints * refuse dead grants in shapes the spec never describes — a `text/plain` 400 * ("your session has expired"), a `text/plain` 404, or a GitHub-style HTTP * 200 whose body carries the error — and executor has to read all of them as * the same definitive refusal. Takes precedence over * `invalidRefreshTokenErrorCode` / `invalidRefreshTokenDescription`. */ readonly refreshRejection?: { readonly status: number; /** Defaults to `text/plain; charset=utf-8`. */ readonly contentType?: string; readonly body: 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; /** Act as an enterprise IdP Authorization Server for the Identity Assertion * JWT Authorization Grant: accept RFC 8693 token exchanges, mint signed * ID-JAGs, and publish the JWKS that verifies them. */ readonly enterpriseIdp?: EnterpriseIdpOptions; /** Act as a Resource Authorization Server for the same profile: advertise it * in RFC 8414 metadata and redeem ID-JAGs presented as RFC 7523 assertions. */ readonly enterpriseResourceServer?: EnterpriseResourceServerOptions; /** Advertise OAuth Client ID Metadata Document support alongside DCR. */ readonly clientIdMetadataDocumentSupported?: boolean;} export interface EnterpriseIdpOptions { /** Refuse every exchange with this RFC 6749 §5.2 error, standing in for an * administrator policy that does not permit this client/user/target. */ readonly denyExchangeWith?: { readonly error: string; readonly errorDescription: string }; /** Lifetime stamped on the minted ID-JAG. May be zero or negative so a test * can present an already-expired assertion. Default 300 (draft §4.3.4). */ readonly idJagExpiresInSeconds?: number; /** JWT header `typ` to stamp. Default `oauth-id-jag+jwt`; override to prove a * Resource Authorization Server rejects a wrongly-typed assertion. */ readonly assertionTyp?: string; /** Scope the IdP actually grants, standing in for policy narrowing (§4.3.3). * Omitted means "exactly what was requested". */ readonly grantScope?: (requested: string | null) => string | null; /** draft §5: the IdP knows which `client_id` the Resource Authorization * Server registered for this client, which need not be the id the client * authenticates to the IdP with. Maps IdP client id to resource client id; * an unmapped client keeps its own id. */ readonly resourceClientIds?: Readonly<Record<string, string>>;} export interface EnterpriseResourceServerOptions { /** The enterprise IdP whose JWKS signs assertions this server will accept. * Resolved through the IdP's own RFC 8414 metadata, as a real server would. */ readonly trustedIdpIssuer: string; /** Scope this server is willing to grant. An assertion carrying more is * narrowed to the intersection (draft §4.4.1). Omitted grants what the * assertion carries. */ readonly grantableScopes?: readonly string[];} 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>; /** Stop honouring a token this server issued, without re-issuing anything. * Drives the "the stored identity assertion died" tier: an enterprise IdP * fixture rejects the exchange afterwards exactly as it would for an expired * or revoked assertion. */ readonly revokeAccessToken: (token: string) => Effect.Effect<void>; /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error. * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives * the case that only exists over time — an administrator withdrawing access * AFTER a connection was made, which the credential-refresh path meets. */ readonly setTokenExchangeDenial: ( denial: { readonly error: string; readonly errorDescription: string } | null, ) => Effect.Effect<void>; 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, }; }); /** RFC 8693 §3 subject token types this fixture accepts on a token exchange. * Anything else is `invalid_request`, so a client that invents a type finds * out here rather than against a lenient deployment. */const SUPPORTED_SUBJECT_TOKEN_TYPES = new Set([ "urn:ietf:params:oauth:token-type:id_token", "urn:ietf:params:oauth:token-type:saml2", "urn:ietf:params:oauth:token-type:refresh_token", "urn:ietf:params:oauth:token-type:access_token",]);Lines 1–500 of 1111. 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"; import { ID_JAG_GRANT_PROFILE_URN, ID_JAG_TOKEN_TYPE_URN, JWT_BEARER_GRANT_TYPE_URN, TOKEN_EXCHANGE_GRANT_TYPE_URN, createIdJagSigningKey, jwksDocumentFor, signIdJag, verifyIdJag, } from "./id-jag-test-support"; 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; /** Answer a rejected refresh-token grant with this EXACT response instead of * the conform §5.2 JSON envelope, byte for byte. Production token endpoints * refuse dead grants in shapes the spec never describes — a `text/plain` 400 * ("your session has expired"), a `text/plain` 404, or a GitHub-style HTTP * 200 whose body carries the error — and executor has to read all of them as * the same definitive refusal. Takes precedence over * `invalidRefreshTokenErrorCode` / `invalidRefreshTokenDescription`. */ readonly refreshRejection?: { readonly status: number; /** Defaults to `text/plain; charset=utf-8`. */ readonly contentType?: string; readonly body: 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; /** Act as an enterprise IdP Authorization Server for the Identity Assertion * JWT Authorization Grant: accept RFC 8693 token exchanges, mint signed * ID-JAGs, and publish the JWKS that verifies them. */ readonly enterpriseIdp?: EnterpriseIdpOptions; /** Act as a Resource Authorization Server for the same profile: advertise it * in RFC 8414 metadata and redeem ID-JAGs presented as RFC 7523 assertions. */ readonly enterpriseResourceServer?: EnterpriseResourceServerOptions; /** Advertise OAuth Client ID Metadata Document support alongside DCR. */ readonly clientIdMetadataDocumentSupported?: boolean; } export interface EnterpriseIdpOptions { /** Refuse every exchange with this RFC 6749 §5.2 error, standing in for an * administrator policy that does not permit this client/user/target. */ readonly denyExchangeWith?: { readonly error: string; readonly errorDescription: string }; /** Lifetime stamped on the minted ID-JAG. May be zero or negative so a test * can present an already-expired assertion. Default 300 (draft §4.3.4). */ readonly idJagExpiresInSeconds?: number; /** JWT header `typ` to stamp. Default `oauth-id-jag+jwt`; override to prove a * Resource Authorization Server rejects a wrongly-typed assertion. */ readonly assertionTyp?: string; /** Scope the IdP actually grants, standing in for policy narrowing (§4.3.3). * Omitted means "exactly what was requested". */ readonly grantScope?: (requested: string | null) => string | null; /** draft §5: the IdP knows which `client_id` the Resource Authorization * Server registered for this client, which need not be the id the client * authenticates to the IdP with. Maps IdP client id to resource client id; * an unmapped client keeps its own id. */ readonly resourceClientIds?: Readonly<Record<string, string>>; } export interface EnterpriseResourceServerOptions { /** The enterprise IdP whose JWKS signs assertions this server will accept. * Resolved through the IdP's own RFC 8414 metadata, as a real server would. */ readonly trustedIdpIssuer: string; /** Scope this server is willing to grant. An assertion carrying more is * narrowed to the intersection (draft §4.4.1). Omitted grants what the * assertion carries. */ readonly grantableScopes?: readonly string[]; } 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>; /** Stop honouring a token this server issued, without re-issuing anything. * Drives the "the stored identity assertion died" tier: an enterprise IdP * fixture rejects the exchange afterwards exactly as it would for an expired * or revoked assertion. */ readonly revokeAccessToken: (token: string) => Effect.Effect<void>; /** Start (or stop) refusing every RFC 8693 exchange with this §5.2 error. * `enterpriseIdp.denyExchangeWith` sets the same policy up front; this drives * the case that only exists over time — an administrator withdrawing access * AFTER a connection was made, which the credential-refresh path meets. */ readonly setTokenExchangeDenial: ( denial: { readonly error: string; readonly errorDescription: string } | null, ) => Effect.Effect<void>; 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, }; }); /** RFC 8693 §3 subject token types this fixture accepts on a token exchange. * Anything else is `invalid_request`, so a client that invents a type finds * out here rather than against a lenient deployment. */ const SUPPORTED_SUBJECT_TOKEN_TYPES = new Set([ "urn:ietf:params:oauth:token-type:id_token", "urn:ietf:params:oauth:token-type:saml2", "urn:ietf:params:oauth:token-type:refresh_token", "urn:ietf:params:oauth:token-type:access_token", ]);