@starci/auth 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +104 -13
  3. package/dist/client/oidc/index.d.ts +5 -0
  4. package/dist/client/oidc/index.js +3 -0
  5. package/dist/client/oidc/oidc-client.d.ts +85 -0
  6. package/dist/client/oidc/oidc-client.js +304 -0
  7. package/dist/client/oidc/pkce.d.ts +15 -0
  8. package/dist/client/oidc/pkce.js +29 -0
  9. package/dist/contracts/index.d.ts +12 -0
  10. package/dist/contracts/index.js +14 -0
  11. package/dist/contracts/oidc.d.ts +148 -0
  12. package/dist/contracts/oidc.js +68 -0
  13. package/dist/oidc/admin.d.ts +42 -0
  14. package/dist/oidc/admin.js +139 -0
  15. package/dist/oidc/broker.d.ts +57 -0
  16. package/dist/oidc/broker.js +77 -0
  17. package/dist/oidc/client.d.ts +58 -0
  18. package/dist/oidc/client.js +119 -0
  19. package/dist/oidc/discovery.d.ts +23 -0
  20. package/dist/oidc/discovery.js +64 -0
  21. package/dist/oidc/errors.d.ts +10 -0
  22. package/dist/oidc/errors.js +10 -0
  23. package/dist/oidc/grants.d.ts +41 -0
  24. package/dist/oidc/grants.js +82 -0
  25. package/dist/oidc/index.d.ts +35 -0
  26. package/dist/oidc/index.js +18 -0
  27. package/dist/oidc/issuers/github-actions.d.ts +54 -0
  28. package/dist/oidc/issuers/github-actions.js +84 -0
  29. package/dist/oidc/issuers/google.d.ts +30 -0
  30. package/dist/oidc/issuers/google.js +39 -0
  31. package/dist/oidc/issuers/index.d.ts +8 -0
  32. package/dist/oidc/issuers/index.js +4 -0
  33. package/dist/oidc/issuers/issuer.d.ts +79 -0
  34. package/dist/oidc/issuers/issuer.js +39 -0
  35. package/dist/oidc/issuers/keycloak.d.ts +26 -0
  36. package/dist/oidc/issuers/keycloak.js +41 -0
  37. package/dist/oidc/jwks.d.ts +26 -0
  38. package/dist/oidc/jwks.js +80 -0
  39. package/dist/oidc/oidc.d.ts +32 -0
  40. package/dist/oidc/oidc.js +21 -0
  41. package/dist/oidc/oidc.module.d.ts +34 -0
  42. package/dist/oidc/oidc.module.js +51 -0
  43. package/dist/oidc/oidc.tokens.d.ts +18 -0
  44. package/dist/oidc/oidc.tokens.js +18 -0
  45. package/dist/oidc/options.d.ts +119 -0
  46. package/dist/oidc/options.js +98 -0
  47. package/dist/oidc/primitives.d.ts +15 -0
  48. package/dist/oidc/primitives.js +11 -0
  49. package/dist/oidc/refresh.d.ts +10 -0
  50. package/dist/oidc/refresh.js +24 -0
  51. package/dist/oidc/roles.d.ts +12 -0
  52. package/dist/oidc/roles.js +51 -0
  53. package/dist/oidc/tokens.d.ts +26 -0
  54. package/dist/oidc/tokens.js +79 -0
  55. package/dist/oidc/transport.d.ts +8 -0
  56. package/dist/oidc/transport.js +19 -0
  57. package/dist/oidc/verifier.d.ts +24 -0
  58. package/dist/oidc/verifier.js +64 -0
  59. package/dist/server/auth.module.d.ts +1 -1
  60. package/dist/server/auth.module.js +20 -6
  61. package/dist/server/auth.tokens.d.ts +2 -0
  62. package/dist/server/auth.tokens.js +2 -0
  63. package/dist/server/doors.d.ts +14 -8
  64. package/dist/server/doors.js +22 -11
  65. package/dist/server/errors.d.ts +2 -2
  66. package/dist/server/errors.js +3 -5
  67. package/dist/server/flows.d.ts +13 -5
  68. package/dist/server/flows.js +40 -20
  69. package/dist/server/index.d.ts +7 -7
  70. package/dist/server/index.js +3 -4
  71. package/dist/server/options.d.ts +11 -13
  72. package/dist/server/ports.d.ts +8 -1
  73. package/dist/server/primitives.d.ts +0 -2
  74. package/dist/server/primitives.js +2 -3
  75. package/dist/server/providers/github.d.ts +59 -0
  76. package/dist/server/providers/github.js +111 -0
  77. package/dist/server/providers/google.d.ts +56 -0
  78. package/dist/server/providers/google.js +87 -0
  79. package/dist/server/providers/index.d.ts +14 -0
  80. package/dist/server/providers/index.js +9 -0
  81. package/dist/server/providers/oauth-state.d.ts +47 -0
  82. package/dist/server/providers/oauth-state.js +74 -0
  83. package/dist/server/providers/provider.d.ts +58 -0
  84. package/dist/server/providers/provider.js +32 -0
  85. package/dist/server/providers/registry.d.ts +30 -0
  86. package/dist/server/providers/registry.js +37 -0
  87. package/dist/server/strategies.d.ts +3 -18
  88. package/dist/server/strategies.js +2 -41
  89. package/dist/server/verifier.d.ts +1 -1
  90. package/dist/testing/index.d.ts +3 -0
  91. package/dist/testing/index.js +2 -0
  92. package/dist/testing/oidc.d.ts +175 -0
  93. package/dist/testing/oidc.js +493 -0
  94. package/package.json +24 -5
  95. package/dist/server/google-state.d.ts +0 -33
  96. package/dist/server/google-state.js +0 -73
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 - 2026-11-09
4
+
5
+ One auth package for both editions: the OIDC sign-in of `@starci/auth-oidc` moves in, and the lite sign-in providers become pluggable. Every 0.5.0 export, option, door, cookie and code is unchanged.
6
+
7
+ - Added: `@starci/auth/oidc`, everything `@starci/auth-oidc/server` held (discovery, JWKS, the token verifier, `OidcPrincipalVerifier`, roles, refresh, `OidcClient`, `OidcTokenClient`, `OidcAdminClient`, `OidcBroker`, `OidcModule`, `createOidc`) and the OIDC contract; the injection tokens keep their `Symbol.for("@starci/auth-oidc/...")` names. `@starci/auth/client/oidc` is `createOidcClient`; `@starci/auth/contracts` also carries the OIDC contract; `@starci/auth/testing` also carries `FakeOidcIssuer` (one `FakeClock` for both), which gains `endpoints()`.
8
+ - Added: issuer presets in `@starci/auth/oidc`: `IssuerPreset` (issuer and aliases, pinned algorithms, a required audience, a pinned key set or discovery, required claims, `identityOf`) and `createIssuerVerifier`; `keycloakIssuer` (and `keycloakOptions`, the trust half of an `OidcModule` registration), `googleIssuer` (Google ID tokens, both spellings of `accounts.google.com`, Google's key set, a verified address, optionally a Workspace domain) and `githubActionsIssuer` (GitHub Actions OIDC: `https://token.actions.githubusercontent.com`, a required audience, the repository by name and by `sub`, optionally its id, the workflow files of `workflow_ref` and the refs), the CI attestation port of a full app. A refusal by a preset's policy is reason `policy` with the `claim` (a new `OidcRefusalReason`). `TokenExpectations.issuer` admits an issuer's aliases; `OidcKeys` takes any `OidcKeySource`; `principalOf` is the principal mapping the verifier and the Keycloak preset share.
9
+ - Added: pluggable sign-in providers in `@starci/auth/server`: `SignInProvider` and `defineProvider` (id, options, state cookie, consent parameters, codes, `profileOf`, the passport strategy), `AuthOptions.providers`, `google(options)` and `github(options)` (passport-oauth2 against GitHub's OAuth app endpoints with `state` and PKCE, `read:user user:email`, the numeric account id as subject and the primary verified address), `OAuthStateClient` (the state store of 0.5.0 generalised to any provider: cookie `auth_<id>_state` on `/auth/<id>`, purpose `p: <id>`), `providersOf`, `buildProviders` and the token `AUTH_PROVIDERS`. `AuthDoors.beginProvider(id)` and `completeProvider(id)`, `AuthFlows.begin(id)` and `complete(id)`; `providerDoor`, `providerCallbackDoor` and `ProviderRedirect` in the contracts; `AuthAuthenticator.signInWithProvider` (optional, needed for a provider other than Google) with `ProviderProfile`; the codes `PASSPORT_PROVIDER_STATE_INVALID`, `PASSPORT_PROVIDER_REJECTED`, `PASSPORT_PROVIDER_UNAVAILABLE` and `PASSPORT_PROVIDERS_INVALID` (Google named both as the option and in the list, two providers with one id, an id that cannot be a door path).
10
+ - Changed: `AuthOptions.google` may be absent (null and absent both mean no Google client). The Google round trip is the `google` provider: the same doors, the same `auth_google_state` cookie on `/auth/google` with the same signed value, the same `PASSPORT_GOOGLE_*` codes and the same `auth.google-sign-in-failed` event; `GoogleStateClient`, `GOOGLE_STATE_*`, `DEFAULT_GOOGLE_STATE_COOKIE`, `googleProfileOf` and `GoogleOptions` keep their names (now from `server/providers/google.ts`; `server/google-state.ts` is gone). A lite app's `AuthModule.register` needs no change and the migrations are the same files.
11
+ - Changed: `jose` (6.2.12) serves both editions from this package; new dependency `passport-oauth2` 1.8.0 (already installed under `passport-google-oauth20`), dev dependency `@types/passport-oauth2` 1.8.0. The build stays ES modules with `.js` declarations, as both packages built.
12
+ - Specs: every spec of `@starci/auth-oidc` passes in its new place (`src/oidc`, `src/client/oidc`, `src/testing/oidc.spec.ts`, `src/oidc/compat.spec.ts`); `src/server/providers/registry.spec.ts` (Google, GitHub and a stub third provider defined in one call and run end to end through the doors; the order, the defaults, the refusals), `github.spec.ts` (the profile), `oauth-state.spec.ts` (the Google cookie byte for byte as 0.5.0 wrote it; one provider's state never passes another's callback), `src/oidc/issuers/issuers.spec.ts` (each preset against the fake issuer; GitHub Actions: a valid run, another audience, another repository, an expired token, the pins). `npm run typecheck` also checks the OIDC tree with `--isolatedDeclarations` (`tsconfig.isolated.json`), as `@starci/auth-oidc` did.
13
+
14
+ ## 0.5.0 - 2026-11-08
15
+
16
+ - Changed: `AuthError` extends `DomainError<AuthErrorCode>` of `@starci/common` (new dependency); its name, codes, constructor, `kind`, `context` and `cause` are unchanged, and it gains `params` (always empty).
17
+ - Changed: the package's own non-array object guard is `isPlainRecord` of `@starci/common`.
18
+ - Changed: requires `@starci/common` 1.0.1 (exact), the one copy an app installs, so `DomainError` stays one class (this version had not reached npm).
19
+
3
20
  ## 0.4.0 - 2026-11-04
4
21
 
5
22
  - Added: `RefreshCookieOptions.domains`: the refresh cookie's `Domain` follows the request host (its `Host` header without the port). A host equal to a listed domain, or ending in `.` and one, takes the longest that fits; any other host takes `domain`, else stays host-only, so one api signs in on localhost (host-only) and on a tunnel origin (its parent domain).
package/README.md CHANGED
@@ -1,18 +1,27 @@
1
1
  # @starci/auth
2
2
 
3
- The sign-in of a lite StarCi app: one package, four entries.
3
+ The sign-in of a StarCi app, for both editions: one package, eight entries. Lite apps issue their own identity (`./server`); full apps
4
+ verify their identity provider's tokens (`./oidc`). Every provider and issuer lives here, so a new one is one file.
4
5
 
5
- | entry | holds |
6
- |---|---|
7
- | `@starci/auth/server` | the NestJS module (`AuthModule.register` / `registerAsync`), the token signer and verifier (EdDSA, multi-key JWKS), rotating sessions, the sign-in budget, the refresh cookie, the passport flows and the doors that run them |
8
- | `@starci/auth/client` | `createAuthClient`: access token in memory, one shared refresh, signed out only on 401 or 403 |
9
- | `@starci/auth/client/react` | `AuthProvider`, `useAuthSession`, `useAuthClient`, `useWhenSignedIn`, `useCooldown` (context and hooks over the client; no visual) |
10
- | `@starci/auth/contracts` | door paths, wire codes, claim names and answer shapes both sides read |
11
- | `@starci/auth/testing` | test keys, a fake clock, in-memory stores, `testAuthOptions` |
6
+ | entry | edition | holds |
7
+ |---|---|---|
8
+ | `@starci/auth/server` | lite | the NestJS module (`AuthModule.register` / `registerAsync`), the token signer and verifier (EdDSA, multi-key JWKS), rotating sessions, the sign-in budget, the refresh cookie, the passport flows, the sign-in providers (`google`, `github`, `defineProvider`) and the doors that run them |
9
+ | `@starci/auth/oidc` | full | `OidcModule.register` / `registerAsync` (also `createOidc` without Nest): issuer discovery, the provider's JWKS with caching and rotation, the token verifier, `OidcPrincipalVerifier`, role mapping, `OidcClient` (authorization code flow with PKCE, state and nonce), `OidcTokenClient`, `OidcAdminClient`, `OidcBroker`, and the issuer presets (`keycloakIssuer`, `googleIssuer`, `githubActionsIssuer`, `createIssuerVerifier`) |
10
+ | `@starci/auth/client` | lite | `createAuthClient`: access token in memory, one shared refresh, signed out only on 401 or 403 |
11
+ | `@starci/auth/client/oidc` | full | `createOidcClient`: the browser half of a public OIDC client (PKCE redirect, callback, one shared refresh, the token in memory) |
12
+ | `@starci/auth/client/react` | lite | `AuthProvider`, `useAuthSession`, `useAuthClient`, `useWhenSignedIn`, `useCooldown` (context and hooks over the client; no visual) |
13
+ | `@starci/auth/contracts` | both | door paths, wire codes, claim names and answer shapes both sides read, and the OIDC contract (algorithms, codes, claims, principal) |
14
+ | `@starci/auth/testing` | both | test keys, a fake clock, in-memory stores, `testAuthOptions`, and `FakeOidcIssuer` (discovery, JWKS, token, revocation and admin endpoints, in memory or on a socket) |
15
+ | `@starci/auth/migrations/*` | lite | the session tables (`identity-sessions.sql`, `identity-refresh-raced-successors.sql`) |
16
+
17
+ An app reaches the package only through its glue: `integrations/passport` (lite) and `integrations/keycloak` (full) in the back end,
18
+ `modules/auth/client.ts` in the front end (R328, `BE_AUTH_PASSPORT_OWNER`). The lint refuses `passport*`, `jose`, `bcryptjs`, `@nestjs/jwt`
19
+ and every other JWT library in an app, and the retired `@starci/auth-oidc` everywhere.
12
20
 
13
- An app reaches the package only through `integrations/passport` (back end) and `modules/auth/client.ts` (front end); the lint refuses `passport*`, `jose`, `bcryptjs` and `@nestjs/jwt` in an app.
21
+ The lite server throws one error class, `AuthError`; the OIDC side throws `OidcError`. Both are `DomainError`s of `@starci/common` whose
22
+ `code` is a wire code, with the `kind` they travel as.
14
23
 
15
- ## Back end
24
+ ## Lite: the back end
16
25
 
17
26
  ```ts
18
27
  AuthModule.registerAsync({
@@ -23,17 +32,99 @@ AuthModule.registerAsync({
23
32
  })
24
33
  ```
25
34
 
26
- The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; secrets are passed as `{ reveal(): string }` holders or text. The app implements `SessionStore` over the tables of `migrations/identity-sessions.sql` (an app whose baseline holds the 0.2.0 tables adds `migrations/identity-refresh-raced-successors.sql`), `AccountDirectory` over its account doors (in a StarCi lite app, the managed `persistence/account-doors.port.ts` over the SECURITY DEFINER `be_auth_*` functions of the baseline), `RateCounter` over its shared counter, and `AuthAuthenticator` (the password and Google decisions) using `AuthPasswords` and `SignInBudget.counted` from the module.
35
+ The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; secrets are passed as `{ reveal(): string }` holders or text. The app implements `SessionStore` over the tables of `migrations/identity-sessions.sql` (an app whose baseline holds the 0.2.0 tables adds `migrations/identity-refresh-raced-successors.sql`), `AccountDirectory` over its account doors (in a StarCi lite app, the managed `persistence/account-doors.port.ts` over the SECURITY DEFINER `be_auth_*` functions of the baseline), `RateCounter` over its shared counter, and `AuthAuthenticator` (the password and provider decisions) using `AuthPasswords` and `SignInBudget.counted` from the module.
27
36
 
28
37
  Key rotation: list the new key first with its private key, keep the old key with its public key only, deploy, and drop the old key once its access tokens have expired. Every listed key verifies and is published by `AuthSessions.jwks()`.
29
38
 
39
+ ### Sign-in providers
40
+
41
+ ```ts
42
+ import { github, google } from "@starci/auth/server"
43
+
44
+ AuthModule.register({ ...options, google: null, providers: [google({ clientId, clientSecret, callbackUrl }), github({ clientId, clientSecret, callbackUrl })] })
45
+ ```
46
+
47
+ Each provider is served at `/auth/<id>` (`AuthDoors.beginProvider(id, exchange)`) and `/auth/<id>/callback` (`completeProvider`), with
48
+ `state` and PKCE (S256) kept in a signed, httpOnly cookie `auth_<id>_state` sent only to `/auth/<id>`, and a same-site landing path. The
49
+ `google` option of 0.5.0 still works and means `providers: [google(options.google)]`; naming Google both ways is `PASSPORT_PROVIDERS_INVALID`.
50
+ Google keeps its doors (`beginGoogle`, `completeGoogle`), its cookie (`auth_google_state`, the same signed `{ w, u, r, e, p: "google" }`
51
+ value) and its codes (`PASSPORT_GOOGLE_*`). Google calls `AuthAuthenticator.signInWithGoogle(profile)`; every other provider calls
52
+ `signInWithProvider(profile)` with a `ProviderProfile` (`provider`, `subject`, `email`, `emailVerified`, `name`, `picture`).
53
+
54
+ GitHub (`github(...)`, an OAuth app) asks for `read:user user:email`, takes the numeric account id as the subject (never the login) and the
55
+ primary verified address from `/user/emails`; a public address only is passed as unverified.
56
+
57
+ A next provider is one file that calls `defineProvider({ id, options, profileOf, strategy })`: `strategy(context)` builds a passport
58
+ strategy that keeps its round trip in `context.state` (an `OAuthStateClient`) and signs in through `signInWithProvider(context.authenticator, profile)`;
59
+ the module registers it under `id` and the doors serve it.
60
+
61
+ ## Full: the back end
62
+
63
+ ```ts
64
+ OidcModule.registerAsync({
65
+ isGlobal: true,
66
+ inject: [HTTP_CLIENT, CLOCK],
67
+ useFactory: (http: HttpClient, clock: Clock) => ({
68
+ ...keycloakOptions({ origin, realm, audience }), // or the parsed issuer, audience and role mapping
69
+ client: parsedClient,
70
+ http: { request: (request) => http.request({ correlationId: "identity-keycloak", ...request }) },
71
+ clock,
72
+ }),
73
+ })
74
+ ```
75
+
76
+ Every provider request goes through the `OidcHttp` port, so the app's bounded HTTP client (`platform/http`) is the one door; without the port the platform `fetch` is used with the request's deadline.
77
+
78
+ | a bearer token | is checked by |
79
+ |---|---|
80
+ | the algorithm | pinned to `algorithms` (`RS256`) before any key is looked up; `none`, HMAC and unlisted algorithms are refused |
81
+ | the key | the JWKS of the discovered `jwks_uri`, selected by `kid`; cached 10 minutes; an unknown `kid` refreshes it at most once per 30 seconds (rotation); an unreachable provider keeps the cached keys for 24 hours |
82
+ | issuer, audience, expiry, not-before | the registration and the clock, with a clock skew of 5 seconds |
83
+ | the token type | an ID token or a refresh token presented as a bearer is refused (`typ` must be `Bearer`) |
84
+ | the roles | `realm_access.roles`, `resource_access.<client>.roles` of the mapped clients and the mapped flat claims; a claim of the wrong shape or past 128 roles refuses the token |
85
+
86
+ `verify` answers `{ kind: "ok", value: OidcPrincipal }` or `{ kind: "refused", code: "PASSPORT_ACCESS_TOKEN_INVALID", detail: { reason } }` (the code the lite verifier refuses with; the reason is for the log). A provider that cannot be asked and has nothing cached throws an `OidcError` of kind `unavailable`, which the app maps to a 503, never a 401. Endpoints a discovery document advertises are trusted only on the issuer's origin, over https, without credentials; `http:` is admitted only with `allowInsecureHttp` (a developer machine or a test double).
87
+
88
+ `OidcBroker` (token `OIDC_BROKER`) is the one facade an app that keeps Keycloak behind its back end binds its identity port to: `passwordGrant`, `refresh`, `revoke`, `socialAuthorizationUrl` (`kc_idp_hint`), `exchangeSocialCode`, `createUser`, `findUserByEmail`, `resetPassword`, `logoutAll` (the realm admin API as the client's service account). The injection tokens keep their 0.2.0 names (`Symbol.for("@starci/auth-oidc/...")`), so an app's providers resolve them unchanged.
89
+
90
+ ### Issuer presets
91
+
92
+ A preset is the trust of one issuer as data plus one claim mapping (`IssuerPreset`: issuer and its aliases, pinned algorithms, required
93
+ audience, where the keys are, required claims, `identityOf`); `createIssuerVerifier(preset, { http, clock })` verifies a token against it.
94
+ A refusal names its `reason` (`audience`, `expired`, `signature`, `claims`, `policy` with the `claim` the policy refused, ...).
95
+
96
+ | preset | verifies | yields |
97
+ |---|---|---|
98
+ | `keycloakIssuer({ origin, realm, audience, roles? })` | realm access tokens, keys from the realm's discovery document | the `OidcPrincipal` of the module's verifier |
99
+ | `googleIssuer({ clientId, hostedDomain?, requireVerifiedEmail? })` | Google ID tokens (`https://accounts.google.com` or `accounts.google.com`, RS256, Google's pinned key set), a verified address unless told otherwise | `GoogleIdentity`: the lite `GoogleProfile` with `hostedDomain` |
100
+ | `githubActionsIssuer({ audience, repository, repositoryId?, workflows?, refs? })` | GitHub Actions OIDC tokens (`https://token.actions.githubusercontent.com`, RS256, GitHub's pinned key set); the audience is required, the repository must match (and `sub` must name it), optionally the repository id, the workflow files of `workflow_ref` and the refs | `GitHubActionsRun`: repository, workflow, workflow path, ref, sha, run id, attempt, actor, event, environment |
101
+
102
+ The CI attestation of a full app (a workflow run proving which repository, workflow, ref and commit it is) composes `githubActionsIssuer`
103
+ inside `integrations/keycloak`; there is no separate integration for it.
104
+
30
105
  ## Front end
31
106
 
32
107
  ```ts
33
108
  // modules/auth/client.ts: the one instance
34
- export const auth = createAuthClient({ apiOrigin: () => apiOrigin() })
109
+ export const auth = createAuthClient({ apiOrigin: () => apiOrigin() }) // lite
110
+ export const auth = createOidcClient({ issuer: () => issuer(), clientId: "web", redirectUri: () => `${origin()}/auth/callback`, postLogoutRedirectUri: () => origin() }) // full
35
111
  ```
36
112
 
113
+ The OIDC client's `signIn()` redirects to the provider; the callback page calls `completeSignIn(window.location.href)`; `restore()` signs in again without a page after a reload; `accessToken()`, `renew()`, `headers()`, `subscribe`, `getSnapshot`, `watch` and `signOut()` have the shape of the lite session surface.
114
+
115
+ ## Migrating from `@starci/auth-oidc`
116
+
117
+ `@starci/auth-oidc` 0.3.0 is a deprecated re-export of this package. `starci app codemod auth-merge` (a dry run; `--execute` writes) rewrites
118
+ `@starci/auth-oidc` and `@starci/auth-oidc/server` to `@starci/auth/oidc`, `/client` to `@starci/auth/client/oidc`, `/contracts` to
119
+ `@starci/auth/oidc`, `/testing` to `@starci/auth/testing`, and the dependency in every package.json to `@starci/auth`.
120
+
37
121
  ## Tests
38
122
 
39
- `npm test` typechecks, lints and runs the `node:test` specs under 90% line coverage.
123
+ `npm test` typechecks (the OIDC tree also under `--isolatedDeclarations`, `tsconfig.isolated.json`), lints and runs the `node:test` specs
124
+ under 90% line coverage, including the assignability of the OIDC verifier and client to the lite interfaces (`src/oidc/compat.spec.ts`).
125
+
126
+ ```ts
127
+ const issuer = await FakeOidcIssuer.create({ audience: "api" })
128
+ const token = await issuer.mintAccessToken({ sub: "user-1", roles: ["member"] })
129
+ const ci = createIssuerVerifier(githubActionsIssuer({ audience: "api", repository: "o/r", endpoints: issuer.endpoints() }), { http: issuer.http })
130
+ ```
@@ -0,0 +1,5 @@
1
+ export { createOidcClient } from "./oidc-client.js";
2
+ export type { OidcAuthClient, OidcClientOptions, OidcEvent, OidcListener, OidcSignedIn, OidcSnapshot, OidcStorage } from "./oidc-client.js";
3
+ export { sameSitePath } from "./pkce.js";
4
+ export { OIDC_CLIENT_CODES } from "../../contracts/oidc.js";
5
+ export type { OidcClientOutcome, OidcFailureKind, OidcSessionUser } from "../../contracts/oidc.js";
@@ -0,0 +1,3 @@
1
+ export { createOidcClient } from "./oidc-client.js";
2
+ export { sameSitePath } from "./pkce.js";
3
+ export { OIDC_CLIENT_CODES } from "../../contracts/oidc.js";
@@ -0,0 +1,85 @@
1
+ import type { SessionEvent, SessionListener, SessionSnapshot } from "../session.js";
2
+ import type { OidcClientOutcome, OidcSessionUser } from "../../contracts/oidc.js";
3
+ /** The event a listener hears: the same four as `AuthEvent` of `@starci/auth/client`. */
4
+ export type OidcEvent = SessionEvent;
5
+ /** Called on every sign-in, sign-out and token refresh with the id of the person (null when signed out). */
6
+ export type OidcListener = SessionListener;
7
+ /** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
8
+ export type OidcSnapshot = SessionSnapshot<OidcSessionUser>;
9
+ /** The small part of `Storage` the client needs: the transaction of one redirect lives here (and a sign-in hint), never a token. */
10
+ export interface OidcStorage {
11
+ getItem(key: string): string | null;
12
+ setItem(key: string, value: string): void;
13
+ removeItem(key: string): void;
14
+ }
15
+ /** The options of the client. */
16
+ export interface OidcClientOptions {
17
+ /** The issuer (the realm address). */
18
+ readonly issuer: string | (() => string);
19
+ /** The registered public client. */
20
+ readonly clientId: string;
21
+ /** The exact registered redirect address, the page that calls `completeSignIn`. */
22
+ readonly redirectUri: string | (() => string);
23
+ /** The exact registered address the provider returns to after logout; without it sign-out only forgets the session here. */
24
+ readonly postLogoutRedirectUri?: string | (() => string) | undefined;
25
+ /** The requested scope (`openid`). */
26
+ readonly scope?: string | undefined;
27
+ /** The fetch to use (the global one when absent). */
28
+ readonly fetch?: typeof fetch | undefined;
29
+ /** Where the transaction of a redirect is kept: `sessionStorage` when the platform has it, else memory. */
30
+ readonly storage?: OidcStorage | undefined;
31
+ /** Takes the browser to an address (`location.assign`). */
32
+ readonly navigate?: ((url: string) => void) | undefined;
33
+ /** Refresh this long before the token really expires, so a request never carries a dying one (15 seconds). */
34
+ readonly refreshAheadMs?: number | undefined;
35
+ /** Tries a refresh makes before it gives up on a busy or unreachable provider (4). */
36
+ readonly retryAttempts?: number | undefined;
37
+ /** The base of the linear backoff between tries, in milliseconds (400). */
38
+ readonly retryBaseMs?: number | undefined;
39
+ /** The clock in milliseconds (`Date.now`). */
40
+ readonly now?: (() => number) | undefined;
41
+ /** The pause between tries (a timer). */
42
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
43
+ }
44
+ /** What a finished sign-in answers: the person and where the page was going. */
45
+ export interface OidcSignedIn {
46
+ readonly user: OidcSessionUser;
47
+ /** The same-site path `signIn` was asked to return to, or `/`. */
48
+ readonly returnTo: string;
49
+ }
50
+ /** The OIDC client of a front end: the access token lives in this closure's memory only, never in storage or a cookie. */
51
+ export interface OidcAuthClient {
52
+ /** Takes the browser to the provider's login page (authorization code flow, PKCE S256, state, nonce). `prompt: "none"` asks without showing a page. */
53
+ signIn(options?: {
54
+ readonly returnTo?: string;
55
+ readonly prompt?: "none" | "login";
56
+ }): Promise<OidcClientOutcome<true>>;
57
+ /** Finishes the flow on the redirect page: checks the state and the issuer, exchanges the code once and keeps the grant. */
58
+ completeSignIn(callbackUrl: string): Promise<OidcClientOutcome<OidcSignedIn>>;
59
+ /** Signs in again without a page when this browser signed in before and holds no session now (a reload): a `prompt=none` redirect, tried once. */
60
+ restore(): Promise<OidcClientOutcome<"redirected" | "skipped">>;
61
+ /** Forgets the session and takes the browser to the provider's logout address. */
62
+ signOut(): Promise<void>;
63
+ /** Refreshes with the refresh token held in memory. Overlapping calls share one refresh. `undefined` when it cannot be refreshed. */
64
+ renew(): Promise<string | undefined>;
65
+ /** The access token of the current session, refreshed when missing or about to expire; `undefined` when signed out. */
66
+ accessToken(): Promise<string | undefined>;
67
+ /** The held access token without any request. */
68
+ peek(): string | undefined;
69
+ /** The signed-in person, or null when signed out. */
70
+ user(): Promise<OidcSessionUser | null>;
71
+ /** The `Authorization` header of the current session (an empty object when signed out). */
72
+ headers(): Promise<Record<string, string>>;
73
+ /** Forgets the session. */
74
+ drop(): void;
75
+ /** Listens to auth events; the first answer comes from a refresh. Answers the function that stops listening. */
76
+ subscribe(listener: OidcListener): () => void;
77
+ /** The current snapshot (stable between changes). */
78
+ getSnapshot: () => OidcSnapshot;
79
+ /** The snapshot for server rendering: always loading. */
80
+ getServerSnapshot: () => OidcSnapshot;
81
+ /** Calls `onChange` when the snapshot changes (the shape `useSyncExternalStore` takes). */
82
+ watch: (onChange: () => void) => () => void;
83
+ }
84
+ /** Creates the one OIDC client of a front end (one instance, like the event stream). */
85
+ export declare const createOidcClient: (options: OidcClientOptions) => OidcAuthClient;
@@ -0,0 +1,304 @@
1
+ import { createSessionStore, createSingleFlight } from "../session.js";
2
+ import { OIDC_CLIENT_CODES } from "../../contracts/oidc.js";
3
+ import { challengeOf, claimsOf, randomText, sameSitePath, textOf } from "./pkce.js";
4
+ const TRANSACTION_KEY = "starci.oidc.tx.";
5
+ const HINT_KEY = "starci.oidc.hint";
6
+ const SILENT_KEY = "starci.oidc.silent";
7
+ const TRANSACTION_TTL_MS = 600_000;
8
+ const MILLISECONDS = 1000;
9
+ /** The answers of a prompt-less request that mean "sign in on a page", not a failure. */
10
+ const SILENT_ANSWERS = ["login_required", "interaction_required", "consent_required", "account_selection_required"];
11
+ const failure = (kind, code) => ({ success: false, message: kind, error: code });
12
+ const success = (data) => ({ success: true, message: "ok", data });
13
+ const recordOf = (value) => (typeof value === "object" && value !== null ? Object.fromEntries(Object.entries(value)) : {});
14
+ const grantOf = (body) => {
15
+ const fields = recordOf(body);
16
+ const { access_token: accessToken, expires_in: expiresIn, refresh_token: refreshToken, id_token: idToken, token_type: type } = fields;
17
+ if (typeof accessToken !== "string" || accessToken === "" || typeof expiresIn !== "number" || typeof type !== "string" || type.toLowerCase() !== "bearer")
18
+ return undefined;
19
+ return { accessToken, expiresIn, refreshToken: textOf(refreshToken), idToken: textOf(idToken) };
20
+ };
21
+ const memoryStorage = () => {
22
+ const held = new Map();
23
+ return {
24
+ getItem: (key) => held.get(key) ?? null,
25
+ setItem: (key, value) => {
26
+ held.set(key, value);
27
+ },
28
+ removeItem: (key) => {
29
+ held.delete(key);
30
+ },
31
+ };
32
+ };
33
+ const isStorage = (value) => typeof value === "object" && value !== null && "getItem" in value && "setItem" in value && "removeItem" in value;
34
+ const platformStorage = () => {
35
+ try {
36
+ const candidate = Reflect.get(globalThis, "sessionStorage");
37
+ return isStorage(candidate) ? candidate : memoryStorage();
38
+ }
39
+ catch {
40
+ return memoryStorage();
41
+ }
42
+ };
43
+ const platformNavigate = (url) => {
44
+ const location = Reflect.get(globalThis, "location");
45
+ const assign = typeof location === "object" && location !== null ? Reflect.get(location, "assign") : undefined;
46
+ if (typeof assign === "function" && typeof location === "object")
47
+ Reflect.apply(assign, location, [url]);
48
+ };
49
+ const timerPause = (ms) => new Promise((resolve) => {
50
+ const timer = setTimeout(() => {
51
+ clearTimeout(timer);
52
+ resolve();
53
+ }, ms);
54
+ });
55
+ const parseTransaction = (raw) => {
56
+ if (raw === null)
57
+ return undefined;
58
+ try {
59
+ const fields = recordOf(JSON.parse(raw));
60
+ const { verifier, nonce, returnTo, at } = fields;
61
+ return typeof verifier === "string" && typeof nonce === "string" && typeof returnTo === "string" && typeof at === "number" ? { verifier, nonce, returnTo, at } : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ };
67
+ /** Creates the one OIDC client of a front end (one instance, like the event stream). */
68
+ export const createOidcClient = (options) => {
69
+ const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
70
+ const storage = options.storage ?? platformStorage();
71
+ const navigate = options.navigate ?? platformNavigate;
72
+ const refreshAheadMs = options.refreshAheadMs ?? 15_000;
73
+ const attemptsMax = options.retryAttempts ?? 4;
74
+ const retryBaseMs = options.retryBaseMs ?? 400;
75
+ const now = options.now ?? Date.now;
76
+ const pause = options.sleep ?? timerPause;
77
+ const scope = options.scope ?? "openid";
78
+ const valueOf = (value) => (typeof value === "string" ? value : value());
79
+ let token;
80
+ let refreshToken;
81
+ let idToken;
82
+ let expiresAt = 0;
83
+ let announced = false;
84
+ let user = null;
85
+ let discovered;
86
+ // The snapshot, the listeners and the watchers are the session store of @starci/auth/client, the one every provider's client shares.
87
+ const store = createSessionStore({
88
+ announced: () => announced,
89
+ announce: () => {
90
+ announced = true;
91
+ },
92
+ held: () => token !== undefined,
93
+ userId: () => user?.id ?? null,
94
+ renew: () => renew(),
95
+ refreshing: () => flight.running(),
96
+ });
97
+ const userOf = (grant) => {
98
+ const { sub, email } = claimsOf(grant.idToken ?? grant.accessToken);
99
+ return typeof sub === "string" ? { id: sub, email: typeof email === "string" ? email : "" } : null;
100
+ };
101
+ const keep = (grant, event) => {
102
+ token = grant.accessToken;
103
+ // A refresh answers a new refresh token when the provider rotates; one that does not leaves the held one valid.
104
+ refreshToken = grant.refreshToken ?? refreshToken;
105
+ idToken = grant.idToken ?? idToken;
106
+ expiresAt = now() + grant.expiresIn * MILLISECONDS;
107
+ announced = true;
108
+ user = userOf(grant);
109
+ storage.setItem(HINT_KEY, "1");
110
+ store.signedIn(user);
111
+ store.emit(event, user?.id ?? null);
112
+ };
113
+ const drop = () => {
114
+ const had = token !== undefined;
115
+ token = undefined;
116
+ refreshToken = undefined;
117
+ idToken = undefined;
118
+ expiresAt = 0;
119
+ announced = true;
120
+ user = null;
121
+ store.signedOut();
122
+ if (had)
123
+ store.emit("SIGNED_OUT", null);
124
+ };
125
+ function refreshEvent() {
126
+ if (!announced)
127
+ return "INITIAL_SESSION";
128
+ return token === undefined ? "SIGNED_IN" : "TOKEN_REFRESHED";
129
+ }
130
+ const discover = () => {
131
+ discovered ??= (async () => {
132
+ const response = await fetcher(`${valueOf(options.issuer).replace(/\/$/u, "")}/.well-known/openid-configuration`, { headers: { accept: "application/json" } });
133
+ const document = recordOf(await response.json().catch(() => undefined));
134
+ const { authorization_endpoint: authorization, token_endpoint: tokenEndpoint, end_session_endpoint: endSession, issuer } = document;
135
+ if (!response.ok || issuer !== valueOf(options.issuer) || typeof authorization !== "string" || typeof tokenEndpoint !== "string")
136
+ throw new Error(OIDC_CLIENT_CODES.Provider);
137
+ return { authorization, token: tokenEndpoint, endSession: typeof endSession === "string" ? endSession : undefined };
138
+ })().catch((cause) => {
139
+ discovered = undefined;
140
+ throw cause;
141
+ });
142
+ return discovered;
143
+ };
144
+ const tokenRequest = async (form) => {
145
+ const { token: endpoint } = await discover();
146
+ const response = await fetcher(endpoint, {
147
+ method: "POST",
148
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
149
+ body: new URLSearchParams({ client_id: options.clientId, ...form }).toString(),
150
+ });
151
+ return { status: response.status, body: await response.json().catch(() => undefined) };
152
+ };
153
+ /** True when the answer to a refresh says the session is over: the provider refused the refresh token (401, 403, or 400 `invalid_grant`). A 429, a 5xx or a lost connection never ends a session. */
154
+ const endsSession = (status, body) => status === 401 || status === 403 || (status === 400 && recordOf(body).error === "invalid_grant");
155
+ const requestRefresh = async (attempt = 1) => {
156
+ const retry = async () => {
157
+ await pause(retryBaseMs * attempt);
158
+ return requestRefresh(attempt + 1);
159
+ };
160
+ const held = refreshToken;
161
+ if (held === undefined) {
162
+ drop();
163
+ return undefined;
164
+ }
165
+ try {
166
+ const answer = await tokenRequest({ grant_type: "refresh_token", refresh_token: held });
167
+ if (answer.status === 200) {
168
+ const grant = grantOf(answer.body);
169
+ if (grant === undefined)
170
+ return undefined;
171
+ keep(grant, refreshEvent());
172
+ return grant.accessToken;
173
+ }
174
+ if (endsSession(answer.status, answer.body)) {
175
+ storage.removeItem(HINT_KEY);
176
+ drop();
177
+ return undefined;
178
+ }
179
+ if ((answer.status === 429 || answer.status >= 500) && attempt < attemptsMax)
180
+ return await retry();
181
+ return undefined;
182
+ }
183
+ catch {
184
+ return attempt < attemptsMax ? retry() : undefined;
185
+ }
186
+ };
187
+ // Many requests answered 401 together refresh once: the others await the one in flight.
188
+ const flight = createSingleFlight(() => requestRefresh());
189
+ const renew = () => flight.run();
190
+ const accessToken = async () => (token !== undefined && expiresAt - refreshAheadMs > now() ? token : renew());
191
+ const signIn = async (input = {}) => {
192
+ try {
193
+ const { authorization } = await discover();
194
+ const state = randomText();
195
+ const nonce = randomText();
196
+ const verifier = randomText();
197
+ const transaction = { verifier, nonce, returnTo: sameSitePath(input.returnTo, "/"), at: now() };
198
+ storage.setItem(TRANSACTION_KEY + state, JSON.stringify(transaction));
199
+ const url = new URL(authorization);
200
+ url.search = new URLSearchParams({
201
+ response_type: "code",
202
+ client_id: options.clientId,
203
+ redirect_uri: valueOf(options.redirectUri),
204
+ scope,
205
+ state,
206
+ nonce,
207
+ code_challenge: await challengeOf(verifier),
208
+ code_challenge_method: "S256",
209
+ ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
210
+ }).toString();
211
+ navigate(url.toString());
212
+ return success(true);
213
+ }
214
+ catch {
215
+ return failure("unavailable", OIDC_CLIENT_CODES.Transport);
216
+ }
217
+ };
218
+ const completeSignIn = async (callbackUrl) => {
219
+ const query = new URL(callbackUrl).searchParams;
220
+ const state = query.get("state") ?? "";
221
+ const key = TRANSACTION_KEY + state;
222
+ const transaction = state === "" ? undefined : parseTransaction(storage.getItem(key));
223
+ storage.removeItem(key);
224
+ storage.removeItem(SILENT_KEY);
225
+ if (transaction === undefined || now() - transaction.at > TRANSACTION_TTL_MS)
226
+ return failure("invalid", OIDC_CLIENT_CODES.State);
227
+ const refusal = query.get("error");
228
+ if (refusal !== null) {
229
+ if (SILENT_ANSWERS.includes(refusal))
230
+ storage.removeItem(HINT_KEY);
231
+ drop();
232
+ return failure("refused", refusal);
233
+ }
234
+ const issuer = query.get("iss");
235
+ if (issuer !== null && issuer !== valueOf(options.issuer))
236
+ return failure("invalid", OIDC_CLIENT_CODES.Issuer);
237
+ const code = query.get("code");
238
+ if (code === null || code === "")
239
+ return failure("invalid", OIDC_CLIENT_CODES.Token);
240
+ try {
241
+ const answer = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: valueOf(options.redirectUri), code_verifier: transaction.verifier });
242
+ const grant = answer.status === 200 ? grantOf(answer.body) : undefined;
243
+ if (grant === undefined)
244
+ return failure(answer.status >= 500 ? "unavailable" : "refused", answer.status >= 500 ? OIDC_CLIENT_CODES.Transport : OIDC_CLIENT_CODES.Provider);
245
+ if (grant.idToken !== undefined && claimsOf(grant.idToken).nonce !== transaction.nonce)
246
+ return failure("invalid", OIDC_CLIENT_CODES.Nonce);
247
+ keep(grant, "SIGNED_IN");
248
+ return user === null ? failure("invalid", OIDC_CLIENT_CODES.Token) : success({ user, returnTo: transaction.returnTo });
249
+ }
250
+ catch {
251
+ return failure("unavailable", OIDC_CLIENT_CODES.Transport);
252
+ }
253
+ };
254
+ const restore = async () => {
255
+ if (token !== undefined || storage.getItem(HINT_KEY) !== "1" || storage.getItem(SILENT_KEY) === "1")
256
+ return success("skipped");
257
+ storage.setItem(SILENT_KEY, "1");
258
+ const started = await signIn({ prompt: "none", returnTo: currentPath() });
259
+ if (started.success)
260
+ return success("redirected");
261
+ storage.removeItem(SILENT_KEY);
262
+ return started;
263
+ };
264
+ const currentPath = () => {
265
+ const location = Reflect.get(globalThis, "location");
266
+ const pathname = typeof location === "object" && location !== null ? Reflect.get(location, "pathname") : undefined;
267
+ return typeof pathname === "string" ? pathname : "/";
268
+ };
269
+ const signOut = async () => {
270
+ const hint = idToken;
271
+ const target = options.postLogoutRedirectUri;
272
+ storage.removeItem(HINT_KEY);
273
+ const endpoint = await discover().then((found) => found.endSession, () => undefined);
274
+ drop();
275
+ if (endpoint === undefined || target === undefined)
276
+ return;
277
+ const url = new URL(endpoint);
278
+ url.search = new URLSearchParams({
279
+ client_id: options.clientId,
280
+ post_logout_redirect_uri: valueOf(target),
281
+ ...(hint === undefined ? {} : { id_token_hint: hint }),
282
+ }).toString();
283
+ navigate(url.toString());
284
+ };
285
+ return {
286
+ signIn,
287
+ completeSignIn,
288
+ restore,
289
+ signOut,
290
+ renew,
291
+ accessToken,
292
+ peek: () => token,
293
+ user: async () => ((await accessToken()) === undefined ? null : user),
294
+ headers: async () => {
295
+ const held = await accessToken();
296
+ return held === undefined ? {} : { authorization: `Bearer ${held}` };
297
+ },
298
+ drop,
299
+ subscribe: store.subscribe,
300
+ getSnapshot: store.getSnapshot,
301
+ getServerSnapshot: store.getServerSnapshot,
302
+ watch: store.watch,
303
+ };
304
+ };
@@ -0,0 +1,15 @@
1
+ /** base64url of bytes, without padding. */
2
+ export declare const bytesToBase64Url: (bytes: Uint8Array) => string;
3
+ /** 256 unpredictable bits as base64url: a state, a nonce or a PKCE verifier (43 characters, inside the 43 to 128 RFC 7636 asks). */
4
+ export declare const randomText: () => string;
5
+ /** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
6
+ export declare const challengeOf: (verifier: string) => Promise<string>;
7
+ /** Non-empty text, else undefined. */
8
+ export declare const textOf: (value: unknown) => string | undefined;
9
+ /**
10
+ * The claims of a token read WITHOUT checking its signature. For display only (who is signed in): the front end trusts no claim for a
11
+ * decision, the back end verifies every token it is sent.
12
+ */
13
+ export declare const claimsOf: (token: string) => Readonly<Record<string, unknown>>;
14
+ /** A same-site path (one leading slash, no line break), else the fallback path: a return address never leaves the site. */
15
+ export declare const sameSitePath: (value: unknown, fallback: string) => string;
@@ -0,0 +1,29 @@
1
+ /** base64url of bytes, without padding. */
2
+ export const bytesToBase64Url = (bytes) => btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
3
+ /** 256 unpredictable bits as base64url: a state, a nonce or a PKCE verifier (43 characters, inside the 43 to 128 RFC 7636 asks). */
4
+ export const randomText = () => bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)));
5
+ /** The S256 challenge of a PKCE verifier: base64url(SHA-256(verifier)). */
6
+ export const challengeOf = async (verifier) => bytesToBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
7
+ /** Non-empty text, else undefined. */
8
+ export const textOf = (value) => (typeof value === "string" && value !== "" ? value : undefined);
9
+ /**
10
+ * The claims of a token read WITHOUT checking its signature. For display only (who is signed in): the front end trusts no claim for a
11
+ * decision, the back end verifies every token it is sent.
12
+ */
13
+ export const claimsOf = (token) => {
14
+ const payload = token.split(".")[1];
15
+ if (payload === undefined)
16
+ return {};
17
+ try {
18
+ const text = new TextDecoder().decode(Uint8Array.from(atob(payload.replaceAll("-", "+").replaceAll("_", "/")), (char) => char.charCodeAt(0)));
19
+ const parsed = JSON.parse(text);
20
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? Object.fromEntries(Object.entries(parsed)) : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ };
26
+ const SAME_SITE_PATH = /^\/(?![/\\])/u;
27
+ const LINE_BREAK = /[\r\n]/u;
28
+ /** A same-site path (one leading slash, no line break), else the fallback path: a return address never leaves the site. */
29
+ export const sameSitePath = (value, fallback) => typeof value === "string" && SAME_SITE_PATH.test(value) && !LINE_BREAK.test(value) ? value : fallback;