| @@ -76,6 +76,10 @@ import { · 75 unmodified lines |
| 76 | beginDynamicAuthorization, | 76 | beginDynamicAuthorization, |
| 77 | discoverAuthorizationServerMetadata, | 77 | discoverAuthorizationServerMetadata, |
| 78 | discoverProtectedResourceMetadata, | 78 | discoverProtectedResourceMetadata, |
| | 79 | type BeginDynamicAuthorizationInput, |
| | 80 | type OAuthAuthorizationServerMetadata, |
| | 81 | type OAuthClientInformation, |
| | 82 | type OAuthProtectedResourceMetadata, |
| 79 | } from "./oauth-discovery"; | 83 | } from "./oauth-discovery"; |
| 80 | import { | 84 | import { |
| 81 | buildAuthorizationUrl, | 85 | buildAuthorizationUrl, |
| @@ -111,6 +115,14 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 29 unmodified lines |
| 111 | resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))), | 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 | const AuthorizationCodeSessionPayload = Schema.Struct({ | 126 | const AuthorizationCodeSessionPayload = Schema.Struct({ |
| 115 | kind: Schema.Literal("authorization-code"), | 127 | kind: Schema.Literal("authorization-code"), |
| 116 | identityLabel: Schema.NullOr(Schema.String), | 128 | identityLabel: Schema.NullOr(Schema.String), |
| @@ -141,14 +153,17 @@ const OAuthSessionPayload = Schema.Union([ · 24 unmodified lines |
| 141 | AuthorizationCodeSessionPayload, | 153 | AuthorizationCodeSessionPayload, |
| 142 | ]); | 154 | ]); |
| 143 | type OAuthSessionPayload = typeof OAuthSessionPayload.Type; | 155 | type OAuthSessionPayload = typeof OAuthSessionPayload.Type; |
| | 156 | type PreviousDynamicAuthorizationState = BeginDynamicAuthorizationInput["previousState"]; |
| 144 | | 157 | |
| 145 | const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload); | 158 | const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload); |
| 146 | const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload); | 159 | const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload); |
| | 160 | const isPendingDynamicDcrSessionRows = Schema.is(PendingDynamicDcrSessionRows); |
| 147 | | 161 | |
| 148 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); | 162 | const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); |
| 149 | const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString); | 163 | const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString); |
| 150 | | 164 | |
| 151 | const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema); | 165 | const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema); |
| | 166 | const decodeProviderStateOption = Schema.decodeUnknownOption(OAuthProviderStateSchema); |
| 152 | const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema); | 167 | const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema); |
| 153 | | 168 | |
| 154 | const coerceJson = (value: unknown): unknown => { | 169 | const coerceJson = (value: unknown): unknown => { |
| @@ -187,6 +202,10 @@ export interface OAuthServiceDeps { · 32 unmodified lines |
| 187 | readonly connectionsCreate: ( | 202 | readonly connectionsCreate: ( |
| 188 | input: CreateConnectionInput, | 203 | input: CreateConnectionInput, |
| 189 | ) => Effect.Effect<ConnectionRef, ConnectionProviderNotRegisteredError | StorageFailure>; | 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 | /** Random session id generator. Tests override to make outputs | 209 | /** Random session id generator. Tests override to make outputs |
| 191 | * deterministic. */ | 210 | * deterministic. */ |
| 192 | readonly newSessionId?: () => string; | 211 | readonly newSessionId?: () => string; |
| @@ -239,6 +258,7 @@ export const makeOAuth2Service = ( · 46 unmodified lines |
| 239 | const newSessionId = deps.newSessionId ?? defaultSessionId; | 258 | const newSessionId = deps.newSessionId ?? defaultSessionId; |
| 240 | const httpClientLayer = deps.httpClientLayer; | 259 | const httpClientLayer = deps.httpClientLayer; |
| 241 | const endpointUrlPolicy = deps.endpointUrlPolicy; | 260 | const endpointUrlPolicy = deps.endpointUrlPolicy; |
| | 261 | const connectionsGet = deps.connectionsGet ?? (() => Effect.succeed(null)); |
| 242 | const secretsGetResolved = | 262 | const secretsGetResolved = |
| 243 | deps.secretsGetResolved ?? | 263 | deps.secretsGetResolved ?? |
| 244 | ((id: string) => | 264 | ((id: string) => |
| @@ -372,17 +392,119 @@ export const makeOAuth2Service = ( · 127 unmodified lines |
| 372 | // ------------------------------------------------------------------- | 392 | // ------------------------------------------------------------------- |
| 373 | // start — branches on strategy.kind | 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 | const startDynamicDcr = ( | 492 | const startDynamicDcr = ( |
| 376 | input: OAuthStartInput, | 493 | input: OAuthStartInput, |
| 377 | strategy: OAuthDynamicDcrStrategy, | 494 | strategy: OAuthDynamicDcrStrategy, |
| 378 | ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> => | 495 | ): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> => |
| 379 | Effect.gen(function* () { | 496 | Effect.gen(function* () { |
| | 497 | const previousState = yield* previousDynamicState({ |
| | 498 | connectionId: input.connectionId, |
| | 499 | tokenScope: input.tokenScope, |
| | 500 | }); |
| 380 | const started = yield* beginDynamicAuthorization( | 501 | const started = yield* beginDynamicAuthorization( |
| 381 | { | 502 | { |
| 382 | endpoint: input.endpoint, | 503 | endpoint: input.endpoint, |
| 383 | redirectUrl: input.redirectUrl, | 504 | redirectUrl: input.redirectUrl, |
| 384 | state: "", | 505 | state: "", |
| 385 | scopes: strategy.scopes, | 506 | scopes: strategy.scopes, |
| | 507 | previousState, |
| 386 | }, | 508 | }, |
| 387 | { | 509 | { |
| 388 | httpClientLayer, | 510 | httpClientLayer, |