| @@ -76,6 +76,10 @@ import { · 75 unmodified lines |
| 76 | 76 | beginDynamicAuthorization, |
| 77 | 77 | discoverAuthorizationServerMetadata, |
| 78 | 78 | discoverProtectedResourceMetadata, |
| 79 | + type BeginDynamicAuthorizationInput, |
| 80 | + type OAuthAuthorizationServerMetadata, |
| 81 | + type OAuthClientInformation, |
| 82 | + type OAuthProtectedResourceMetadata, |
| 79 | 83 | } from "./oauth-discovery"; |
| 80 | 84 | import { |
| 81 | 85 | buildAuthorizationUrl, |
| @@ -111,6 +115,14 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 29 unmodified lines |
| 111 | 115 | resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))), |
| 112 | 116 | }); |
| 113 | 117 | |
| 118 | +const PendingDynamicDcrSessionRows = Schema.Array( |
| 119 | + Schema.Struct({ |
| 120 | + payload: Schema.Unknown, |
| 121 | + expires_at: Schema.Union([Schema.Number, Schema.BigInt, Schema.String]), |
| 122 | + created_at: Schema.Union([Schema.Date, Schema.String, Schema.Number]), |
| 123 | + }), |
| 124 | +); |
| 125 | + |
| 114 | 126 | const AuthorizationCodeSessionPayload = Schema.Struct({ |
| 115 | 127 | kind: Schema.Literal("authorization-code"), |
| 116 | 128 | identityLabel: Schema.NullOr(Schema.String), |
| @@ -141,14 +153,17 @@ const OAuthSessionPayload = Schema.Union([ · 24 unmodified lines |
| 141 | 153 | AuthorizationCodeSessionPayload, |
| 142 | 154 | ]); |
| 143 | 155 | type OAuthSessionPayload = typeof OAuthSessionPayload.Type; |
| 156 | +type PreviousDynamicAuthorizationState = BeginDynamicAuthorizationInput["previousState"]; |
| 144 | 157 | |
| 145 | 158 | const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload); |
| 146 | 159 | const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload); |
| 160 | +const isPendingDynamicDcrSessionRows = Schema.is(PendingDynamicDcrSessionRows); |
| 147 | 161 | |
| 148 | 162 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); |
| 149 | 163 | const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString); |
| 150 | 164 | |
| 151 | 165 | const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema); |
| 166 | +const decodeProviderStateOption = Schema.decodeUnknownOption(OAuthProviderStateSchema); |
| 152 | 167 | const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema); |
| 153 | 168 | |
| 154 | 169 | const coerceJson = (value: unknown): unknown => { |
| @@ -187,6 +202,10 @@ export interface OAuthServiceDeps { · 32 unmodified lines |
| 187 | 202 | readonly connectionsCreate: ( |
| 188 | 203 | input: CreateConnectionInput, |
| 189 | 204 | ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>; |
| 205 | + /** Reads an existing Connection so dynamic-DCR retries can reuse the |
| 206 | + * registered OAuth client instead of registering a new client every |
| 207 | + * time the user restarts a browser flow. */ |
| 208 | + readonly connectionsGet?: (id: string) => Effect.Effect<ConnectionRef | null, StorageFailure>; |
| 190 | 209 | /** Random session id generator. Tests override to make outputs |
| 191 | 210 | * deterministic. */ |
| 192 | 211 | readonly newSessionId?: () => string; |
| @@ -239,6 +258,7 @@ export const makeOAuth2Service = ( · 46 unmodified lines |
| 239 | 258 | const newSessionId = deps.newSessionId ?? defaultSessionId; |
| 240 | 259 | const httpClientLayer = deps.httpClientLayer; |
| 241 | 260 | const endpointUrlPolicy = deps.endpointUrlPolicy; |
| 261 | + const connectionsGet = deps.connectionsGet ?? (() => Effect.succeed(null)); |
| 242 | 262 | const secretsGetResolved = |
| 243 | 263 | deps.secretsGetResolved ?? |
| 244 | 264 | ((id: string) => |
| @@ -372,17 +392,119 @@ export const makeOAuth2Service = ( · 127 unmodified lines |
| 372 | 392 | // ------------------------------------------------------------------- |
| 373 | 393 | // start — branches on strategy.kind |
| 374 | 394 | // ------------------------------------------------------------------- |
| 395 | + |
| 396 | + const dynamicClientAuthMethod = ( |
| 397 | + state: Extract<OAuthProviderState, { kind: "dynamic-dcr" }>, |
| 398 | + ): "none" | "client_secret_basic" | "client_secret_post" => |
| 399 | + state.clientSecretSecretId |
| 400 | + ? state.clientAuth === "basic" |
| 401 | + ? "client_secret_basic" |
| 402 | + : "client_secret_post" |
| 403 | + : "none"; |
| 404 | + |
| 405 | + const timestampMillis = (value: unknown): number => { |
| 406 | + if (value instanceof Date) return value.getTime(); |
| 407 | + if (typeof value === "string" || typeof value === "number") return new Date(value).getTime(); |
| 408 | + return 0; |
| 409 | + }; |
| 410 | + |
| 411 | + const previousDynamicStateFromConnection = ( |
| 412 | + connectionId: string, |
| 413 | + ): Effect.Effect<PreviousDynamicAuthorizationState | undefined, StorageFailure> => |
| 414 | + Effect.gen(function* () { |
| 415 | + const existing = yield* connectionsGet(connectionId); |
| 416 | + const state = existing?.providerState |
| 417 | + ? Option.getOrNull(decodeProviderStateOption(coerceJson(existing.providerState))) |
| 418 | + : null; |
| 419 | + if (!state || state.kind !== "dynamic-dcr") return undefined; |
| 420 | + |
| 421 | + const clientSecret = |
| 422 | + state.clientSecretSecretId !== null |
| 423 | + ? yield* getSecretFromRecordedScope({ |
| 424 | + secretId: state.clientSecretSecretId, |
| 425 | + scopeId: state.clientSecretSecretScopeId ?? null, |
| 426 | + }) |
| 427 | + : null; |
| 428 | + if (state.clientSecretSecretId !== null && !clientSecret) return undefined; |
| 429 | + |
| 430 | + return { |
| 431 | + authorizationServerUrl: state.authorizationServerUrl ?? null, |
| 432 | + authorizationServerMetadataUrl: state.authorizationServerMetadataUrl, |
| 433 | + clientInformation: { |
| 434 | + client_id: state.clientId, |
| 435 | + token_endpoint_auth_method: dynamicClientAuthMethod(state), |
| 436 | + ...(clientSecret ? { client_secret: clientSecret } : {}), |
| 437 | + }, |
| 438 | + }; |
| 439 | + }); |
| 440 | + |
| 441 | + const previousDynamicStateFromPendingSession = (input: { |
| 442 | + readonly connectionId: string; |
| 443 | + readonly tokenScope: string; |
| 444 | + }): Effect.Effect<PreviousDynamicAuthorizationState | undefined, StorageFailure> => |
| 445 | + Effect.gen(function* () { |
| 446 | + const rowsRaw = yield* deps.fuma.use("oauth2_session.findReusableDynamicDcr", (db) => |
| 447 | + db.findMany("oauth2_session", { |
| 448 | + where: (b) => |
| 449 | + b.and( |
| 450 | + b("connection_id", "=", input.connectionId), |
| 451 | + b("token_scope", "=", input.tokenScope), |
| 452 | + b("strategy", "=", "dynamic-dcr"), |
| 453 | + ), |
| 454 | + }), |
| 455 | + ); |
| 456 | + const rows = isPendingDynamicDcrSessionRows(rowsRaw) ? rowsRaw : []; |
| 457 | + |
| 458 | + const reusable = rows |
| 459 | + .filter((row) => Number(row.expires_at) > now()) |
| 460 | + .sort((a, b) => { |
| 461 | + const aTime = timestampMillis(a.created_at); |
| 462 | + const bTime = timestampMillis(b.created_at); |
| 463 | + return bTime - aTime; |
| 464 | + }); |
| 465 | + |
| 466 | + for (const row of reusable) { |
| 467 | + const payload = decodeSessionPayload(row.payload); |
| 468 | + if (payload.kind !== "dynamic-dcr") continue; |
| 469 | + return { |
| 470 | + authorizationServerUrl: payload.authorizationServerUrl, |
| 471 | + authorizationServerMetadataUrl: payload.authorizationServerMetadataUrl, |
| 472 | + authorizationServerMetadata: |
| 473 | + payload.authorizationServerMetadata as OAuthAuthorizationServerMetadata, |
| 474 | + resourceMetadata: payload.resourceMetadata as OAuthProtectedResourceMetadata | null, |
| 475 | + resourceMetadataUrl: payload.resourceMetadataUrl, |
| 476 | + clientInformation: payload.clientInformation as OAuthClientInformation, |
| 477 | + }; |
| 478 | + } |
| 479 | + return undefined; |
| 480 | + }); |
| 481 | + |
| 482 | + const previousDynamicState = (input: { |
| 483 | + readonly connectionId: string; |
| 484 | + readonly tokenScope: string; |
| 485 | + }) => |
| 486 | + previousDynamicStateFromPendingSession(input).pipe( |
| 487 | + Effect.flatMap((pending) => |
| 488 | + pending ? Effect.succeed(pending) : previousDynamicStateFromConnection(input.connectionId), |
| 489 | + ), |
| 490 | + ); |
| 491 | + |
| 375 | 492 | const startDynamicDcr = ( |
| 376 | 493 | input: OAuthStartInput, |
| 377 | 494 | strategy: OAuthDynamicDcrStrategy, |
| 378 | 495 | ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> => |
| 379 | 496 | Effect.gen(function* () { |
| 497 | + const previousState = yield* previousDynamicState({ |
| 498 | + connectionId: input.connectionId, |
| 499 | + tokenScope: input.tokenScope, |
| 500 | + }); |
| 380 | 501 | const started = yield* beginDynamicAuthorization( |
| 381 | 502 | { |
| 382 | 503 | endpoint: input.endpoint, |
| 383 | 504 | redirectUrl: input.redirectUrl, |
| 384 | 505 | state: "", |
| 385 | 506 | scopes: strategy.scopes, |
| 507 | + previousState, |
| 386 | 508 | }, |
| 387 | 509 | { |
| 388 | 510 | httpClientLayer, |