@o3co/auth-provider-foundation 0.14.0 → 0.15.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
@@ -41,6 +41,8 @@ class HttpUserRepository implements UserRepository {
41
41
  authenticateUrl: string; // POST endpoint for username/password auth
42
42
  authenticateByTokenUrl: string; // POST endpoint for token-based auth
43
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)
44
46
  timeout: number; // request timeout in milliseconds
45
47
  maxResponseBytes?: number; // response body cap, default 1 MiB
46
48
  });
@@ -54,9 +56,18 @@ class HttpUserRepository implements UserRepository {
54
56
  // POST linkFederatedIdentityUrl with body: { userId, provider, sub, token, claims }
55
57
  // — present only when the URL is configured (#482)
56
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>;
57
66
  }
58
67
  ```
59
68
 
69
+ For `authenticate` and `authenticateByToken`:
70
+
60
71
  - Returns `null` on HTTP 401 or 403.
61
72
  - Throws an error on any other non-OK HTTP status.
62
73
  - Throws when the upstream returns 2xx with a body that is not a JSON `User`
@@ -71,13 +82,78 @@ class HttpUserRepository implements UserRepository {
71
82
  e-mail alone — is in the
72
83
  [session package README](../session/README.md#account-linking-across-federations-482).
73
84
 
85
+ #### The identity lookup (#613)
86
+
87
+ 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
89
+ delegation is refused when the account belongs to another local user. The
90
+ Store answers about ownership across **every** registration of the IdP, not
91
+ only the one the identity came through — a login links under the federation
92
+ the user signed in through, and an IdP whose `sub` is pairwise per app
93
+ registration (Entra's is) gives the same person another `sub` under each. For
94
+ D19's separate grants registration, that means: to answer `unlinked`
95
+ honestly, your Store must resolve the verified `(tid, oid)` against an
96
+ authoritative directory complete for the relevant tenant and all local
97
+ ownership links across registrations; no match in a partial directory or an
98
+ unfamiliar identity is `identity_not_resolvable`, and multiple distinct local
99
+ owners are a server error. Declaring coverage attests to that strategy; boot
100
+ neither discovers nor proves the remote directory's completeness.
101
+
102
+ The request is `POST findSubjectByFederatedIdentityUrl` with
103
+
104
+ ```json
105
+ { "provider": "entra-files", "issuer": "https://login.microsoftonline.com/<tenant>/v2.0",
106
+ "clientId": "<the grants app registration>", "sub": "<verified sub>",
107
+ "claims": { "tid": "<verified>", "oid": "<verified>" } }
108
+ ```
109
+
110
+ — the registration exactly as the connection is configured, the verified
111
+ `sub`, and every claim the connection's `identityClaims` named (verified,
112
+ from the id_token; `{}` when it named none). The lookup must change nothing:
113
+ no login stamped, no link made, no user provisioned. The answer is a `2xx`
114
+ JSON body, one of
115
+
116
+ | Body | Meaning |
117
+ |---|---|
118
+ | `{ "kind": "linked", "subject": "<local User.id>" }` | exactly one local user holds it. `subject` is that user's `id` **byte for byte** — the callback compares it with the signed-in user's `sub` exactly, so a padded or otherwise normalised id reads as another user's |
119
+ | `{ "kind": "unlinked" }` | a **complete** resolution found nobody |
120
+ | `{ "kind": "indeterminate", "reason": "registration_not_covered" }` | no strategy for this registration |
121
+ | `{ "kind": "indeterminate", "reason": "identity_not_resolvable" }` | a strategy, and this identity is not in it |
122
+
123
+ Fields beyond those are ignored. **Everything else is an outage, never
124
+ "nobody"**: any status but a `2xx` — `404`, `401`, `403`, `409`, `5xx` — a
125
+ `2xx` whose body is empty or not one of the four, a redirect (never followed:
126
+ the body carries a verified identity), a timeout, or a body over the cap all
127
+ throw, and the callback answers `temporarily_unavailable`. More than one
128
+ distinct local owner is a `500` from the Store. What is thrown names the
129
+ endpoint — and, for an answer with a status, the status — never the body,
130
+ the identity, a status text or an underlying cause.
131
+
132
+ **Coverage.** The probe the grants module asks at boot is synchronous and
133
+ cannot reach the Store, so `federatedIdentityLookupCoverage` relays what the
134
+ Store's lookup covers: one entry per registration,
135
+ `{ provider, issuer, clientId, requiredClaims }`, all four fields, compared
136
+ exactly (no trimming, no case folding, no trailing-slash tolerance), and
137
+ `requiredClaims` the claim names the strategy needs — `[]` for one on the
138
+ registration and `sub` alone. `supportsFederatedIdentityLookup(registration,
139
+ 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
142
+ answered `registration_not_covered` locally, and a declared one arriving
143
+ without a required claim `identity_not_resolvable`, with no request either
144
+ way. The declaration is snapshotted at construction; a duplicate registration,
145
+ a malformed entry, or coverage without the URL is a construction error. It has
146
+ no environment-variable form (a list is HOCON's), as `federationGrants.connections`
147
+ has none.
148
+
74
149
  ### Constructor validation
75
150
 
76
151
  Every option is validated in the **constructor**, so a misconfigured deployment
77
152
  fails at boot rather than at the first login attempt.
78
153
 
79
- **Every URL must use `https://`** (the link endpoint included). They carry plaintext user credentials — a
80
- password on `authenticateUrl`, a token on `authenticateByTokenUrl` — so an
154
+ **Every URL must use `https://`** (the link and lookup endpoints included). They carry plaintext user credentials — a
155
+ password on `authenticateUrl`, a token on `authenticateByTokenUrl`, a verified
156
+ upstream identity on `findSubjectByFederatedIdentityUrl` — so an
81
157
  `http://` URL does not merely weaken the connection, it publishes the credential
82
158
  to every hop on the path.
83
159
 
package/dist/index.d.mts CHANGED
@@ -2,5 +2,5 @@ import type { AdapterFactory, UserRepository } from "@o3co/auth-provider-core";
2
2
  export declare const registerBuiltinAdapters: (factories: {
3
3
  userFactory: AdapterFactory<UserRepository>;
4
4
  }) => void;
5
- export { DEFAULT_MAX_RESPONSE_BYTES, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
5
+ export { DEFAULT_MAX_RESPONSE_BYTES, type FederatedIdentityLookupCoverage, HttpUserRepository, } from "./repositories/HttpUserRepository.mjs";
6
6
  //# 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;AA6B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IAsBH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,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,IAqCH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,KAAK,+BAA+B,EACpC,kBAAkB,GAClB,MAAM,uCAAuC,CAAC"}
package/dist/index.mjs CHANGED
@@ -56,6 +56,20 @@ export const registerBuiltinAdapters = (factories) => {
56
56
  ...(typeof config.linkFederatedIdentityUrl === "string"
57
57
  ? { linkFederatedIdentityUrl: config.linkFederatedIdentityUrl }
58
58
  : {}),
59
+ // #613: optional. Forwarded whenever SET, not only when well-typed —
60
+ // the link URL above vanishes when misspelt, and for the lookup a value
61
+ // that vanishes is a deployment that believes itself covered and is
62
+ // not. The constructor refuses what is not a URL, or not a list.
63
+ ...(config.findSubjectByFederatedIdentityUrl !== undefined
64
+ ? {
65
+ findSubjectByFederatedIdentityUrl: config.findSubjectByFederatedIdentityUrl,
66
+ }
67
+ : {}),
68
+ ...(config.federatedIdentityLookupCoverage !== undefined
69
+ ? {
70
+ federatedIdentityLookupCoverage: config.federatedIdentityLookupCoverage,
71
+ }
72
+ : {}),
59
73
  timeout: toNumber(config.timeout, DEFAULT_TIMEOUT_MS),
60
74
  maxResponseBytes: toNumber(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
61
75
  });
@@ -1,4 +1,4 @@
1
- import type { FederatedIdentityLink, LinkFederatedIdentityResult, User, UserRepository } from "@o3co/auth-provider-core";
1
+ import type { FederatedIdentityLink, FederatedIdentityLookup, FederatedIdentityLookupResult, FederatedIdentityRegistration, LinkFederatedIdentityResult, User, UserRepository } from "@o3co/auth-provider-core";
2
2
  /**
3
3
  * Default ceiling on an upstream response body, in bytes.
4
4
  *
@@ -7,6 +7,21 @@ import type { FederatedIdentityLink, LinkFederatedIdentityResult, User, UserRepo
7
7
  * process out of memory one login at a time (#285).
8
8
  */
9
9
  export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
10
+ /**
11
+ * What the Store declares it can place (#613): identities issued under one
12
+ * registration — the federation's name, the issuer and the client, exactly as
13
+ * a federation-grant connection is configured — given at least these claims
14
+ * from the verified id_token. The synchronous probe D7 check 5 asks at boot
15
+ * cannot reach the Store, so the operator relays the Store's own claim here;
16
+ * boot then holds every connection to it. `requiredClaims = []` is a strategy
17
+ * on the registration and the `sub` alone.
18
+ */
19
+ export interface FederatedIdentityLookupCoverage {
20
+ readonly provider: string;
21
+ readonly issuer: string;
22
+ readonly clientId: string;
23
+ readonly requiredClaims: readonly string[];
24
+ }
10
25
  /**
11
26
  * `UserRepository` backed by "the Store" — the upstream user service defined
12
27
  * on core's `User` doc (`@o3co/auth-provider-core`, `src/repositories/types.mts`).
@@ -23,17 +38,33 @@ export declare class HttpUserRepository implements UserRepository {
23
38
  private timeout;
24
39
  private maxResponseBytes;
25
40
  private linkFederatedIdentityUrl?;
41
+ private findSubjectByFederatedIdentityUrl?;
42
+ private readonly coverage;
26
43
  /**
27
44
  * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
28
45
  * `linkFederatedIdentityUrl` is configured, which is how the federation routes
29
46
  * know to refuse `?link=1` up front instead of at the callback.
30
47
  */
31
48
  readonly linkFederatedIdentity?: (userId: string, identity: FederatedIdentityLink) => Promise<LinkFederatedIdentityResult>;
32
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, timeout, maxResponseBytes, }: {
49
+ /**
50
+ * #613 — see {@link UserRepository.supportsFederatedIdentityLookup}. Present
51
+ * together with the lookup, only when `findSubjectByFederatedIdentityUrl` is
52
+ * configured: absent, a deployment that requires the lookup is refused at
53
+ * boot by the method's name, which is the message that says what to set.
54
+ * Answers from the declaration alone — the Store is not asked at boot.
55
+ */
56
+ readonly supportsFederatedIdentityLookup?: (registration: FederatedIdentityRegistration, identityClaims: readonly string[]) => boolean;
57
+ /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
58
+ readonly findSubjectByFederatedIdentity?: (identity: FederatedIdentityLookup) => Promise<FederatedIdentityLookupResult>;
59
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, timeout, maxResponseBytes, }: {
33
60
  authenticateUrl: string;
34
61
  authenticateByTokenUrl: string;
35
62
  /** #482: optional; the Store's link endpoint. Same https rule as the other two. */
36
63
  linkFederatedIdentityUrl?: string;
64
+ /** #613: optional; the Store's identity lookup. Same https rule; carries a verified identity. */
65
+ findSubjectByFederatedIdentityUrl?: string;
66
+ /** #613: which registrations the Store's lookup covers. Needs the URL; `[]` covers none. */
67
+ federatedIdentityLookupCoverage?: readonly FederatedIdentityLookupCoverage[];
37
68
  timeout: number;
38
69
  maxResponseBytes?: number;
39
70
  });
@@ -46,6 +77,30 @@ export declare class HttpUserRepository implements UserRepository {
46
77
  * provider, …); `409` says the identity is already someone else's.
47
78
  */
48
79
  private linkViaHttp;
80
+ /** The declaration for a registration, if the operator made one. Exact on all three. */
81
+ private declared;
82
+ /**
83
+ * POST `{ provider, issuer, clientId, sub, claims }` to the Store's lookup
84
+ * (#613) and read one of the port's three answers. Answered locally, with
85
+ * no request, where the declaration already decides: a registration nobody
86
+ * declared is `registration_not_covered`, and a declared one arriving
87
+ * without a claim its strategy needs is `identity_not_resolvable`. Every
88
+ * claim the connection supplied is sent, not only the required ones: what
89
+ * the Store matches on is the Store's business.
90
+ */
91
+ private lookupViaHttp;
92
+ /**
93
+ * The lookup's transport: the same deadline, cap and abort as {@link post},
94
+ * and none of its readings. A `401`/`403` is not "nobody", a `409` is not a
95
+ * conflict, a `404` is not an absence: only a `2xx` carrying one of the
96
+ * three answers is an answer, and everything else throws — as an outage,
97
+ * which is what a lookup that could not be made is. Redirects are never
98
+ * followed: the body carries a verified identity, and a `Location` is not a
99
+ * configured endpoint. What is thrown names the endpoint — and, for an
100
+ * answer with a status, the status — and never the body, the identity, a
101
+ * status text or an underlying cause.
102
+ */
103
+ private postLookup;
49
104
  private post;
50
105
  }
