@o3co/auth-provider-foundation 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -1,97 +1,195 @@
1
1
  # @o3co/auth-provider-foundation
2
2
 
3
- Production HTTP user-authentication adapter for auth.provider. Registers the `"http"` adapter type into a `UserRepository` factory, which delegates `authenticate` / `authenticateByToken` to an upstream HTTP service.
4
-
5
- This package's scope is **production non-database / external-service adapters** for the v0.5.0 module system. As of v0.5.0 it ships exactly one such adapter (`HttpUserRepository`); the previous Redis `CodeRepository` adapter was relocated to [`@o3co/auth-provider-redis`](../redis/README.md) in Phase 10.
3
+ Last updated: 2026-09-26
4
+
5
+ The HTTP client of "the Store" — the deployment's own user service — for
6
+ auth.provider. `HttpUserRepository` implements core's `UserRepository` port over
7
+ HTTPS: it authenticates users, links federated identities, and answers the
8
+ identity lookup federation grants ask for. `registerBuiltinAdapters` registers
9
+ it as the `"http"` user adapter.
10
+
11
+ ## Responsibility
12
+
13
+ **Role.** An adapter for one core port,
14
+ [`UserRepository`](../core/src/repositories/UserRepository.mts) (see
15
+ [`core/src/repositories`](../core/src/repositories/README.md)). Despite the
16
+ package name it is not a base layer: no other package imports it at runtime
17
+ (`federation-grants` uses it in a test). A composition root selects it —
18
+ `repositories.user.type = "http"` in the standalone template.
19
+
20
+ **Owns:**
21
+
22
+ - the wire contract a Store implements: the requests `HttpUserRepository` sends
23
+ and what each answer means (below);
24
+ - the transport rules on the Store's URLs — `https`, or `http` to a loopback
25
+ host only ([`src/endpointUrl.mts`](src/endpointUrl.mts)) — and that no
26
+ request follows a redirect away from them;
27
+ - the credential it presents to the Store (`bearerToken`), the floor that
28
+ credential is held to, and how the Store's refusal of it reads;
29
+ - that nothing a transport reports — which may quote the request — is thrown;
30
+ - the request deadline and the response-size cap;
31
+ - the coverage declaration the identity lookup is judged by at boot.
32
+
33
+ **Does not own:** the port and the `User` shape (core); when a user is
34
+ authenticated, an identity linked or an owner looked up — the session routes
35
+ ([`@o3co/auth-provider-session`](../session/README.md)), `oauth`'s jwt-bearer
36
+ grant ([`@o3co/auth-provider-oauth`](../oauth/README.md)) and federation grants
37
+ ([`@o3co/auth-provider-federation-grants`](../federation-grants/README.md)); the
38
+ Store itself; core's development user adapters (`yaml` / `static`); Redis-backed
39
+ stores ([`@o3co/auth-provider-redis`](../redis/README.md), which also holds the
40
+ Redis authorization-code store).
41
+
42
+ **Why a separate package.** Core ships only development user adapters, read from
43
+ a file; a production deployment keeps its users in a service of its own, and
44
+ this is the client for one. It is the replaceable piece — a deployment with its
45
+ own `UserRepository` implementation does not install it — and it depends on
46
+ nothing but core (the global `fetch`, no other dependency).
6
47
 
7
48
  ## Install
8
49
 
9
50
  ```sh
10
- npm install @o3co/auth-provider-foundation
11
- # Peer dependency (required):
12
- npm install @o3co/auth-provider-core
51
+ npm install @o3co/auth-provider-foundation @o3co/auth-provider-core
13
52
  ```
14
53
 
15
- ## Public API
16
-
17
- ### `registerBuiltinAdapters`
18
-
19
- Registers the `"http"` adapter type into the provided `userFactory`.
20
-
21
- ```typescript
22
- function registerBuiltinAdapters(factories: {
23
- userFactory: AdapterFactory<UserRepository>;
24
- }): void;
25
- ```
54
+ Peer dependency: `@o3co/auth-provider-core`. The package has no dependencies
55
+ of its own.
26
56
 
27
- For Redis-backed code storage, register the builder from `@o3co/auth-provider-redis` directly:
57
+ ## Usage
28
58
 
29
59
  ```typescript
30
- import { redisCodeRepositoryBuilder } from "@o3co/auth-provider-redis";
31
- codeFactory.register("redis", redisCodeRepositoryBuilder);
32
- ```
60
+ import { createRepositoryFactories } from "@o3co/auth-provider-core";
61
+ import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
33
62
 
34
- ### `HttpUserRepository`
63
+ const { userFactory } = createRepositoryFactories();
35
64
 
36
- A `UserRepository` implementation that delegates authentication to an upstream HTTP service.
65
+ registerBuiltinAdapters({ userFactory });
37
66
 
