@@ -76,6 +76,10 @@ import {
7676 beginDynamicAuthorization ,
7777 discoverAuthorizationServerMetadata ,
7878 discoverProtectedResourceMetadata ,
79+ type BeginDynamicAuthorizationInput ,
80+ type OAuthAuthorizationServerMetadata ,
81+ type OAuthClientInformation ,
82+ type OAuthProtectedResourceMetadata ,
7983} from "./oauth-discovery" ;
8084import {
8185 buildAuthorizationUrl ,
@@ -111,6 +115,14 @@ const DynamicDcrSessionPayload = Schema.Struct({
111115 resource : Schema . NullOr ( Schema . String ) . pipe ( Schema . withDecodingDefaultType ( Effect . succeed ( null ) ) ) ,
112116} ) ;
113117
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+
114126const AuthorizationCodeSessionPayload = Schema . Struct ( {
115127 kind : Schema . Literal ( "authorization-code" ) ,
116128 identityLabel : Schema . NullOr ( Schema . String ) ,
@@ -141,14 +153,17 @@ const OAuthSessionPayload = Schema.Union([
141153 AuthorizationCodeSessionPayload ,
142154] ) ;
143155type OAuthSessionPayload = typeof OAuthSessionPayload . Type ;
156+ type PreviousDynamicAuthorizationState = BeginDynamicAuthorizationInput [ "previousState" ] ;
144157
145158const decodeSessionPayload = Schema . decodeUnknownSync ( OAuthSessionPayload ) ;
146159const encodeSessionPayload = Schema . encodeSync ( OAuthSessionPayload ) ;
160+ const isPendingDynamicDcrSessionRows = Schema . is ( PendingDynamicDcrSessionRows ) ;
147161
148162const UnknownFromJsonString = Schema . fromJsonString ( Schema . Unknown ) ;
149163const decodeUnknownJsonOption = Schema . decodeUnknownOption ( UnknownFromJsonString ) ;
150164
151165const decodeProviderStateSync = Schema . decodeUnknownSync ( OAuthProviderStateSchema ) ;
166+ const decodeProviderStateOption = Schema . decodeUnknownOption ( OAuthProviderStateSchema ) ;
152167const encodeProviderStateSync = Schema . encodeSync ( OAuthProviderStateSchema ) ;
153168
154169const coerceJson = ( value : unknown ) : unknown => {
@@ -187,6 +202,10 @@ export interface OAuthServiceDeps {
187202 readonly connectionsCreate : (
188203 input : CreateConnectionInput ,
189204 ) => 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 > ;
190209 /** Random session id generator. Tests override to make outputs
191210 * deterministic. */
192211 readonly newSessionId ?: ( ) => string ;
@@ -239,6 +258,7 @@ export const makeOAuth2Service = (
239258 const newSessionId = deps . newSessionId ?? defaultSessionId ;
240259 const httpClientLayer = deps . httpClientLayer ;
241260 const endpointUrlPolicy = deps . endpointUrlPolicy ;
261+ const connectionsGet = deps . connectionsGet ?? ( ( ) => Effect . succeed ( null ) ) ;
242262 const secretsGetResolved =
243263 deps . secretsGetResolved ??
244264 ( ( id : string ) =>
@@ -372,17 +392,119 @@ export const makeOAuth2Service = (
372392 // -------------------------------------------------------------------
373393 // start — branches on strategy.kind
374394 // -------------------------------------------------------------------
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+
375492 const startDynamicDcr = (
376493 input : OAuthStartInput ,
377494 strategy : OAuthDynamicDcrStrategy ,
378495 ) : Effect . Effect < OAuthStartResult , OAuthStartError | StorageFailure > =>
379496 Effect . gen ( function * ( ) {
497+ const previousState = yield * previousDynamicState ( {
498+ connectionId : input . connectionId ,
499+ tokenScope : input . tokenScope ,
500+ } ) ;
380501 const started = yield * beginDynamicAuthorization (
381502 {
382503 endpoint : input . endpoint ,
383504 redirectUrl : input . redirectUrl ,
384505 state : "" ,
385506 scopes : strategy . scopes ,
507+ previousState,
386508 } ,
387509 {
388510 httpClientLayer,
0 commit comments