(
input: RegisterDynamicClientInput,
issuer: string | null,
flowRedirectUri: string | null,
)
| 1344 | ); |
| 1345 | |
| 1346 | const decideDcrClientReuse = ( |
| 1347 | input: RegisterDynamicClientInput, |
| 1348 | issuer: string | null, |
| 1349 | flowRedirectUri: string | null, |
| 1350 | ): Effect.Effect< |
| 1351 | { |
| 1352 | readonly existingSlug: OAuthClientSlug | null; |
| 1353 | readonly registrationSlug: OAuthClientSlug; |
| 1354 | }, |
| 1355 | StorageFailure |
| 1356 | > => |
| 1357 | Effect.gen(function* () { |
| 1358 | const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer); |
| 1359 | const resource = input.resource ?? null; |
| 1360 | // A caller-supplied redirect is authoritative: only a client registered |
| 1361 | // with that exact callback can be reused. In particular, a legacy row |
| 1362 | // with no recorded redirect is not proof of a match. When the caller |
| 1363 | // relies on the executor's configured default, retain the legacy-null |
| 1364 | // compatibility behavior so upgrades do not re-register every client. |
| 1365 | const hasExplicitRedirectUri = input.redirectUri != null; |
| 1366 | const redirectMatches = (candidate: DcrReuseCandidate): boolean => { |
| 1367 | if (candidate.redirectUri === flowRedirectUri) return true; |
| 1368 | if (hasExplicitRedirectUri) return false; |
| 1369 | return candidate.redirectUri === null || flowRedirectUri === null; |
| 1370 | }; |
| 1371 | // A fresh registration must never take a slug an existing candidate |
| 1372 | // holds: `createClient` deletes any colliding (owner, slug) row first, |
| 1373 | // which would clobber a client that live connections still refresh |
| 1374 | // through (a redirect-mismatched client stays valid for refresh — the |
| 1375 | // token grant doesn't involve the redirect URI). |
| 1376 | const takenSlugs = new Set(candidates.map((client) => String(client.slug))); |
| 1377 | if (resource !== null) { |
| 1378 | // Prefer a candidate matching resource AND the current redirect across |
| 1379 | // ALL candidates (mirroring the resource-less branch below). Candidates |
| 1380 | // are oldest-first, so after an origin drift the oldest matching- |
| 1381 | // resource row is the STRANDED one — but the first drift recovery |
| 1382 | // already minted a client bound to the CURRENT callback, and later |
| 1383 | // reconnects must reuse that instead of registering another duplicate |
| 1384 | // each time. |
| 1385 | const reusable = candidates.find( |
| 1386 | (client) => client.resource === resource && redirectMatches(client), |
| 1387 | ); |
| 1388 | if (reusable) { |
| 1389 | return { existingSlug: reusable.slug, registrationSlug: reusable.slug }; |
| 1390 | } |
| 1391 | const slug = uniqueDcrSlug( |
| 1392 | dcrClientSlug(issuer, candidates.length > 0 ? resource : null, input.slug), |
| 1393 | takenSlugs, |
| 1394 | ); |
| 1395 | return { |
| 1396 | existingSlug: null, |
| 1397 | registrationSlug: slug, |
| 1398 | }; |
| 1399 | } |
| 1400 | |
| 1401 | // Resource-less request: only reuse a resource-LESS candidate. A client |
| 1402 | // minted for a specific RFC 8707 resource must NOT be reused for a |
| 1403 | // resource-less flow (its tokens are bound to that resource), so when only |
no test coverage detected