@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 +78 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14 -0
- package/dist/repositories/HttpUserRepository.d.mts +57 -2
- package/dist/repositories/HttpUserRepository.d.mts.map +1 -1
- package/dist/repositories/HttpUserRepository.mjs +246 -1
- package/package.json +3 -3
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
|
|
80
|
-
password on `authenticateUrl`, a token on `authenticateByTokenUrl
|
|
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
|
package/dist/index.d.mts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
43
|
+
"@o3co/auth-provider-core": "0.15.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"prebuild": "rimraf dist",
|