38
- ```typescript
39
- class HttpUserRepository implements UserRepository {
40
- constructor(options: {
41
- authenticateUrl: string; // POST endpoint for username/password auth
42
- authenticateByTokenUrl: string; // POST endpoint for token-based auth
43
- linkFederatedIdentityUrl?: string; // POST endpoint that links a federated identity (#482)
44
- findSubjectByFederatedIdentityUrl?: string; // POST endpoint that says who holds an upstream identity (#613)
45
- federatedIdentityLookupCoverage?: FederatedIdentityLookupCoverage[]; // what that lookup covers (#613)
46
- timeout: number; // request timeout in milliseconds
47
- maxResponseBytes?: number; // response body cap, default 1 MiB
48
- });
49
-
50
- // POST authenticateUrl with body: { email, password }
51
- authenticate(username: string, password: string): Promise<User | null>;
52
-
53
- // POST authenticateByTokenUrl with body: { token }
54
- authenticateByToken(token: string): Promise<User | null>;
55
-
56
- // POST linkFederatedIdentityUrl with body: { userId, provider, sub, token, claims }
57
- // — present only when the URL is configured (#482)
58
- linkFederatedIdentity?(userId: string, identity: FederatedIdentityLink): Promise<LinkFederatedIdentityResult>;
59
-
60
- // From the coverage declaration alone; the Store is not asked (#613).
61
- // Present together with the lookup, only when its URL is configured.
62
- supportsFederatedIdentityLookup?(registration: { provider, issuer, clientId }, identityClaims: string[]): boolean;
63
-
64
- // POST findSubjectByFederatedIdentityUrl with body: { provider, issuer, clientId, sub, claims }
65
- findSubjectByFederatedIdentity?(identity: FederatedIdentityLookup): Promise<FederatedIdentityLookupResult>;
66
- }
67
+ // Create an HTTP user repository via the factory
68
+ const userRepo = await userFactory.create({
69
+ type: "http",
70
+ authenticateUrl: "https://users.example.com/authenticate",
71
+ authenticateByTokenUrl: "https://users.example.com/authenticate-by-token",
72
+ timeout: 5000,
73
+ });
67
74
  ```
68
75
 