51
106
  //# sourceMappingURL=HttpUserRepository.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACX,qBAAqB,EACrB,2BAA2B,EAC3B,IAAI,EACJ,cAAc,EACd,MAAM,0BAA0B,CAAC;AAMlC;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAkItD;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,wBAAwB,CAAC,CAAS;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,qBAAqB,KAC3B,OAAO,CAAC,2BAA2B,CAAC,CAAC;gBAE9B,EACX,eAAe,EACf,sBAAsB,EACtB,wBAAwB,EACxB,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,mFAAmF;QACnF,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IA4BK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAKtE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAI9D;;;;;OAKG;YACW,WAAW;YAWX,IAAI;CA4ElB"}
1
+ {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACX,qBAAqB,EACrB,uBAAuB,EACvB,6BAA6B,EAC7B,6BAA6B,EAC7B,2BAA2B,EAC3B,IAAI,EACJ,cAAc,EACd,MAAM,0BAA0B,CAAC;AAMlC;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AA8BtD;;;;;;;;GAQG;AACH,MAAM,WAAW,+BAA+B;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AA4ND;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,wBAAwB,CAAC,CAAS;IAC1C,OAAO,CAAC,iCAAiC,CAAC,CAAS;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;IACtE;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,qBAAqB,KAC3B,OAAO,CAAC,2BAA2B,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,+BAA+B,CAAC,EAAE,CAC1C,YAAY,EAAE,6BAA6B,EAC3C,cAAc,EAAE,SAAS,MAAM,EAAE,KAC7B,OAAO,CAAC;IACb,gGAAgG;IAChG,QAAQ,CAAC,8BAA8B,CAAC,EAAE,CACzC,QAAQ,EAAE,uBAAuB,KAC7B,OAAO,CAAC,6BAA6B,CAAC,CAAC;gBAEhC,EACX,eAAe,EACf,sBAAsB,EACtB,wBAAwB,EACxB,iCAAiC,EACjC,+BAA+B,EAC/B,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,mFAAmF;QACnF,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,iGAAiG;QACjG,iCAAiC,CAAC,EAAE,MAAM,CAAC;QAC3C,4FAA4F;QAC5F,+BAA+B,CAAC,EAAE,SAAS,+BAA+B,EAAE,CAAC;QAC7E,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IAkDK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAKtE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAI9D;;;;;OAKG;YACW,WAAW;IAWzB,wFAAwF;IACxF,OAAO,CAAC,QAAQ;IAWhB;;;;;;;;OAQG;YACW,aAAa;IAoB3B;;;;;;;;;;OAUG;YACW,UAAU;YAsEV,IAAI;CA4ElB"}
@@ -51,6 +51,110 @@ function isUser(v) {
51
51
  const o = v;
52
52
  return typeof o.id === "string" && typeof o.username === "string";
53
53
  }
54
+ const COVERAGE_FIELD = "federatedIdentityLookupCoverage";
55
+ const COVERAGE_KEYS = new Set([
56
+ "provider",
57
+ "issuer",
58
+ "clientId",
59
+ "requiredClaims",
60
+ ]);
61
+ /** A claim name: printable ASCII, no spaces (the shape the connection side accepts). */
62
+ const CLAIM_NAME = /^[\x21-\x7E]{1,256}$/;
63
+ const FORBIDDEN_CLAIM_NAMES = new Set([
64
+ "__proto__",
65
+ "constructor",
66
+ "prototype",
67
+ ]);
68
+ /** A registration field: a non-empty string carrying no surrounding whitespace, compared exactly. */
69
+ const exactString = (value) => typeof value === "string" && value.length > 0 && value.trim() === value;
70
+ const coverageError = (at, problem) => new Error(`HttpUserRepository: "${at}" ${problem}`);
71
+ /**
72
+ * The declaration, checked field by field and frozen — the list, each entry
73
+ * and each claim list — so that neither a later mutation of the caller's list
74
+ * nor anything in-process holding the repository can widen what the boot
75
+ * probe accepts. Diagnostics name the option, the entry and the field (a
76
+ * field NAME an operator wrote wrongly is quoted) — never a value: a
77
+ * registration's client id is configuration an operator may not want in a
78
+ * log line.
79
+ */
80
+ function validateCoverage(value) {
81
+ if (value === undefined)
82
+ return Object.freeze([]);
83
+ if (!Array.isArray(value)) {
84
+ throw coverageError(COVERAGE_FIELD, "must be a list of registrations");
85
+ }
86
+ const seen = new Set();
87
+ const entries = value.map((entry, index) => {
88
+ const at = `${COVERAGE_FIELD}[${index}]`;
89
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
90
+ throw coverageError(at, "must be an object with provider, issuer, clientId and requiredClaims");
91
+ }
92
+ for (const key of Object.keys(entry)) {
93
+ if (!COVERAGE_KEYS.has(key))
94
+ throw coverageError(at, `has a field it may not have: "${key}"`);
95
+ }
96
+ // Own properties only: a field that lives on the entry's prototype is
97
+ // not one the operator wrote.
98
+ const own = (field) => Object.hasOwn(entry, field) ? entry[field] : undefined;
99
+ const provider = own("provider");
100
+ const issuer = own("issuer");
101
+ const clientId = own("clientId");
102
+ const requiredClaims = own("requiredClaims");
103
+ for (const [field, candidate] of [
104
+ ["provider", provider],
105
+ ["issuer", issuer],
106
+ ["clientId", clientId],
107
+ ]) {
108
+ if (!exactString(candidate)) {
109
+ throw coverageError(`${at}.${field}`, "must be a non-empty string without surrounding whitespace");
110
+ }
111
+ }
112
+ if (!Array.isArray(requiredClaims)) {
113
+ throw coverageError(`${at}.requiredClaims`, "must be a list of claim names (empty for none)");
114
+ }
115
+ const names = new Set();
116
+ for (const name of requiredClaims) {
117
+ if (typeof name !== "string" || !CLAIM_NAME.test(name) || FORBIDDEN_CLAIM_NAMES.has(name)) {
118
+ throw coverageError(`${at}.requiredClaims`, "holds a name that is not a claim name");
119
+ }
120
+ if (names.has(name))
121
+ throw coverageError(`${at}.requiredClaims`, "lists a claim twice");
122
+ names.add(name);
123
+ }
124
+ const key = JSON.stringify([provider, issuer, clientId]);
125
+ if (seen.has(key))
126
+ throw coverageError(at, "declares a registration an earlier entry already declares");
127
+ seen.add(key);
128
+ return Object.freeze({
129
+ provider: provider,
130
+ issuer: issuer,
131
+ clientId: clientId,
132
+ requiredClaims: Object.freeze([...names]),
133
+ });
134
+ });
135
+ return Object.freeze(entries);
136
+ }
137
+ /** A lookup answer if it is one the port defines — a fresh object of the contract's fields, nothing else. */
138
+ function lookupAnswer(value) {
139
+ if (typeof value !== "object" || value === null || Array.isArray(value))
140
+ return undefined;
141
+ const answer = value;
142
+ switch (answer.kind) {
143
+ case "linked":
144
+ return typeof answer.subject === "string" && answer.subject.length > 0
145
+ ? { kind: "linked", subject: answer.subject }
146
+ : undefined;
147
+ case "unlinked":
148
+ return { kind: "unlinked" };
149
+ case "indeterminate":
150
+ return answer.reason === "registration_not_covered" ||
151
+ answer.reason === "identity_not_resolvable"
152
+ ? { kind: "indeterminate", reason: answer.reason }
153
+ : undefined;
154
+ default:
155
+ return undefined;
156
+ }
157
+ }
54
158
  /** Whether `value` is a positive integer that fits `bound`. */