69
- For `authenticate` and `authenticateByToken`:
70
-
71
- - Returns `null` on HTTP 401 or 403.
72
- - Throws an error on any other non-OK HTTP status.
73
- - Throws when the upstream returns 2xx with a body that is not a JSON `User`
74
- (`{ id: string, username: string, … }`) — an upstream failure, not a
75
- "user not found".
76
- - `linkFederatedIdentity` (#482): a `2xx` `User` is `{ ok: true, user }`;
77
- `401` / `403` is `{ ok: false, reason: "refused" }`; `409` is
78
- `{ ok: false, reason: "conflict" }`; anything else throws. The method is
79
- absent when `linkFederatedIdentityUrl` is not configured, which is how the
80
- federation routes know to refuse `?link=1` up front. What a Store checks
81
- before answering `2xx` — never on an unverified or relay address, never by
82
- e-mail alone — is in the
83
- [session package README](../session/README.md#account-linking-across-federations-482).
76
+ `registerBuiltinAdapters` ([`src/index.mts`](src/index.mts)) registers the
77
+ `"http"` type on a `UserRepository` factory; the builder reads the keys below,
78
+ defaults `timeout` to 5000 ms and `maxResponseBytes` to
79
+ `DEFAULT_MAX_RESPONSE_BYTES` (1 MiB) when absent, and accepts numbers given as
80
+ strings (an environment override). `HttpUserRepository`
81
+ ([`src/repositories/HttpUserRepository.mts`](src/repositories/HttpUserRepository.mts))
82
+ can also be constructed directly with the same options; there `timeout` is
83
+ required, since the 5000 ms default is the builder's.
84
+
85
+ ### Configuration
86
+
87
+ Under `repositories.user` (`type = "http"`, `CLIENT_USER_TYPE`), in its `http`
88
+ block; defaults are in [`reference.conf`](../core/config/reference.conf):
89
+
90
+ | Key | Env | |
91
+ | --- | --- | --- |
92
+ | `authenticateUrl` | `CLIENT_USER_AUTHENTICATE_URL` | Required. Password login. |
93
+ | `authenticateByTokenUrl` | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | Required. Resolves an opaque handle: federated login, the jwt-bearer grant. |
94
+ | `linkFederatedIdentityUrl` | `CLIENT_USER_LINK_FEDERATED_IDENTITY_URL` | Optional. Enables account linking. |
95
+ | `findSubjectByFederatedIdentityUrl` | `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | Optional. The identity lookup. |
96
+ | `federatedIdentityLookupCoverage` | — (a list; HOCON only) | What the lookup covers. Default `[]`. |
97
+ | `bearerToken` | `CLIENT_USER_BEARER_TOKEN` | Optional. Sent on every request as `Authorization: Bearer <token>`; at least 32 bytes of key material. Unset, no `Authorization` header is sent. See [who may call the Store](#what-the-store-must-enforce-itself). |
98
+ | `timeout` | `CLIENT_USER_TIMEOUT` | Milliseconds. Default 5000. |
99
+ | `maxResponseBytes` | `CLIENT_USER_MAX_RESPONSE_BYTES` | Default 1048576. |
100
+
101
+ ## The wire contract
102
+
103
+ Every request is a `POST` with a JSON body, and — when `bearerToken` is
104
+ configured — an `Authorization: Bearer <token>` header. The user a Store
105
+ answers with is core's [`User`](../core/src/repositories/types.mts).
106
+
107
+ **`authenticate`** posts `{ email, password }` to `authenticateUrl` (the
108
+ username arrives as `email`); **`authenticateByToken`** posts `{ token }` to
109
+ `authenticateByTokenUrl`, where `token` is an opaque handle the Store resolves
110
+ to a user — `<provider>:<sub>` from the federation callback, the verified
111
+ assertion's subject handle from `oauth`'s jwt-bearer grant. For both:
112
+
113
+ - a `2xx` whose body is a JSON `User` (`{ id: string, username: string, … }`) is
114
+ the user;
115
+ - `401` or `403` is `null` — no such user, or wrong credentials;
116
+ - a `2xx` with a body that is not a `User` throws — an upstream failure, not a
117
+ "user not found";
118
+ - any other status throws.
119
+
120
+ The body of a non-`2xx` answer is discarded unread, for these and for linking.
121
+ No request follows a redirect, including the identity lookup: a `3xx` is one
122
+ more status that throws, and its `Location` is never contacted.
123
+
124
+ **A `401` or `403` with a `Bearer` challenge is a refused credential.** When
125
+ `bearerToken` is configured, a `401` or `403` carrying
126
+ `WWW-Authenticate: Bearer …` (RFC 6750 §3 — `invalid_token`,
127
+ `insufficient_scope`) from any of the four endpoints throws a
128
+ `StoreCredentialRefusedError`, `HttpUserRepository: the Store at <url> refused
129
+ this deployment's credential (HTTP <status> with a Bearer challenge) — …`,
130
+ instead of the reading this section otherwise gives that status: not "no such
131
+ user", not a refused link, not the lookup's `answered HTTP <status>`. What each
132
+ caller then answers and logs is in
133
+ [What the Store must enforce itself](#what-the-store-must-enforce-itself). The
134
+ challenge is found in any case, among other challenges or on a header line of
135
+ its own, and never inside a quoted string; a quoted string left unterminated
136
+ runs to the end of the value and hides whatever follows it, which then reads
137
+ as no challenge. Nothing the Store wrote after the scheme is repeated. A `401`
138
+ or `403` without a `Bearer` challenge, and any `401` or `403` when no token is
139
+ configured, keeps the meaning this section gives it, so a Store that does not
140
+ check the token is unaffected.
141
+
142
+ **A transport failure carries nothing of the exchange.** When a request cannot
143
+ be made or its answer cannot be read, the error is a `StoreTransportError`
144
+ whose `reason` and message say which:
145
+
146
+ | `reason` | When | Message (the lookup: `identity lookup at <url> …`) |
147
+ | --- | --- | --- |
148
+ | `unreachable` | no connection to exchange on: a refused connection, DNS, TLS, a route or host the network cannot reach — the network path, or TLS | `HttpUserRepository: request to <url> could not be reached` |
149
+ | `connection_closed` | a connection closed or reset before a complete response arrived: before any byte, after an interim `1xx`, mid-head, or a pooled keep-alive connection the Store, a proxy or an idle timeout closed between two requests — the transport cannot tell which | `HttpUserRepository: the connection to <url> closed before a complete response arrived` (the lookup: `identity lookup at <url>: the connection closed …`) |
150
+ | `malformed_response` | the Store sent a response head the transport cannot take: the parser refused the status line or a header, or the head outgrew the size limit — the Store's answer, or a proxy's | `HttpUserRepository: the Store at <url> answered with a malformed HTTP response` |
151
+ | `unreadable` | an HTTP answer whose body broke mid-read | `HttpUserRepository: response from <url> could not be read` |
152
+
153
+ Each carries at most a transport code an operator can act on, in the message
154
+ and as `code`: `ECONNREFUSED`, `ENOTFOUND`, `ECONNRESET`, `EPROTO`, an
155
+ `UND_ERR_*` (`UND_ERR_HEADERS_OVERFLOW`, `UND_ERR_SOCKET`, …), an `HPE_*` where
156
+ the runtime sets one (Node 26's undici sets no code on its parser errors), an
157
+ `ERR_SSL_*` (`ERR_SSL_WRONG_VERSION_NUMBER` is an https URL on a port that
158
+ speaks plain HTTP; `ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE` is a TLS 1.2
159
+ handshake the Store refused — mutual TLS it expects a client certificate for,
160
+ or no shared cipher), a certificate code. It never
161
+ carries the transport's own error or a `cause`: undici's parser errors quote
162
+ the bytes they rejected, and a peer that reflects the request — a broken
163
+ proxy, a debugging echo — puts the `Authorization` header, or a password,
164
+ among them. A request that outlives the deadline is a `TimeoutError` instead
165
+ (see Constructor validation).
166
+
167
+ **`linkFederatedIdentity`** posts `{ userId, provider, sub, token, claims }` to
168
+ `linkFederatedIdentityUrl`: a `2xx` `User` is `{ ok: true, user }`; `401` / `403`
169
+ is `{ ok: false, reason: "refused" }`; `409` is
170
+ `{ ok: false, reason: "conflict" }`; anything else throws. Because the body of a
171
+ refusal is not read, a refusal carries no description from the Store. The
172
+ method is absent when `linkFederatedIdentityUrl` is not configured, which is
173
+ how the federation
174
+ routes know to refuse `?link=1` up front. What a Store checks before answering
175
+ `2xx` — never on an unverified or relay address, never by e-mail alone — is in
176
+ the
177
+ [session package README](../session/README.md#account-linking-across-federations-482).
84
178
 
85
179
  #### The identity lookup (#613)
86
180
 
87
181
  What a federation-grant deployment (`@o3co/auth-provider-federation-grants`,
88
- D7 check 5) asks of the Store: who holds an upstream identity, so that a
182
+ the connect callback's check 5 in the ADR's
183
+ [D7](../core/docs/adr/2026-09-17-federation-grants-offline-delegation.md#d7--the-connect-flow-binds-its-callback-and-refuses-on-any-mismatch))
184
+ asks of the Store: who holds an upstream identity, so that a
89
185
  delegation is refused when the account belongs to another local user. The
90
186
  Store answers about ownership across **every** registration of the IdP, not
91
187
  only the one the identity came through — a login links under the federation
92
188
  the user signed in through, and an IdP whose `sub` is pairwise per app
93
189
  registration (Entra's is) gives the same person another `sub` under each. For
94
- D19's separate grants registration, that means: to answer `unlinked`
190
+ a separate registration for grants (the ADR's
191
+ [D19](../core/docs/adr/2026-09-17-federation-grants-offline-delegation.md#d19--entra-on-behalf-of-is-not-implemented-and-consent-accumulates)),
192
+ that means: to answer `unlinked`
95
193
  honestly, your Store must resolve the verified `(tid, oid)` against an
96
194
  authoritative directory complete for the relevant tenant and all local
97
195
  ownership links across registrations; no match in a partial directory or an
@@ -109,7 +207,10 @@ The request is `POST findSubjectByFederatedIdentityUrl` with
109
207
 
110
208
  — the registration exactly as the connection is configured, the verified
111
209
  `sub`, and every claim the connection's `identityClaims` named (verified,
112
- from the id_token; `{}` when it named none). The lookup must change nothing:
210
+ from the id_token; `{}` when it named none). The Store must not log, persist
211
+ or echo those claims — core's
212
+ [`FederatedIdentityLookup`](../core/src/repositories/UserRepository.mts) marks
213
+ them transient. The lookup must change nothing:
113
214
  no login stamped, no link made, no user provisioned. The answer is a `2xx`
114
215
  JSON body, one of
115
216
 
@@ -137,16 +238,86 @@ exactly (no trimming, no case folding, no trailing-slash tolerance), and
137
238
  `requiredClaims` the claim names the strategy needs — `[]` for one on the
138
239
  registration and `sub` alone. `supportsFederatedIdentityLookup(registration,
139
240
  identityClaims)` is `true` when an entry equals the registration and every
140
- `requiredClaims` name is among `identityClaims`; the grants module refuses at
141
- boot every connection it is `false` for. A registration nobody declared is
241
+ `requiredClaims` name is among `identityClaims`. When federation grants are enabled and have at
242
+ least one connection and `federationGrants.identityLookup` is `"required"` (the
243
+ default), the grants module refuses at boot every connection it is `false` for;
244
+ under `"unsupported"`, or with no connection, it asks nothing. A registration nobody declared is
142
245
  answered `registration_not_covered` locally, and a declared one arriving
143
246
  without a required claim `identity_not_resolvable`, with no request either
144
247
  way. The declaration is snapshotted at construction; a duplicate registration,
145
248
  a malformed entry, or coverage without the URL is a construction error. It has
146
249
  no environment-variable form (a list is HOCON's), as `federationGrants.connections`
147
- has none.
148
-
149
- ### Constructor validation
250
+ has none. `supportsFederatedIdentityLookup` and `findSubjectByFederatedIdentity`
251
+ are absent when `findSubjectByFederatedIdentityUrl` is not configured, and a
252
+ deployment that then has a connection under `"required"` is refused at boot.
253
+
254
+ ## What the Store must enforce itself
255
+
256
+ - **Who may call it.** What `authenticateByToken` and linking carry is not a
257
+ secret — the federation callback's `<provider>:<sub>` is an identifier — so a
258
+ Store that answers any caller lets anyone who can reach
259
+ `authenticateByTokenUrl` resolve a known identity to its user, and anyone who
260
+ can reach an open `linkFederatedIdentityUrl` bind any identity to any
261
+ `userId`. Configure `bearerToken` (`CLIENT_USER_BEARER_TOKEN`, from
262
+ `openssl rand -hex 32`) and have the Store refuse every request on all four
263
+ endpoints whose `Authorization` is not exactly `Bearer <that token>`,
264
+ compared in constant time, and never log the header. One token goes to all
265
+ four URLs, so they must be one trust domain: whoever runs any of those
266
+ endpoints holds a credential the others accept. Refuse with `401` and
267
+ `WWW-Authenticate: Bearer error="invalid_token"` (RFC 6750 §3), or `403` and
268
+ `error="insufficient_scope"` for a token that is valid but not enough. With
269
+ that challenge, a token the Store does not accept — a typo, a half-finished
270
+ rotation — is an outage on every call, which each caller reports as the
271
+ table below shows. Without the challenge the `401` or `403` keeps its wire
272
+ meaning, "no such user" or a refused link, and a mismatch shows only as
273
+ every login failing. For the same reason, never send a `Bearer` challenge
274
+ with a user's wrong password, an unknown identity or a link your policy
275
+ refuses: that answer would read as the Store refusing auth.provider, and the
276
+ user's failed login or link as an outage. To rotate, have the Store accept the old token and the new, move
277
+ auth.provider to the new, then retire the old.
278
+ Without `bearerToken` no request carries an `Authorization` header, and the
279
+ Store must admit only auth.provider some other way — a network policy or a
280
+ private network, or mutual TLS provided by the platform in front of the
281
+ Store (a sidecar on a loopback address, which the `http` carve-out admits).
282
+ This adapter offers no client certificate of its own: Node's `fetch` takes
283
+ one only through an `undici` dispatcher, a dependency this package does not
284
+ carry. A URL carrying `user:password@` is refused.
285
+ - **No secret in the URL.** A query-string token would not stay secret: it
286
+ travels in every request line, to the Store's own access logs and to any
287
+ proxy in between. The errors this adapter throws — which the session routes
288
+ log — name an endpoint by its origin and path only, never its query or
289
+ fragment, so the query at least does not reach this deployment's log. The
290
+ caller's credential belongs in `bearerToken`, which nothing this adapter
291
+ throws carries.
292
+ - **Answer without redirecting.** No request follows a redirect, so a
293
+ password, a token, a link request or an identity goes only to the configured
294
+ URL — the one the `https` rule below checks — and no answer from anywhere
295
+ else is taken as the user, the link or the lookup's answer. A `3xx` from any
296
+ of the four endpoints throws like any other unexpected status (the session
297
+ routes and the jwt-bearer grant answer `503 temporarily_unavailable`, the
298
+ grants callback `temporarily_unavailable`), so a Store behind a URL that
299
+ redirects — a host alias redirecting to the canonical host, an added
300
+ trailing slash, a moved path — fails every call. Configure each URL as the
301
+ endpoint that answers, not one that redirects.
302
+
303
+ **What a refused token looks like, per caller.** Each caller answers a
304
+ `StoreCredentialRefusedError` as it answers any Store failure — a
305
+ `StoreTransportError` or a `TimeoutError` too; what differs is what it logs:
306
+
307
+ | Caller | Answers | Logs |
308
+ | --- | --- | --- |
309
+ | Password login, `POST /session/login` ([`@o3co/auth-provider-session`](../session/README.md)) | `503 temporarily_unavailable` | `login_store_unavailable` (error, `store: "user_repository"`), with `err` |
310
+ | Federation login and `?link=1` callbacks (session) | `503 temporarily_unavailable` | `federation_callback_store_unavailable` or `federation_link_store_unavailable` (error, `store: "user_repository"`), with `err` |
311
+ | The jwt-bearer grant ([`@o3co/auth-provider-oauth`](../oauth/README.md)) | `503 temporarily_unavailable` | `jwt_bearer_user_repository_unavailable` (error), with `err` |
312
+ | The federation-grants connect callback — the identity lookup ([`@o3co/auth-provider-federation-grants`](../federation-grants/README.md)) | redirect with `error=temporarily_unavailable` | `federation_grant_callback_unavailable` (error), `store: "user_directory"`, with `err` |
313
+
314
+ Where `err` is logged, a `StoreCredentialRefusedError`'s message names the
315
+ Store endpoint (its origin and path), the status and
316
+ `CLIENT_USER_BEARER_TOKEN` — never the token; a `StoreTransportError`'s names
317
+ the endpoint the same way, what failed and at most a transport code. No
318
+ message this adapter throws quotes a URL's query string or fragment.
319
+
320
+ ## Constructor validation
150
321
 
151
322
  Every option is validated in the **constructor**, so a misconfigured deployment
152
323
  fails at boot rather than at the first login attempt.
@@ -155,7 +326,9 @@ fails at boot rather than at the first login attempt.
155
326
  password on `authenticateUrl`, a token on `authenticateByTokenUrl`, a verified
156
327
  upstream identity on `findSubjectByFederatedIdentityUrl` — so an
157
328
  `http://` URL does not merely weaken the connection, it publishes the credential
158
- to every hop on the path.
329
+ to every hop on the path. The URL checked here is the only place a request goes
330
+ and the only one whose answer is taken: no request follows a redirect, so a
331
+ `307` or `308` cannot re-send the body to, and no `301`, `302` or `303` can fetch an answer from, a `Location` this rule never saw.
159
332
 
160
333
  **The one carve-out is loopback:** `http://` is accepted when the host is
161
334
  `localhost`, an address in `127.0.0.0/8`, or `[::1]`. That traffic never leaves
@@ -170,44 +343,82 @@ This is the same rule `oauth.jwt.issuer` applies in
170
343
  [`@o3co/auth-provider-core`](../core/README.md), with the carve-out widened from
171
344
  the single address `127.0.0.1` to the whole `127.0.0.0/8` block, and query
172
345
  strings allowed (an issuer may not carry one; a POST endpoint legitimately may).
346
+ A query is sent, and never quoted: every error names the endpoint by origin
347
+ and path.
173
348
 
174
349
  **`timeout` must be a positive integer** no greater than `2147483647`
175
350
  milliseconds. `0`, a negative number, and `NaN` all clamp to "fire immediately"
176
351
  in `setTimeout` — which would abort every request — and a value above Node's
177
352
  timer range clamps to 1ms, turning "be patient" into the most impatient setting
178
- available. The deadline covers the whole exchange, **body included**: the body
179
- read is raced against it rather than relying on the abort signal, because
180
- aborting a request does not reliably interrupt a read already in flight. That is
181
- the slow-loris shape — headers arrive promptly, then the body dribbles or stops
182
- — and without the race it hangs forever. A request that outlives the deadline
183
- rejects with a `timed out after <n>ms` error naming the endpoint.
353
+ available. A blank environment override is a boot failure, not the default. The
354
+ deadline covers the whole exchange, **body included**: the body read is raced
355
+ against it rather than relying on the abort signal, because aborting a request
356
+ does not reliably interrupt a read already in flight. That is the slow-loris
357
+ shape — headers arrive promptly, then the body dribbles or stops — and without
358
+ the race it hangs forever. A request that outlives the deadline rejects with a
359
+ `timed out after <n>ms` error naming the endpoint, named `TimeoutError` on all
360
+ four requests, so a reporter that classifies by name reads it as a timeout.
184
361
 
185
362
  **`maxResponseBytes` must be a positive integer**, defaulting to
186
363
  `DEFAULT_MAX_RESPONSE_BYTES` (1 MiB). The cap is enforced against
187
364
  `Content-Length` *and* while streaming, so a Store that omits the header — or
188
365
  lies in it — is still cut off rather than allowed to exhaust memory.
189
366
 
190
- ## Usage Example
191
-
192
- ```typescript
193
- import { createRepositoryFactories } from "@o3co/auth-provider-core";
194
- import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
195
-
196
- const { userFactory } = createRepositoryFactories();
367
+ **`bearerToken`, when set, must be a bare RFC 6750 token of at least 32 bytes
368
+ of key material.** Unset (absent) sends no `Authorization` header. Set, it is
369
+ refused unless it is a string, not blank (a blank environment override is a
370
+ boot failure, not "no token"), made only of letters, digits, `-._~+/` and
371
+ trailing `=` padding — no whitespace, no line break, and no `Bearer ` prefix,
372
+ which the adapter adds — and it clears core's shared-secret floor
373
+ (`MIN_SECRET_ENTROPY_BYTES`, the one `SESSION_SECRET` and `OAUTH_JWT_SECRET`
374
+ clear), measured on the decoded length of a hex or base64 value, so
375
+ `openssl rand -hex 16` is 16 bytes however long it looks. Whoever holds the
376
+ token speaks to the Store as auth.provider. The shape is checked here because
377
+ a header value `fetch` refuses is one it quotes in the error it throws. No
378
+ refusal quotes the value, no error from a request carries it — a transport
379
+ failure is thrown without the transport's error (see the wire contract) — and
380
+ it is held in an ECMAScript private field, so it is absent from `inspect()`
381
+ and `JSON.stringify` of the repository. These checks run when the repository
382
+ is built, which a deployment does under `repositories.user.type = "http"`
383
+ (the standalone template's default); under core's `reference.conf` default,
384
+ `yaml`, the `http` block — and a token in it — is not read at all.
197
385
 
198
- registerBuiltinAdapters({ userFactory });
199
-
200
- // Create an HTTP user repository via the factory
201
- const userRepo = await userFactory.create({
202
- type: "http",
203
- authenticateUrl: "https://users.example.com/authenticate",
204
- authenticateByTokenUrl: "https://users.example.com/authenticate-by-token",
205
- timeout: 5000,
206
- });
207
- ```
208
-
209
- ## See Also
386
+ ## Public API
210
387
 
211
- - [`@o3co/auth-provider-core`](../core/README.md) — Core interfaces (`UserRepository`, `CodeRepository`, `AdapterFactory`, `createAdapterFactory`, `BuilderContext`, `PathResolver`)
212
- - [`@o3co/auth-provider-redis`](../redis/README.md) — Redis-backed adapters (challenges, replay-seen-set, refresh-token-family, user-sessions, federation-tokens, **code-repository**, rate-limiter)
213
- - [auth.provider](../../README.md) — Top-level repository documentation
388
+ Exported from [`src/index.mts`](src/index.mts):
389
+
390
+ - `registerBuiltinAdapters({ userFactory })` — registers the `"http"` type.
391
+ - `HttpUserRepository` — the repository
392
+ ([`src/repositories/HttpUserRepository.mts`](src/repositories/HttpUserRepository.mts)).
393
+ - `StoreCredentialRefusedError` — what a refused credential throws; its
394
+ `name` and `storeStatus` (`401` or `403`) are part of the contract. Not
395
+ `status`: Express, http-errors and the standalone's terminal handler read
396
+ that as the status to answer with.
397
+ - `StoreTransportError`, `StoreTransportFailure` — what a transport failure
398
+ throws; its `name`, `reason` and `code` are part of the contract, and it has
399
+ no `status` either
400
+ ([`src/repositories/storeErrors.mts`](src/repositories/storeErrors.mts)).
401
+ - `DEFAULT_MAX_RESPONSE_BYTES` — the default response cap.
402
+ - `FederatedIdentityLookupCoverage` — the type of one coverage entry.
403
+
404
+ ## Tests
405
+
406
+ | Test file | Pins |
407
+ | --- | --- |
408
+ | [`HttpUserRepository.test.mts`](src/repositories/__tests__/HttpUserRepository.test.mts) | authentication and its answers, the `User` shape check, the https rule, the timeout and the response cap, linking, and the identity lookup's presence, probe and wire |
409
+ | [`HttpUserRepository.transport.test.mts`](src/repositories/__tests__/HttpUserRepository.transport.test.mts) | against real HTTP servers: the identity lookup releasing a refused answer's connection, and a redirect refused on each of the four requests — to another origin, to the same origin, or with no `Location` — with nothing sent to a redirect target |
410
+ | [`HttpUserRepository.credential.test.mts`](src/repositories/__tests__/HttpUserRepository.credential.test.mts) | against real HTTP servers: `Authorization: Bearer <token>` on each of the four requests when `bearerToken` is set and no `Authorization` header when it is not, by hand and through the `"http"` builder; a weak, malformed, blank or non-string token refused at construction; the token in no failure and no inspection of the repository; a transport failure a `StoreTransportError` that says what failed — a refused connection and an https URL on a plain-HTTP port (not reached), a peer that reflects the token into a status line or header, a head over the size limit (malformed response), a close after a `1xx`, mid-head, before any byte, or of a pooled keep-alive connection between two requests (connection closed), a chunked body (not read), a TLS 1.2 server that refuses the handshake (not reached, `ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE`) — with its code and no cause; a timeout a `TimeoutError` whether the headers or the body stall; with a token sent, a `401` or `403` with a `Bearer` challenge a `StoreCredentialRefusedError` (`storeStatus`, no `status`) on each of the four, and one without — or with no token sent — read as before |
411
+ | [`storeErrors.test.mts`](src/repositories/__tests__/storeErrors.test.mts) | which transport codes are kept, and the two named errors' shape — no `status`, no cause |
412
+ | [`wwwAuthenticate.test.mts`](src/repositories/__tests__/wwwAuthenticate.test.mts) | which `WWW-Authenticate` values carry a `Bearer` challenge, and a hostile 64 KiB value read in one pass |
413
+ | [`registerBuiltinAdapters.test.mts`](src/repositories/__tests__/registerBuiltinAdapters.test.mts) | the `"http"` builder, its defaults and string coercion, and configuration refused at build time |
414
+ | [`endpointUrl.test.mts`](src/__tests__/endpointUrl.test.mts) | the https-or-loopback rule |
415
+
416
+ ## See also
417
+
418
+ - [`@o3co/auth-provider-core`](../core/README.md) — the `UserRepository` port,
419
+ `createRepositoryFactories` and the development user adapters
420
+ - [`@o3co/auth-provider-session`](../session/README.md) — the routes that call
421
+ `authenticate`, `authenticateByToken` and `linkFederatedIdentity`
422
+ - [`@o3co/auth-provider-federation-grants`](../federation-grants/README.md) — the
423
+ caller of the identity lookup
424
+ - [auth.provider](../../README.md) — top-level repository documentation
@@ -27,6 +27,15 @@
27
27
  * runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
28
28
  * `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
29
29
  * session redirect policy consumes, so the carve-outs cannot drift apart.
30
+ *
31
+ * ## What a message names of an endpoint
32
+ *
33
+ * Every failure the repository throws names the endpoint it was talking to,
34
+ * and every caller logs what it throws. A message names the endpoint by its
35
+ * origin and path alone ({@link endpointForMessage}): a query string is
36
+ * accepted in a Store URL, and a deployment may carry a credential there
37
+ * (`?api_key=…`) despite the README's advice, so the query never reaches a
38
+ * message — nor does a fragment, which no request sends anyway.
30
39
  */
31
40
  import { isLoopbackHostname } from "@o3co/auth-provider-core";
32
41
  export { isLoopbackHostname };
@@ -56,4 +65,15 @@ export declare function describeEndpointRejection(reason: EndpointRejection): st
56
65
  * lands in logs.
57
66
  */
58
67
  export declare function assertSecureEndpoint(value: unknown, field: string): string;
68
+ /**
69
+ * `url` as a message names it: its origin and path — `https://store.example
70
+ * /authenticate`, as the WHATWG URL parser normalises them (a default port
71
+ * dropped, scheme and host lower-cased, an IPv6 literal in brackets) — and
72
+ * never its query or fragment, which may carry a credential. Anything that is
73
+ * not an http or https URL is named by what it is not: its origin would be
74
+ * `"null"` and its "path" the rest of it (`data:…`, `mailto:…`), and the
75
+ * constructor refuses such a Store URL anyway — but a caller can hand
76
+ * `StoreCredentialRefusedError` anything.
77
+ */
78
+ export declare function endpointForMessage(url: string): string;
59
79
  //# sourceMappingURL=endpointUrl.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"endpointUrl.d.mts","sourceRoot":"","sources":["../src/endpointUrl.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAC1B,cAAc,GACd,OAAO,GACP,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,iBAAiB,CAAC;AAKrB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAyB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAS1E"}
1
+ {"version":3,"file":"endpointUrl.d.mts","sourceRoot":"","sources":["../src/endpointUrl.mts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAK9D,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B,4EAA4E;AAC5E,MAAM,MAAM,iBAAiB,GAC1B,cAAc,GACd,OAAO,GACP,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,iBAAiB,CAAC;AAKrB;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,IAAI,CAyB5E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,CAwB3E;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAS1E;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD"}
@@ -42,6 +42,15 @@
42
42
  * runs on, though, is shared vocabulary: `isLoopbackHostname` comes from
43
43
  * `@o3co/auth-provider-core` (`net/loopback`, #364), the same definition the
44
44
  * session redirect policy consumes, so the carve-outs cannot drift apart.
45
+ *
46
+ * ## What a message names of an endpoint
47
+ *
48
+ * Every failure the repository throws names the endpoint it was talking to,
49
+ * and every caller logs what it throws. A message names the endpoint by its
50
+ * origin and path alone ({@link endpointForMessage}): a query string is
51
+ * accepted in a Store URL, and a deployment may carry a credential there
52
+ * (`?api_key=…`) despite the README's advice, so the query never reaches a
53
+ * message — nor does a fragment, which no request sends anyway.
45
54
  */
46
55
  import { isLoopbackHostname } from "@o3co/auth-provider-core";
47
56
  // Re-exported unchanged: this module's callers (and its tests) read the
@@ -127,3 +136,24 @@ export function assertSecureEndpoint(value, field) {
127
136
  }
128
137
  return value;
129
138
  }
139
+ /**
140
+ * `url` as a message names it: its origin and path — `https://store.example
141
+ * /authenticate`, as the WHATWG URL parser normalises them (a default port
142
+ * dropped, scheme and host lower-cased, an IPv6 literal in brackets) — and
143
+ * never its query or fragment, which may carry a credential. Anything that is
144
+ * not an http or https URL is named by what it is not: its origin would be
145
+ * `"null"` and its "path" the rest of it (`data:…`, `mailto:…`), and the
146
+ * constructor refuses such a Store URL anyway — but a caller can hand
147
+ * `StoreCredentialRefusedError` anything.
148
+ */
149
+ export function endpointForMessage(url) {
150
+ try {
151
+ const { protocol, origin, pathname } = new URL(url);
152
+ if (protocol === "http:" || protocol === "https:")
153
+ return `${origin}${pathname}`;
154
+ }
155
+ catch {
156
+ // Named below, like any other value that is not an http or https URL.
157
+ }
158
+ return "(an endpoint that is not an http or https URL)";
159
+ }
package/dist/index.d.mts CHANGED
@@ -3,4 +3,5 @@ export declare const registerBuiltinAdapters: (factories: {
3
3
  userFactory: AdapterFactory<UserRepository>;
4
4
  }) => void;
5
5
  export { DEFAULT_MAX_RESPONSE_BYTES, type FederatedIdentityLookupCoverage, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
6
+ export { StoreCredentialRefusedError, StoreTransportError, type StoreTransportFailure, } from "./repositories/storeErrors.mjs";
6
7
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA8B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IAqCH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,KAAK,+BAA+B,EACpC,kBAAkB,GAClB,MAAM,uCAAuC,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA8B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IA0CH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,KAAK,+BAA+B,EACpC,kBAAkB,GAClB,MAAM,uCAAuC,CAAC;AAC/C,OAAO,EACN,2BAA2B,EAC3B,mBAAmB,EACnB,KAAK,qBAAqB,GAC1B,MAAM,gCAAgC,CAAC"}
package/dist/index.mjs CHANGED
@@ -70,9 +70,15 @@ export const registerBuiltinAdapters = (factories) => {
70
70
  federatedIdentityLookupCoverage: config.federatedIdentityLookupCoverage,
71
71
  }
72
72
  : {}),
73
+ // Optional, and forwarded whenever SET for the lookup URL's reason: a
74
+ // token that vanished would be a deployment that believes its Store
75
+ // calls authenticated and sends them bare. The constructor refuses
76
+ // what is not a string, and what is blank, malformed or too weak.
77
+ ...(config.bearerToken !== undefined ? { bearerToken: config.bearerToken } : {}),
73
78
  timeout: toNumber(config.timeout, DEFAULT_TIMEOUT_MS),
74
79
  maxResponseBytes: toNumber(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
75
80
  });
76
81
  });
77
82
  };
78
83
  export { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
84
+ export { StoreCredentialRefusedError, StoreTransportError, } from "./repositories/storeErrors.mjs";