55
159
  function isPositiveIntegerWithin(value, bound) {
56
160
  return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= bound;
@@ -155,19 +259,47 @@ export class HttpUserRepository {
155
259
  timeout;
156
260
  maxResponseBytes;
157
261
  linkFederatedIdentityUrl;
262
+ findSubjectByFederatedIdentityUrl;
263
+ coverage;
158
264
  /**
159
265
  * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
160
266
  * `linkFederatedIdentityUrl` is configured, which is how the federation routes
161
267
  * know to refuse `?link=1` up front instead of at the callback.
162
268
  */
163
269
  linkFederatedIdentity;
164
- constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
270
+ /**
271
+ * #613 — see {@link UserRepository.supportsFederatedIdentityLookup}. Present
272
+ * together with the lookup, only when `findSubjectByFederatedIdentityUrl` is
273
+ * configured: absent, a deployment that requires the lookup is refused at
274
+ * boot by the method's name, which is the message that says what to set.
275
+ * Answers from the declaration alone — the Store is not asked at boot.
276
+ */
277
+ supportsFederatedIdentityLookup;
278
+ /** #613 — see {@link UserRepository.findSubjectByFederatedIdentity}. Present with the probe. */
279
+ findSubjectByFederatedIdentity;
280
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, findSubjectByFederatedIdentityUrl, federatedIdentityLookupCoverage, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
165
281
  this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
166
282
  this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
167
283
  if (linkFederatedIdentityUrl !== undefined) {
168
284
  this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
169
285
  this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
170
286
  }
287
+ this.coverage = validateCoverage(federatedIdentityLookupCoverage);
288
+ if (findSubjectByFederatedIdentityUrl !== undefined) {
289
+ this.findSubjectByFederatedIdentityUrl = assertSecureEndpoint(findSubjectByFederatedIdentityUrl, "findSubjectByFederatedIdentityUrl");
290
+ this.supportsFederatedIdentityLookup = (registration, identityClaims) =>
291
+ // A list, as the port types it: `includes` on a string would match
292
+ // a substring, and a caller that is not the boot probe may hand
293
+ // over anything.
294
+ Array.isArray(identityClaims) &&
295
+ (this.declared(registration)?.requiredClaims.every((name) => identityClaims.includes(name)) ??
296
+ false);
297
+ this.findSubjectByFederatedIdentity = (identity) => this.lookupViaHttp(identity);
298
+ }
299
+ else if (this.coverage.length > 0) {
300
+ throw new Error(`HttpUserRepository: "${COVERAGE_FIELD}" declares what the Store's lookup covers, and no ` +
301
+ '"findSubjectByFederatedIdentityUrl" names that lookup — set the URL, or remove the declaration');
302
+ }
171
303
  if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
172
304
  throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
173
305
  `${MAX_TIMEOUT_MS} milliseconds`);
@@ -200,6 +332,119 @@ export class HttpUserRepository {
200
332
  return { ok: false, reason: "refused" };
201
333
  return { ok: true, user: answer };
202
334
  }
335
+ /** The declaration for a registration, if the operator made one. Exact on all three. */
336
+ declared(registration) {
337
+ return this.coverage.find((entry) => entry.provider === registration.provider &&
338
+ entry.issuer === registration.issuer &&
339
+ entry.clientId === registration.clientId);
340
+ }
341
+ /**
342
+ * POST `{ provider, issuer, clientId, sub, claims }` to the Store's lookup
343
+ * (#613) and read one of the port's three answers. Answered locally, with
344
+ * no request, where the declaration already decides: a registration nobody
345
+ * declared is `registration_not_covered`, and a declared one arriving
346
+ * without a claim its strategy needs is `identity_not_resolvable`. Every
347
+ * claim the connection supplied is sent, not only the required ones: what
348
+ * the Store matches on is the Store's business.
349
+ */
350
+ async lookupViaHttp(identity) {
351
+ const entry = this.declared(identity);
352
+ if (entry === undefined)
353
+ return { kind: "indeterminate", reason: "registration_not_covered" };
354
+ for (const name of entry.requiredClaims) {
355
+ const value = Object.hasOwn(identity.claims, name) ? identity.claims[name] : undefined;
356
+ if (typeof value !== "string" || value.length === 0) {
357
+ return { kind: "indeterminate", reason: "identity_not_resolvable" };
358
+ }
359
+ }
360
+ return this.postLookup(this.findSubjectByFederatedIdentityUrl, {
361
+ provider: identity.provider,
362
+ issuer: identity.issuer,
363
+ clientId: identity.clientId,
364
+ sub: identity.sub,
365
+ claims: { ...identity.claims },
366
+ });
367
+ }
368
+ /**
369
+ * The lookup's transport: the same deadline, cap and abort as {@link post},
370
+ * and none of its readings. A `401`/`403` is not "nobody", a `409` is not a
371
+ * conflict, a `404` is not an absence: only a `2xx` carrying one of the
372
+ * three answers is an answer, and everything else throws — as an outage,
373
+ * which is what a lookup that could not be made is. Redirects are never
374
+ * followed: the body carries a verified identity, and a `Location` is not a
375
+ * configured endpoint. What is thrown names the endpoint — and, for an
376
+ * answer with a status, the status — and never the body, the identity, a
377
+ * status text or an underlying cause.
378
+ */
379
+ async postLookup(url, body) {
380
+ const controller = new AbortController();
381
+ let timedOut = false;
382
+ const timeoutError = () => {
383
+ const error = new Error(`HttpUserRepository: request to ${url} timed out after ${this.timeout}ms`);
384
+ error.name = "TimeoutError";
385
+ return error;
386
+ };
387
+ let fireDeadline = () => { };
388
+ const deadline = new Promise((_resolve, reject) => {
389
+ fireDeadline = () => reject(timeoutError());
390
+ });
391
+ deadline.catch(() => { });
392
+ const timer = setTimeout(() => {
393
+ timedOut = true;
394
+ controller.abort();
395
+ fireDeadline();
396
+ }, this.timeout);
397
+ try {
398
+ let res;
399
+ try {
400
+ res = await fetch(url, {
401
+ method: "POST",
402
+ headers: { "Content-Type": "application/json" },
403
+ body: JSON.stringify(body),
404
+ signal: controller.signal,
405
+ redirect: "manual",
406
+ });
407
+ }
408
+ catch (err) {
409
+ if (timedOut && isAbortError(err))
410
+ throw timeoutError();
411
+ // A fixed message, without the cause: what a transport reports may
412
+ // quote what it was sending.
413
+ throw new Error(`HttpUserRepository: identity lookup at ${url} could not be reached`);
414
+ }
415
+ if (!res.ok) {
416
+ discardBody(res);
417
+ throw new Error(`HttpUserRepository: identity lookup at ${url} answered HTTP ${res.status}`);
418
+ }
419
+ let raw;
420
+ try {
421
+ raw = await readBodyCapped(res, this.maxResponseBytes, url, deadline);
422
+ }
423
+ catch (err) {
424
+ if (timedOut)
425
+ throw timeoutError();
426
+ if (err instanceof Error && err.message.startsWith("HttpUserRepository:"))
427
+ throw err;
428
+ throw new Error(`HttpUserRepository: identity lookup at ${url} could not be read`);
429
+ }
430
+ let parsed;
431
+ try {
432
+ parsed = JSON.parse(raw);
433
+ }
434
+ catch {
435
+ throw new Error(`HttpUserRepository: upstream ${url} returned a non-JSON body`);
436
+ }
437
+ const answer = lookupAnswer(parsed);
438
+ if (answer === undefined) {
439
+ throw new Error(`HttpUserRepository: identity lookup at ${url} answered a body that is not one of the ` +
440
+ "port's answers (linked / unlinked / indeterminate)");
441
+ }
442
+ return answer;
443
+ }
444
+ finally {
445
+ clearTimeout(timer);
446
+ }
447
+ }
203
448
  async post(url, body, options = {}) {
204
449
  const controller = new AbortController();
205
450
  let timedOut = false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-foundation",
3
3
  "description": "Production HTTP user-authentication adapter for auth.provider",
4
- "version": "0.14.0",
4
+ "version": "0.15.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">=22.0.0"
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "peerDependencies": {
35
- "@o3co/auth-provider-core": "^0.14.0"
35
+ "@o3co/auth-provider-core": "^0.15.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^26.5.1",
@@ -40,7 +40,7 @@
40
40
  "pino": "^10.3.1",
41
41
  "vitest": "^5.0.0",
42
42
  "@vitest/coverage-v8": "^5.0.0",
43
- "@o3co/auth-provider-core": "0.14.0"
43
+ "@o3co/auth-provider-core": "0.15.0"
44
44
  },
45
45
  "scripts": {
46
46
  "prebuild": "rimraf dist",