@spine-event-engine/auth 2.0.0-snapshot.26 → 2.0.0-snapshot.28
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 +102 -11
- package/REFERENCE.md +125 -3
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/oidc/contracts.d.ts +18 -4
- package/dist/oidc/contracts.d.ts.map +1 -1
- package/dist/oidc/flow-store.d.ts +143 -0
- package/dist/oidc/flow-store.d.ts.map +1 -0
- package/dist/oidc/flow-store.js +184 -0
- package/dist/oidc/flow-store.js.map +1 -0
- package/dist/oidc/index.d.ts +29 -9
- package/dist/oidc/index.d.ts.map +1 -1
- package/dist/oidc/index.js +611 -152
- package/dist/oidc/index.js.map +1 -1
- package/dist/oidc/persistent-flow-store.d.ts +117 -0
- package/dist/oidc/persistent-flow-store.d.ts.map +1 -0
- package/dist/oidc/persistent-flow-store.js +413 -0
- package/dist/oidc/persistent-flow-store.js.map +1 -0
- package/dist/oidc/records.d.ts +15 -0
- package/dist/oidc/records.d.ts.map +1 -0
- package/dist/oidc/records.js +91 -0
- package/dist/oidc/records.js.map +1 -0
- package/dist/providers/index.d.ts +10 -5
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +103 -30
- package/dist/providers/index.js.map +1 -1
- package/dist/sessions/cookies.d.ts +46 -8
- package/dist/sessions/cookies.d.ts.map +1 -1
- package/dist/sessions/cookies.js +269 -47
- package/dist/sessions/cookies.js.map +1 -1
- package/dist/sessions/opaque-session-store.d.ts +143 -0
- package/dist/sessions/opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/opaque-session-store.js +185 -0
- package/dist/sessions/opaque-session-store.js.map +1 -0
- package/dist/sessions/opaque.d.ts +93 -106
- package/dist/sessions/opaque.d.ts.map +1 -1
- package/dist/sessions/opaque.js +501 -232
- package/dist/sessions/opaque.js.map +1 -1
- package/dist/sessions/persistent-opaque-session-store.d.ts +105 -0
- package/dist/sessions/persistent-opaque-session-store.d.ts.map +1 -0
- package/dist/sessions/persistent-opaque-session-store.js +333 -0
- package/dist/sessions/persistent-opaque-session-store.js.map +1 -0
- package/dist/sessions/persistent-token-revocations.d.ts +90 -0
- package/dist/sessions/persistent-token-revocations.d.ts.map +1 -0
- package/dist/sessions/persistent-token-revocations.js +422 -0
- package/dist/sessions/persistent-token-revocations.js.map +1 -0
- package/dist/sessions/session-lifetime.d.ts +67 -0
- package/dist/sessions/session-lifetime.d.ts.map +1 -0
- package/dist/sessions/session-lifetime.js +165 -0
- package/dist/sessions/session-lifetime.js.map +1 -0
- package/dist/sessions/session-record.d.ts +15 -0
- package/dist/sessions/session-record.d.ts.map +1 -0
- package/dist/sessions/session-record.js +109 -0
- package/dist/sessions/session-record.js.map +1 -0
- package/dist/sessions/signed.d.ts +5 -4
- package/dist/sessions/signed.d.ts.map +1 -1
- package/dist/sessions/signed.js +212 -74
- package/dist/sessions/signed.js.map +1 -1
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +260 -26
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -6
package/dist/oidc/index.js
CHANGED
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
* the License.
|
|
13
13
|
*/
|
|
14
14
|
import { Time } from "@spine-event-engine/core/time";
|
|
15
|
-
import { createHash, randomBytes as nodeRandomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { createHash, randomBytes as nodeRandomBytes, randomUUID, timingSafeEqual, } from "node:crypto";
|
|
16
16
|
import { create } from "@bufbuild/protobuf";
|
|
17
17
|
import { TimestampSchema } from "@bufbuild/protobuf/wkt";
|
|
18
|
+
import { OidcLoginGrantSchema, OidcLoginTransactionSchema, } from "@spine-event-engine/proto/auth";
|
|
19
|
+
import { Configuration, buildAuthorizationUrl, calculatePKCECodeChallenge } from "openid-client";
|
|
20
|
+
import { MemoryOidcFlowStore } from "./flow-store.js";
|
|
21
|
+
import { OidcFlowKeys } from "./records.js";
|
|
22
|
+
import { SessionRecords } from "../sessions/session-record.js";
|
|
18
23
|
const RANDOM_BYTES = 32;
|
|
19
24
|
const DEFAULT_TRANSACTION_TTL = 5 * 60 * 1_000;
|
|
20
25
|
const DEFAULT_GRANT_TTL = 60 * 1_000;
|
|
@@ -28,10 +33,12 @@ const RFC7636_VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
|
28
33
|
/**
|
|
29
34
|
* Bounded, framework-neutral authorization-code transaction manager.
|
|
30
35
|
*
|
|
31
|
-
* It
|
|
36
|
+
* It manages finite start, callback, and one-time application-session exchange
|
|
32
37
|
* transitions; HTTP endpoints and provider discovery remain application work.
|
|
33
38
|
*/
|
|
34
39
|
export class OidcFlow {
|
|
40
|
+
#store;
|
|
41
|
+
#localStore;
|
|
35
42
|
#authorizationEndpoint;
|
|
36
43
|
#callbackUri;
|
|
37
44
|
#clientId;
|
|
@@ -45,15 +52,18 @@ export class OidcFlow {
|
|
|
45
52
|
#random;
|
|
46
53
|
#transactionTtlMilliseconds;
|
|
47
54
|
#grantTtlMilliseconds;
|
|
48
|
-
#maxTransactions;
|
|
49
|
-
#maxGrants;
|
|
50
55
|
#collisionAttempts;
|
|
51
56
|
#operationTimeoutMilliseconds;
|
|
52
57
|
#maxAuthorizationUrlLength;
|
|
53
58
|
#transactions = new Map();
|
|
54
59
|
#grants = new Map();
|
|
55
60
|
#callbacks = new Set();
|
|
61
|
+
#rawCollaborators = new Set();
|
|
62
|
+
#lateIssuerFailure;
|
|
56
63
|
#closed = false;
|
|
64
|
+
#active = 0;
|
|
65
|
+
#drained;
|
|
66
|
+
#closing;
|
|
57
67
|
/**
|
|
58
68
|
* Creates a bounded authorization-code flow.
|
|
59
69
|
* @param options The trusted provider, callback, transaction, grant, and session settings.
|
|
@@ -74,31 +84,58 @@ export class OidcFlow {
|
|
|
74
84
|
const limits = OidcFlowValues.limits(options);
|
|
75
85
|
this.#transactionTtlMilliseconds = limits.transactionTtlMilliseconds;
|
|
76
86
|
this.#grantTtlMilliseconds = limits.grantTtlMilliseconds;
|
|
77
|
-
this.#maxTransactions = limits.maxTransactions;
|
|
78
|
-
this.#maxGrants = limits.maxGrants;
|
|
79
87
|
this.#collisionAttempts = limits.collisionAttempts;
|
|
80
88
|
this.#operationTimeoutMilliseconds = limits.operationTimeoutMilliseconds;
|
|
81
89
|
this.#maxAuthorizationUrlLength = limits.maxAuthorizationUrlLength;
|
|
90
|
+
this.#localStore = this.#selectMemoryStore(options, limits);
|
|
91
|
+
const selectedStore = options.store ?? this.#localStore;
|
|
92
|
+
if (selectedStore === undefined)
|
|
93
|
+
throw new Error("OIDC flow store is unavailable.");
|
|
94
|
+
this.#store = selectedStore;
|
|
95
|
+
if (typeof this.#store.flowId !== "string" || this.#store.flowId.trim().length === 0)
|
|
96
|
+
throw new TypeError("OIDC flow store must provide a nonempty flow identity.");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Selects process-local storage only when no store was injected.
|
|
100
|
+
* @param options Flow configuration and optional injected store.
|
|
101
|
+
* @param limits Validated memory capacity settings.
|
|
102
|
+
* @returns A local store, or undefined when the caller supplied one.
|
|
103
|
+
*/
|
|
104
|
+
#selectMemoryStore(options, limits) {
|
|
105
|
+
if (options.store !== undefined) {
|
|
106
|
+
if (options.maxTransactions !== undefined || options.maxGrants !== undefined)
|
|
107
|
+
throw new TypeError("Memory-only OIDC capacity options require the default store.");
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return new MemoryOidcFlowStore(randomUUID(), () => OidcFlowValues.timestamp(this.#clock.now()), limits.maxTransactions, limits.maxGrants);
|
|
82
111
|
}
|
|
83
112
|
/**
|
|
84
113
|
* Starts one finite authorization-code transaction without exposing any application credential.
|
|
85
|
-
* @param input The browser
|
|
114
|
+
* @param input The browser challenge and post-login redirect.
|
|
115
|
+
* @param signal Optional host request cancellation; ordinary abort rejects with its reason.
|
|
86
116
|
* @returns The authorization URL and state, or a rejection.
|
|
87
117
|
*/
|
|
88
|
-
start(input) {
|
|
118
|
+
async start(input, signal) {
|
|
119
|
+
return this.#run(() => this.#start(input, signal));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Validates a start request and retries bounded entropy collisions.
|
|
123
|
+
* @param input Browser challenge and allowed redirect.
|
|
124
|
+
* @returns A persisted start or a rejected attempt.
|
|
125
|
+
*/
|
|
126
|
+
async #start(input, signal) {
|
|
89
127
|
if (this.#isClosed())
|
|
90
128
|
return OidcFlowValues.rejected("closed");
|
|
129
|
+
signal?.throwIfAborted();
|
|
91
130
|
const request = OidcFlowValues.snapshotStartInput(input);
|
|
92
131
|
if (request === undefined ||
|
|
93
132
|
!OidcFlowValues.validStartInput(request, this.#allowedPostLoginRedirects))
|
|
94
133
|
return OidcFlowValues.rejected("invalid-input");
|
|
95
134
|
const now = this.#now();
|
|
96
|
-
if (now === undefined)
|
|
97
|
-
return OidcFlowValues.rejected("closed");
|
|
98
|
-
if (this.#closed)
|
|
135
|
+
if (now === undefined || this.#isClosed())
|
|
99
136
|
return OidcFlowValues.rejected("closed");
|
|
100
137
|
this.#sweepExpired(now);
|
|
101
|
-
if (this.#
|
|
138
|
+
if (this.#localStore?.transactionsFull())
|
|
102
139
|
return OidcFlowValues.rejected("capacity-exceeded");
|
|
103
140
|
for (let attempt = 0; attempt < this.#collisionAttempts; attempt += 1) {
|
|
104
141
|
const material = this.#transactionMaterial();
|
|
@@ -108,32 +145,9 @@ export class OidcFlow {
|
|
|
108
145
|
continue;
|
|
109
146
|
}
|
|
110
147
|
try {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
return
|
|
114
|
-
this.#sweepExpired(current);
|
|
115
|
-
if (this.#isClosed())
|
|
116
|
-
return OidcFlowValues.rejected("closed");
|
|
117
|
-
if (this.#transactions.size >= this.#maxTransactions)
|
|
118
|
-
return OidcFlowValues.rejected("capacity-exceeded");
|
|
119
|
-
if (this.#transactions.has(material.state) || this.#transactionMaterialInUse(material))
|
|
120
|
-
continue;
|
|
121
|
-
const transactionExpiry = OidcFlowValues.expiryAt(current, this.#transactionTtlMilliseconds);
|
|
122
|
-
if (transactionExpiry === undefined) {
|
|
123
|
-
this.#failClosed();
|
|
124
|
-
return OidcFlowValues.rejected("clock-failure");
|
|
125
|
-
}
|
|
126
|
-
const authorizationUrl = this.#authorizationUrl(material.state, material.nonce, Buffer.from(material.providerVerifierBytes).toString("base64url"), request.browserCodeChallenge);
|
|
127
|
-
if (authorizationUrl === undefined)
|
|
128
|
-
return OidcFlowValues.rejected("invalid-input");
|
|
129
|
-
this.#transactions.set(material.state, {
|
|
130
|
-
nonce: material.nonce,
|
|
131
|
-
providerVerifier: Uint8Array.from(material.providerVerifierBytes),
|
|
132
|
-
browserCodeChallenge: request.browserCodeChallenge,
|
|
133
|
-
postLoginRedirect: request.postLoginRedirect,
|
|
134
|
-
expiresAt: transactionExpiry,
|
|
135
|
-
});
|
|
136
|
-
return Object.freeze({ kind: "started", authorizationUrl, expiresAt: transactionExpiry });
|
|
148
|
+
const result = await this.#attemptStart(material, request, signal);
|
|
149
|
+
if (result !== undefined)
|
|
150
|
+
return result;
|
|
137
151
|
}
|
|
138
152
|
finally {
|
|
139
153
|
material.bytes.forEach((bytes) => bytes.fill(0));
|
|
@@ -141,78 +155,269 @@ export class OidcFlow {
|
|
|
141
155
|
}
|
|
142
156
|
return OidcFlowValues.rejected("entropy-exhausted");
|
|
143
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Persists one fully bound transaction before returning its authorization URL.
|
|
160
|
+
* @param material Fresh state, nonce, and verifier bytes.
|
|
161
|
+
* @param request Validated browser start request.
|
|
162
|
+
* @returns A start result, or undefined after a collision.
|
|
163
|
+
*/
|
|
164
|
+
async #attemptStart(material, request, signal) {
|
|
165
|
+
const current = this.#now();
|
|
166
|
+
if (current === undefined)
|
|
167
|
+
return OidcFlowValues.rejected("closed");
|
|
168
|
+
this.#sweepExpired(current);
|
|
169
|
+
if (this.#isClosed())
|
|
170
|
+
return OidcFlowValues.rejected("closed");
|
|
171
|
+
if (this.#localStore?.transactionsFull())
|
|
172
|
+
return OidcFlowValues.rejected("capacity-exceeded");
|
|
173
|
+
if (this.#transactions.has(material.state) || this.#transactionMaterialInUse(material))
|
|
174
|
+
return undefined;
|
|
175
|
+
const expiry = OidcFlowValues.expiryAt(current, this.#transactionTtlMilliseconds);
|
|
176
|
+
if (expiry === undefined) {
|
|
177
|
+
this.#failClosed();
|
|
178
|
+
return OidcFlowValues.rejected("clock-failure");
|
|
179
|
+
}
|
|
180
|
+
const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
|
|
181
|
+
const authorizationUrl = await this.#authorizationUrl(material.state, material.nonce, verifier);
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
if (authorizationUrl === undefined)
|
|
184
|
+
return OidcFlowValues.rejected("invalid-input");
|
|
185
|
+
const record = this.#transactionRecord(material, request, verifier, expiry);
|
|
186
|
+
if (!(await this.#store.insertTransaction(record, signal))) {
|
|
187
|
+
signal?.throwIfAborted();
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (signal?.aborted || this.#isClosed()) {
|
|
191
|
+
await this.#compensate(() => this.#store.consumeTransaction(record), signal);
|
|
192
|
+
return OidcFlowValues.rejected("closed");
|
|
193
|
+
}
|
|
194
|
+
this.#retainTransaction(material, expiry);
|
|
195
|
+
return Object.freeze({ kind: "started", authorizationUrl, expiresAt: expiry });
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Removes a confirmed undisclosed write when its exact record remains.
|
|
199
|
+
* @param remove Conditional deletion of the exact inserted record.
|
|
200
|
+
* @param signal Optional caller cancellation signal.
|
|
201
|
+
* @returns Completion after confirmed compensation.
|
|
202
|
+
*/
|
|
203
|
+
async #compensate(remove, signal) {
|
|
204
|
+
try {
|
|
205
|
+
await remove();
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
throw new AggregateError([signal?.aborted ? signal.reason : new Error("OIDC flow closed."), error], "OIDC confirmed-write compensation failed.");
|
|
209
|
+
}
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Builds one complete domain transaction from validated material.
|
|
214
|
+
* @param material Fresh state and nonce.
|
|
215
|
+
* @param request Validated browser start request.
|
|
216
|
+
* @param verifier Provider PKCE verifier.
|
|
217
|
+
* @param expiry Exact transaction deadline in milliseconds.
|
|
218
|
+
* @returns Complete typed transaction.
|
|
219
|
+
*/
|
|
220
|
+
#transactionRecord(material, request, verifier, expiry) {
|
|
221
|
+
return create(OidcLoginTransactionSchema, {
|
|
222
|
+
lookupDigest: OidcFlowKeys.digest(this.#store.flowId, material.state),
|
|
223
|
+
flowId: this.#store.flowId,
|
|
224
|
+
providerIssuer: this.#providerIssuer,
|
|
225
|
+
clientId: this.#clientId,
|
|
226
|
+
callbackUri: this.#callbackUri,
|
|
227
|
+
nonce: material.nonce,
|
|
228
|
+
providerCodeVerifier: verifier,
|
|
229
|
+
browserCodeChallenge: request.browserCodeChallenge,
|
|
230
|
+
postLoginRedirect: request.postLoginRedirect,
|
|
231
|
+
whenExpires: OidcFlowValues.timestamp(expiry),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Retains only local entropy-collision tracking after confirmed persistence.
|
|
236
|
+
* @param material Fresh state and verifier bytes.
|
|
237
|
+
* @param expiry Exact transaction deadline in milliseconds.
|
|
238
|
+
*/
|
|
239
|
+
#retainTransaction(material, expiry) {
|
|
240
|
+
const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
|
|
241
|
+
this.#transactions.set(material.state, {
|
|
242
|
+
nonce: material.nonce,
|
|
243
|
+
verifierChallenge: OidcFlowValues.sha256Base64Url(verifier),
|
|
244
|
+
expiresAt: expiry,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
144
247
|
/**
|
|
145
248
|
* Processes a callback state before provider verification or mapping.
|
|
146
249
|
* A rejected callback never restores its transaction.
|
|
147
250
|
* @param input The callback state and provider response.
|
|
251
|
+
* @param signal Optional host request cancellation; a consumed state remains consumed.
|
|
148
252
|
* @returns The callback result, including a one-time grant when accepted.
|
|
149
253
|
*/
|
|
150
|
-
async callback(input) {
|
|
254
|
+
async callback(input, signal) {
|
|
255
|
+
return this.#run(() => this.#callback(input, signal));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Consumes accepted state before invoking external identity collaborators.
|
|
259
|
+
* @param input Provider callback value.
|
|
260
|
+
* @returns A grant or a rejected callback.
|
|
261
|
+
*/
|
|
262
|
+
async #callback(input, signal) {
|
|
263
|
+
const accepted = await this.#consumeCallback(input, signal);
|
|
264
|
+
if ("kind" in accepted)
|
|
265
|
+
return accepted;
|
|
266
|
+
const { transaction, callback } = accepted;
|
|
267
|
+
if (callback.error !== undefined)
|
|
268
|
+
return OidcFlowValues.callbackRejected("provider-error");
|
|
269
|
+
signal?.throwIfAborted();
|
|
270
|
+
const mapped = await this.#mapCallback(callback.code, transaction, signal);
|
|
271
|
+
if ("kind" in mapped)
|
|
272
|
+
return mapped;
|
|
273
|
+
return this.#createGrant(mapped, transaction, signal);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Validates binding and expiry before atomically consuming a transaction.
|
|
277
|
+
* @param input Browser callback input.
|
|
278
|
+
* @returns Accepted transaction and code or a rejection.
|
|
279
|
+
*/
|
|
280
|
+
async #consumeCallback(input, signal) {
|
|
151
281
|
if (this.#isClosed())
|
|
152
282
|
return OidcFlowValues.callbackRejected("closed");
|
|
283
|
+
signal?.throwIfAborted();
|
|
153
284
|
const state = OidcFlowValues.snapshotCallbackState(input);
|
|
154
285
|
if (state === undefined)
|
|
155
286
|
return OidcFlowValues.callbackRejected("invalid-input");
|
|
156
287
|
const now = this.#now();
|
|
157
|
-
if (now === undefined)
|
|
288
|
+
if (now === undefined || this.#isClosed())
|
|
158
289
|
return OidcFlowValues.callbackRejected("closed");
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const transaction = this.#transactions.get(state);
|
|
290
|
+
const transaction = await this.#store.readTransaction(OidcFlowKeys.digest(this.#store.flowId, state), signal);
|
|
291
|
+
signal?.throwIfAborted();
|
|
162
292
|
if (transaction === undefined)
|
|
163
293
|
return OidcFlowValues.callbackRejected("not-found");
|
|
164
|
-
this.#
|
|
294
|
+
if (this.#isClosed())
|
|
295
|
+
return OidcFlowValues.callbackRejected("closed");
|
|
296
|
+
const current = this.#now();
|
|
297
|
+
if (current === undefined || this.#isClosed())
|
|
298
|
+
return OidcFlowValues.callbackRejected("closed");
|
|
165
299
|
const callback = OidcFlowValues.snapshotCallbackInput(input);
|
|
166
|
-
if (callback === undefined || !OidcFlowValues.validCallbackInput(callback))
|
|
167
|
-
transaction.providerVerifier.fill(0);
|
|
300
|
+
if (callback === undefined || !OidcFlowValues.validCallbackInput(callback))
|
|
168
301
|
return OidcFlowValues.callbackRejected("invalid-input");
|
|
302
|
+
const invalid = this.#callbackValidity(transaction, callback, current);
|
|
303
|
+
if (invalid !== undefined)
|
|
304
|
+
return invalid;
|
|
305
|
+
if (this.#isClosed())
|
|
306
|
+
return OidcFlowValues.callbackRejected("closed");
|
|
307
|
+
if (!(await this.#store.consumeTransaction(transaction, signal))) {
|
|
308
|
+
signal?.throwIfAborted();
|
|
309
|
+
return OidcFlowValues.callbackRejected("not-found");
|
|
169
310
|
}
|
|
170
|
-
|
|
171
|
-
|
|
311
|
+
return this.#settleConsumedCallback(state, transaction, callback, signal);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Rechecks expiry after a confirmed one-use transaction consumption.
|
|
315
|
+
* @param state Browser state whose local collision fact can be discarded.
|
|
316
|
+
* @param transaction Complete consumed transaction.
|
|
317
|
+
* @param callback Validated callback facts.
|
|
318
|
+
* @param signal Optional host request cancellation.
|
|
319
|
+
* @returns Accepted callback or a terminal rejection without state restoration.
|
|
320
|
+
*/
|
|
321
|
+
#settleConsumedCallback(state, transaction, callback, signal) {
|
|
322
|
+
this.#transactions.delete(state);
|
|
323
|
+
signal?.throwIfAborted();
|
|
324
|
+
const consumedAt = this.#now();
|
|
325
|
+
if (consumedAt === undefined || this.#isClosed())
|
|
326
|
+
return OidcFlowValues.callbackRejected("closed");
|
|
327
|
+
if (this.#expired(transaction, consumedAt))
|
|
172
328
|
return OidcFlowValues.callbackRejected("expired");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
329
|
+
return { transaction, callback };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Checks exact expiry and provider binding before transaction consumption.
|
|
333
|
+
* @param transaction Persisted state snapshot.
|
|
334
|
+
* @param callback Validated callback facts.
|
|
335
|
+
* @param current Trusted time sampled after storage read.
|
|
336
|
+
* @returns Rejection when invalid, otherwise undefined.
|
|
337
|
+
*/
|
|
338
|
+
#callbackValidity(transaction, callback, current) {
|
|
339
|
+
if (this.#expired(transaction, current))
|
|
340
|
+
return OidcFlowValues.callbackRejected("expired");
|
|
341
|
+
if (callback.responseIssuer !== undefined && callback.responseIssuer !== this.#providerIssuer)
|
|
180
342
|
return OidcFlowValues.callbackRejected("issuer-mismatch");
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
343
|
+
if (!this.#matchesTransaction(transaction))
|
|
344
|
+
return OidcFlowValues.callbackRejected("issuer-mismatch");
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Compares a trusted time against a record's exact deadline.
|
|
349
|
+
* @param record Persisted transaction or grant being checked.
|
|
350
|
+
* @param current Trusted milliseconds after the latest awaited transition.
|
|
351
|
+
* @returns Whether the record has reached its deadline.
|
|
352
|
+
*/
|
|
353
|
+
#expired(record, current) {
|
|
354
|
+
return (SessionRecords.compare(OidcFlowValues.timestamp(current), OidcFlowKeys.expiry(record)) >= 0);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Checks a persisted transaction against the current trusted provider configuration.
|
|
358
|
+
* @param transaction Persisted callback binding.
|
|
359
|
+
* @returns Whether issuer, client, and exact callback URI still match.
|
|
360
|
+
*/
|
|
361
|
+
#matchesTransaction(transaction) {
|
|
362
|
+
return (transaction.providerIssuer === this.#providerIssuer &&
|
|
363
|
+
transaction.clientId === this.#clientId &&
|
|
364
|
+
transaction.callbackUri === this.#callbackUri);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Verifies a consumed provider code and maps its verified external identity.
|
|
368
|
+
* @param code Provider authorization code.
|
|
369
|
+
* @param transaction Consumed transaction binding and nonce.
|
|
370
|
+
* @returns Resolved application identity or a rejected callback.
|
|
371
|
+
*/
|
|
372
|
+
async #mapCallback(code, transaction, signal) {
|
|
373
|
+
signal?.throwIfAborted();
|
|
374
|
+
const identity = await this.#runBounded((bounded) => this.#provider.exchangeAuthorizationCode({
|
|
375
|
+
code,
|
|
186
376
|
clientId: this.#clientId,
|
|
187
377
|
callbackUri: this.#callbackUri,
|
|
188
|
-
providerCodeVerifier:
|
|
378
|
+
providerCodeVerifier: transaction.providerCodeVerifier,
|
|
189
379
|
expectedNonce: transaction.nonce,
|
|
190
|
-
signal,
|
|
191
|
-
}));
|
|
380
|
+
signal: bounded,
|
|
381
|
+
}), signal);
|
|
382
|
+
signal?.throwIfAborted();
|
|
192
383
|
if (this.#isClosed())
|
|
193
384
|
return OidcFlowValues.callbackRejected("closed");
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
verified = OidcFlowValues.validExternalIdentity(identity, this.#providerIssuer);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
verified = undefined;
|
|
200
|
-
}
|
|
385
|
+
const verified = this.#verifiedIdentity(identity);
|
|
201
386
|
if (verified === undefined)
|
|
202
387
|
return OidcFlowValues.callbackRejected("verification-failed");
|
|
203
|
-
const resolved = await this.#runBounded((
|
|
388
|
+
const resolved = await this.#runBounded((bounded) => this.#identityMapping.resolve(verified, bounded), signal);
|
|
389
|
+
signal?.throwIfAborted();
|
|
204
390
|
if (this.#isClosed())
|
|
205
391
|
return OidcFlowValues.callbackRejected("closed");
|
|
206
392
|
const mapped = OidcFlowValues.snapshotResolvedIdentity(resolved, verified);
|
|
207
|
-
|
|
208
|
-
|
|
393
|
+
return mapped ?? OidcFlowValues.callbackRejected("mapping-failed");
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Validates a provider identity without exposing hostile getter failures.
|
|
397
|
+
* @param identity Provider result.
|
|
398
|
+
* @returns Verified identity or undefined.
|
|
399
|
+
*/
|
|
400
|
+
#verifiedIdentity(identity) {
|
|
401
|
+
try {
|
|
402
|
+
return OidcFlowValues.validExternalIdentity(identity, this.#providerIssuer);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Builds and persists a one-use application grant after identity mapping.
|
|
410
|
+
* @param mapped Validated mapped identity.
|
|
411
|
+
* @param transaction Consumed transaction binding.
|
|
412
|
+
* @returns Grant or rejection.
|
|
413
|
+
*/
|
|
414
|
+
async #createGrant(mapped, transaction, signal) {
|
|
415
|
+
signal?.throwIfAborted();
|
|
209
416
|
const current = this.#now();
|
|
210
|
-
if (current === undefined)
|
|
417
|
+
if (current === undefined || this.#isClosed())
|
|
211
418
|
return OidcFlowValues.callbackRejected("closed");
|
|
212
419
|
this.#sweepGrants(current);
|
|
213
|
-
if (this.#
|
|
214
|
-
return OidcFlowValues.callbackRejected("closed");
|
|
215
|
-
if (this.#grants.size >= this.#maxGrants)
|
|
420
|
+
if (this.#localStore?.grantsFull())
|
|
216
421
|
return OidcFlowValues.callbackRejected("capacity-exceeded");
|
|
217
422
|
const expiry = OidcFlowValues.expiryAt(current, this.#grantTtlMilliseconds);
|
|
218
423
|
if (expiry === undefined) {
|
|
@@ -220,16 +425,32 @@ export class OidcFlow {
|
|
|
220
425
|
return OidcFlowValues.callbackRejected("clock-failure");
|
|
221
426
|
}
|
|
222
427
|
const grant = this.#nextGrant();
|
|
223
|
-
if (grant === undefined
|
|
224
|
-
|
|
225
|
-
|
|
428
|
+
if (grant === undefined ||
|
|
429
|
+
this.#isClosed() ||
|
|
430
|
+
this.#localStore?.grantsFull() ||
|
|
431
|
+
this.#grants.has(grant))
|
|
226
432
|
return OidcFlowValues.callbackRejected(this.#isClosed() ? "closed" : "entropy-exhausted");
|
|
227
|
-
this.#
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
433
|
+
return this.#persistGrant(grant, mapped, transaction, expiry, signal);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Persists a grant and compensates only a confirmed insertion after closure.
|
|
437
|
+
* @param grant Random browser grant value.
|
|
438
|
+
* @param mapped Validated mapped identity.
|
|
439
|
+
* @param transaction Consumed transaction binding.
|
|
440
|
+
* @param expiry Exact grant deadline in milliseconds.
|
|
441
|
+
* @returns Grant or rejection.
|
|
442
|
+
*/
|
|
443
|
+
async #persistGrant(grant, mapped, transaction, expiry, signal) {
|
|
444
|
+
const record = this.#grantRecord(grant, mapped, transaction, expiry);
|
|
445
|
+
if (!(await this.#store.insertGrant(record, signal))) {
|
|
446
|
+
signal?.throwIfAborted();
|
|
447
|
+
return OidcFlowValues.callbackRejected("entropy-exhausted");
|
|
448
|
+
}
|
|
449
|
+
if (signal?.aborted || this.#isClosed()) {
|
|
450
|
+
await this.#compensate(() => this.#store.consumeGrant(record), signal);
|
|
451
|
+
return OidcFlowValues.callbackRejected("closed");
|
|
452
|
+
}
|
|
453
|
+
this.#grants.set(grant, expiry);
|
|
233
454
|
return Object.freeze({
|
|
234
455
|
kind: "granted",
|
|
235
456
|
grant,
|
|
@@ -238,32 +459,45 @@ export class OidcFlow {
|
|
|
238
459
|
});
|
|
239
460
|
}
|
|
240
461
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
462
|
+
* Builds a typed application grant bound to the consumed transaction.
|
|
463
|
+
* @param grant Random browser grant value.
|
|
464
|
+
* @param mapped Validated mapped identity.
|
|
465
|
+
* @param transaction Consumed transaction binding.
|
|
466
|
+
* @param expiry Exact grant deadline in milliseconds.
|
|
467
|
+
* @returns Complete typed grant record.
|
|
468
|
+
*/
|
|
469
|
+
#grantRecord(grant, mapped, transaction, expiry) {
|
|
470
|
+
return create(OidcLoginGrantSchema, {
|
|
471
|
+
lookupDigest: OidcFlowKeys.digest(this.#store.flowId, grant),
|
|
472
|
+
flowId: this.#store.flowId,
|
|
473
|
+
principal: SessionRecords.principal(mapped.principal),
|
|
474
|
+
browserCodeChallenge: transaction.browserCodeChallenge,
|
|
475
|
+
postLoginRedirect: transaction.postLoginRedirect,
|
|
476
|
+
whenExpires: OidcFlowValues.timestamp(expiry),
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Validates and consumes a grant before issuing an application session.
|
|
481
|
+
* All failures deliberately have the same result to avoid grant enumeration.
|
|
243
482
|
* @param input The grant and browser PKCE proof.
|
|
483
|
+
* @param signal Optional host request cancellation; a consumed grant remains consumed.
|
|
244
484
|
* @returns The issued application session or an enumeration-safe rejection.
|
|
245
485
|
*/
|
|
246
|
-
async exchange(input) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const grant = this.#
|
|
486
|
+
async exchange(input, signal) {
|
|
487
|
+
return this.#run(() => this.#exchange(input, signal));
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Issues a session only after winning grant consumption.
|
|
491
|
+
* @param input Browser grant and verifier.
|
|
492
|
+
* @returns An issued session or enumeration-safe rejection.
|
|
493
|
+
*/
|
|
494
|
+
async #exchange(input, signal) {
|
|
495
|
+
const grant = await this.#consumeGrant(input, signal);
|
|
256
496
|
if (grant === undefined)
|
|
257
497
|
return OidcFlowValues.exchangeRejected();
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
browserCodeVerifier === undefined ||
|
|
262
|
-
!OidcFlowValues.validBrowserCodeVerifier(browserCodeVerifier))
|
|
263
|
-
return OidcFlowValues.exchangeRejected();
|
|
264
|
-
if (!OidcFlowValues.constantTimeEquals(OidcFlowValues.sha256Base64Url(browserCodeVerifier), grant.browserCodeChallenge))
|
|
265
|
-
return OidcFlowValues.exchangeRejected();
|
|
266
|
-
const issued = await this.#runBounded((signal) => this.#sessionIssuer.issue(grant.identity.principal, signal));
|
|
498
|
+
signal?.throwIfAborted();
|
|
499
|
+
const issued = await this.#runBounded((bounded) => this.#sessionIssuer.issue(OidcFlowKeys.principal(grant), bounded), signal, true);
|
|
500
|
+
signal?.throwIfAborted();
|
|
267
501
|
const safeIssue = OidcFlowValues.snapshotSessionIssue(issued);
|
|
268
502
|
if (this.#isClosed() || safeIssue === undefined)
|
|
269
503
|
return OidcFlowValues.exchangeRejected();
|
|
@@ -274,11 +508,70 @@ export class OidcFlow {
|
|
|
274
508
|
});
|
|
275
509
|
}
|
|
276
510
|
/**
|
|
277
|
-
*
|
|
511
|
+
* Validates binding, expiry, and browser proof before one atomic grant deletion.
|
|
512
|
+
* @param input Browser grant and verifier.
|
|
513
|
+
* @returns Consumed grant or undefined after rejection.
|
|
514
|
+
*/
|
|
515
|
+
async #consumeGrant(input, signal) {
|
|
516
|
+
if (this.#isClosed())
|
|
517
|
+
return undefined;
|
|
518
|
+
signal?.throwIfAborted();
|
|
519
|
+
const request = OidcFlowValues.snapshotGrantExchangeInput(input);
|
|
520
|
+
if (request === undefined || !OidcFlowValues.validGrant(request.grant))
|
|
521
|
+
return undefined;
|
|
522
|
+
const now = this.#now();
|
|
523
|
+
if (now === undefined)
|
|
524
|
+
return undefined;
|
|
525
|
+
const grant = await this.#store.readGrant(OidcFlowKeys.digest(this.#store.flowId, request.grant), signal);
|
|
526
|
+
signal?.throwIfAborted();
|
|
527
|
+
if (grant === undefined)
|
|
528
|
+
return undefined;
|
|
529
|
+
if (this.#isClosed())
|
|
530
|
+
return undefined;
|
|
531
|
+
const current = this.#now();
|
|
532
|
+
if (current === undefined || this.#isClosed())
|
|
533
|
+
return undefined;
|
|
534
|
+
const verifier = OidcFlowValues.snapshotBrowserCodeVerifier(input);
|
|
535
|
+
if (!this.#validGrantProof(grant, verifier, current))
|
|
536
|
+
return undefined;
|
|
537
|
+
if (this.#isClosed())
|
|
538
|
+
return undefined;
|
|
539
|
+
if (!(await this.#store.consumeGrant(grant, signal))) {
|
|
540
|
+
signal?.throwIfAborted();
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
this.#grants.delete(request.grant);
|
|
544
|
+
signal?.throwIfAborted();
|
|
545
|
+
const consumedAt = this.#now();
|
|
546
|
+
if (consumedAt === undefined || this.#isClosed() || this.#expired(grant, consumedAt))
|
|
547
|
+
return undefined;
|
|
548
|
+
return grant;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Checks exact grant expiry and browser PKCE proof before consumption.
|
|
552
|
+
* @param grant Persisted grant snapshot.
|
|
553
|
+
* @param verifier Browser supplied verifier.
|
|
554
|
+
* @param current Trusted time sampled after storage read.
|
|
555
|
+
* @returns Whether the grant may be consumed.
|
|
556
|
+
*/
|
|
557
|
+
#validGrantProof(grant, verifier, current) {
|
|
558
|
+
if (SessionRecords.compare(OidcFlowValues.timestamp(current), OidcFlowKeys.expiry(grant)) >= 0 ||
|
|
559
|
+
verifier === undefined ||
|
|
560
|
+
!OidcFlowValues.validBrowserCodeVerifier(verifier))
|
|
561
|
+
return false;
|
|
562
|
+
return OidcFlowValues.constantTimeEquals(OidcFlowValues.sha256Base64Url(verifier), grant.browserCodeChallenge);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Closes the flow after admitted work and original collaborator promises settle.
|
|
566
|
+
*
|
|
567
|
+
* An injected store and factory remain open. A late distinct issuer failure
|
|
568
|
+
* rejects this close operation; repeated calls retain that result.
|
|
569
|
+
*
|
|
570
|
+
* @returns Completion after raw work drains and local memory closes.
|
|
278
571
|
*/
|
|
279
572
|
close() {
|
|
280
|
-
if (this.#
|
|
281
|
-
return;
|
|
573
|
+
if (this.#closing !== undefined)
|
|
574
|
+
return this.#closing;
|
|
282
575
|
this.#closed = true;
|
|
283
576
|
this.#clearTransactions();
|
|
284
577
|
this.#grants.clear();
|
|
@@ -286,24 +579,102 @@ export class OidcFlow {
|
|
|
286
579
|
controller.abort();
|
|
287
580
|
});
|
|
288
581
|
this.#callbacks.clear();
|
|
582
|
+
this.#closing = this.#finishClose();
|
|
583
|
+
return this.#closing;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Removes eligible records through the selected flow store.
|
|
587
|
+
*
|
|
588
|
+
* @param cutoff Trusted current-or-earlier exact deadline.
|
|
589
|
+
* @param signal Optional cancellation.
|
|
590
|
+
* @returns Confirmed transaction and grant removals in this pass.
|
|
591
|
+
*/
|
|
592
|
+
purgeExpired(cutoff, signal) {
|
|
593
|
+
if (this.#isClosed())
|
|
594
|
+
return Promise.reject(new Error("OIDC flow is closed."));
|
|
595
|
+
return this.#run(() => this.#store.purgeExpired(cutoff, signal));
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Waits for admitted work, then releases only internally selected memory.
|
|
599
|
+
*
|
|
600
|
+
* @returns Completion after all admitted operations settle.
|
|
601
|
+
*/
|
|
602
|
+
async #finishClose() {
|
|
603
|
+
if (this.#active > 0)
|
|
604
|
+
await new Promise((resolve) => (this.#drained = resolve));
|
|
605
|
+
while (this.#rawCollaborators.size > 0)
|
|
606
|
+
await Promise.allSettled([...this.#rawCollaborators]);
|
|
607
|
+
let closeFailure;
|
|
608
|
+
try {
|
|
609
|
+
await this.#localStore?.close();
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
closeFailure = error;
|
|
613
|
+
}
|
|
614
|
+
if (this.#lateIssuerFailure !== undefined && closeFailure !== undefined)
|
|
615
|
+
throw new AggregateError([this.#lateIssuerFailure.reason, closeFailure], "OIDC flow close failed.");
|
|
616
|
+
if (this.#lateIssuerFailure !== undefined)
|
|
617
|
+
throw this.#lateIssuerFailure.reason;
|
|
618
|
+
// A store may reject with a non-Error reason; preserve its exact failure.
|
|
619
|
+
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
|
620
|
+
if (closeFailure !== undefined)
|
|
621
|
+
throw closeFailure;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Fences admitted asynchronous flow work against closure.
|
|
625
|
+
*
|
|
626
|
+
* @typeParam T Operation result.
|
|
627
|
+
* @param work Operation body.
|
|
628
|
+
* @returns Completion of admitted work.
|
|
629
|
+
*/
|
|
630
|
+
async #run(work) {
|
|
631
|
+
if (this.#closed)
|
|
632
|
+
return work();
|
|
633
|
+
this.#active += 1;
|
|
634
|
+
try {
|
|
635
|
+
return await work();
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
this.#active -= 1;
|
|
639
|
+
if (this.#active === 0)
|
|
640
|
+
this.#drained?.();
|
|
641
|
+
}
|
|
289
642
|
}
|
|
290
643
|
#isClosed() {
|
|
291
644
|
return this.#closed;
|
|
292
645
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
646
|
+
/**
|
|
647
|
+
* Builds one S256 authorization URL with the maintained OIDC client library.
|
|
648
|
+
*
|
|
649
|
+
* @param state Random state retained in the transaction.
|
|
650
|
+
* @param nonce Random ID-token nonce.
|
|
651
|
+
* @param providerVerifier Provider PKCE verifier.
|
|
652
|
+
* @returns Bounded URL, if representable under configured limits.
|
|
653
|
+
*/
|
|
654
|
+
async #authorizationUrl(state, nonce, providerVerifier) {
|
|
655
|
+
const endpoint = new URL(this.#authorizationEndpoint);
|
|
656
|
+
for (const name of [
|
|
657
|
+
"response_type",
|
|
658
|
+
"client_id",
|
|
659
|
+
"redirect_uri",
|
|
660
|
+
"scope",
|
|
661
|
+
"state",
|
|
662
|
+
"nonce",
|
|
663
|
+
"code_challenge",
|
|
664
|
+
"code_challenge_method",
|
|
665
|
+
])
|
|
666
|
+
endpoint.searchParams.delete(name);
|
|
667
|
+
const config = new Configuration({ issuer: this.#providerIssuer, authorization_endpoint: endpoint.toString() }, this.#clientId);
|
|
668
|
+
const url = buildAuthorizationUrl(config, {
|
|
669
|
+
response_type: "code",
|
|
670
|
+
client_id: this.#clientId,
|
|
671
|
+
redirect_uri: this.#callbackUri,
|
|
672
|
+
scope: this.#scopes.join(" "),
|
|
673
|
+
state,
|
|
674
|
+
nonce,
|
|
675
|
+
code_challenge: await calculatePKCECodeChallenge(providerVerifier),
|
|
676
|
+
code_challenge_method: "S256",
|
|
677
|
+
});
|
|
307
678
|
const serialized = url.toString();
|
|
308
679
|
return serialized.length <= this.#maxAuthorizationUrlLength ? serialized : undefined;
|
|
309
680
|
}
|
|
@@ -338,39 +709,120 @@ export class OidcFlow {
|
|
|
338
709
|
}
|
|
339
710
|
}
|
|
340
711
|
/**
|
|
341
|
-
* Runs one
|
|
712
|
+
* Runs one collaborator under the configured timeout and request signal.
|
|
342
713
|
* @typeParam T The successful callback result.
|
|
343
714
|
* @param operation The callback to run with a cancellation signal.
|
|
715
|
+
* @param signal Optional host request cancellation.
|
|
716
|
+
* @param issuer Whether distinct issuer failures must remain observable.
|
|
344
717
|
* @returns The callback result, or undefined after timeout or closure.
|
|
345
718
|
*/
|
|
346
|
-
async #runBounded(operation) {
|
|
719
|
+
async #runBounded(operation, signal, issuer = false) {
|
|
720
|
+
if (this.#isClosed())
|
|
721
|
+
return undefined;
|
|
722
|
+
signal?.throwIfAborted();
|
|
347
723
|
const controller = new AbortController();
|
|
724
|
+
const onAbort = () => {
|
|
725
|
+
controller.abort(signal?.reason);
|
|
726
|
+
};
|
|
727
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
728
|
+
if (signal?.aborted)
|
|
729
|
+
onAbort();
|
|
348
730
|
this.#callbacks.add(controller);
|
|
349
|
-
|
|
731
|
+
const timer = setTimeout(() => {
|
|
732
|
+
controller.abort();
|
|
733
|
+
}, this.#operationTimeoutMilliseconds);
|
|
350
734
|
try {
|
|
351
|
-
|
|
352
|
-
controller.signal.addEventListener("abort", () => {
|
|
353
|
-
resolve(undefined);
|
|
354
|
-
}, { once: true });
|
|
355
|
-
});
|
|
356
|
-
const timeout = new Promise((resolve) => {
|
|
357
|
-
timer = setTimeout(() => {
|
|
358
|
-
controller.abort();
|
|
359
|
-
resolve(undefined);
|
|
360
|
-
}, this.#operationTimeoutMilliseconds);
|
|
361
|
-
});
|
|
362
|
-
return await Promise.race([operation(controller.signal), timeout, aborted]);
|
|
735
|
+
return await this.#awaitBounded(operation, controller.signal, issuer);
|
|
363
736
|
}
|
|
364
|
-
catch {
|
|
737
|
+
catch (error) {
|
|
738
|
+
if (issuer)
|
|
739
|
+
throw error;
|
|
365
740
|
return undefined;
|
|
366
741
|
}
|
|
367
742
|
finally {
|
|
368
|
-
|
|
369
|
-
|
|
743
|
+
clearTimeout(timer);
|
|
744
|
+
signal?.removeEventListener("abort", onAbort);
|
|
370
745
|
this.#callbacks.delete(controller);
|
|
371
746
|
controller.abort();
|
|
372
747
|
}
|
|
373
748
|
}
|
|
749
|
+
/**
|
|
750
|
+
* Races a tracked original collaborator with its cancellation signal.
|
|
751
|
+
* @typeParam T Collaborator result.
|
|
752
|
+
* @param operation Collaborator invoked with bounded signal.
|
|
753
|
+
* @param signal Bounded cancellation signal.
|
|
754
|
+
* @param issuer Whether distinct issuer failures remain observable.
|
|
755
|
+
* @returns Collaborator result or undefined after cancellation.
|
|
756
|
+
*/
|
|
757
|
+
#awaitBounded(operation, signal, issuer) {
|
|
758
|
+
const returned = { done: false };
|
|
759
|
+
const raw = Promise.resolve().then(() => {
|
|
760
|
+
signal.throwIfAborted();
|
|
761
|
+
return operation(signal);
|
|
762
|
+
});
|
|
763
|
+
const observed = this.#observeRaw(raw, signal, issuer, returned);
|
|
764
|
+
this.#trackRaw(observed);
|
|
765
|
+
return Promise.race([observed, this.#aborted(signal)]).then((outcome) => {
|
|
766
|
+
returned.done = true;
|
|
767
|
+
if (outcome === undefined) {
|
|
768
|
+
if (returned.failure !== undefined)
|
|
769
|
+
this.#lateIssuerFailure ??= returned.failure;
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
if (outcome.kind === "failed")
|
|
773
|
+
throw outcome.reason;
|
|
774
|
+
return outcome.kind === "value" ? outcome.value : undefined;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Resolves when a bounded collaborator signal aborts.
|
|
779
|
+
* @param signal Bounded cancellation signal.
|
|
780
|
+
* @returns Undefined once cancelled.
|
|
781
|
+
*/
|
|
782
|
+
#aborted(signal) {
|
|
783
|
+
return new Promise((resolve) => {
|
|
784
|
+
if (signal.aborted)
|
|
785
|
+
resolve(undefined);
|
|
786
|
+
else
|
|
787
|
+
signal.addEventListener("abort", () => {
|
|
788
|
+
resolve(undefined);
|
|
789
|
+
}, { once: true });
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Tracks the original collaborator promise until it really settles.
|
|
794
|
+
* @typeParam T Collaborator result.
|
|
795
|
+
* @param raw Original collaborator promise.
|
|
796
|
+
*/
|
|
797
|
+
#trackRaw(raw) {
|
|
798
|
+
this.#rawCollaborators.add(raw);
|
|
799
|
+
void raw.then(() => {
|
|
800
|
+
this.#rawCollaborators.delete(raw);
|
|
801
|
+
}, () => {
|
|
802
|
+
this.#rawCollaborators.delete(raw);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Preserves a late issuer failure after a caller-facing timeout or abort.
|
|
807
|
+
* @typeParam T Collaborator result.
|
|
808
|
+
* @param raw Original collaborator promise.
|
|
809
|
+
* @param signal Exact cancellation reason supplied to the collaborator.
|
|
810
|
+
* @param issuer Whether this is a durable session issuer.
|
|
811
|
+
* @param returned Caller-facing settlement and any issuer rejection.
|
|
812
|
+
* @returns The tagged collaborator outcome.
|
|
813
|
+
*/
|
|
814
|
+
#observeRaw(raw, signal, issuer, returned) {
|
|
815
|
+
return raw.then((value) => ({ kind: "value", value }), (error) => {
|
|
816
|
+
if (!issuer || (signal.aborted && error === signal.reason))
|
|
817
|
+
return { kind: "declined" };
|
|
818
|
+
const failure = { reason: error };
|
|
819
|
+
if (returned.done)
|
|
820
|
+
this.#lateIssuerFailure ??= failure;
|
|
821
|
+
else
|
|
822
|
+
returned.failure = failure;
|
|
823
|
+
return { kind: "failed", reason: error };
|
|
824
|
+
});
|
|
825
|
+
}
|
|
374
826
|
#nextGrant() {
|
|
375
827
|
for (let attempt = 0; attempt < this.#collisionAttempts; attempt += 1) {
|
|
376
828
|
let bytes;
|
|
@@ -394,8 +846,7 @@ export class OidcFlow {
|
|
|
394
846
|
#transactionMaterialInUse(material) {
|
|
395
847
|
const verifier = Buffer.from(material.providerVerifierBytes).toString("base64url");
|
|
396
848
|
const challenge = OidcFlowValues.sha256Base64Url(verifier);
|
|
397
|
-
return [...this.#transactions.values()].some((transaction) => transaction.nonce === material.nonce ||
|
|
398
|
-
OidcFlowValues.sha256Base64Url(Buffer.from(transaction.providerVerifier).toString("base64url")) === challenge);
|
|
849
|
+
return [...this.#transactions.values()].some((transaction) => transaction.nonce === material.nonce || transaction.verifierChallenge === challenge);
|
|
399
850
|
}
|
|
400
851
|
#now() {
|
|
401
852
|
try {
|
|
@@ -412,19 +863,14 @@ export class OidcFlow {
|
|
|
412
863
|
#sweepExpired(now) {
|
|
413
864
|
for (const [state, transaction] of this.#transactions)
|
|
414
865
|
if (now >= transaction.expiresAt)
|
|
415
|
-
this.#
|
|
866
|
+
this.#transactions.delete(state);
|
|
416
867
|
}
|
|
417
868
|
#sweepGrants(now) {
|
|
418
|
-
for (const [grant,
|
|
419
|
-
if (now >=
|
|
869
|
+
for (const [grant, expiresAt] of this.#grants)
|
|
870
|
+
if (now >= expiresAt)
|
|
420
871
|
this.#grants.delete(grant);
|
|
421
872
|
}
|
|
422
|
-
#dropTransaction(state, transaction) {
|
|
423
|
-
this.#transactions.delete(state);
|
|
424
|
-
transaction.providerVerifier.fill(0);
|
|
425
|
-
}
|
|
426
873
|
#clearTransactions() {
|
|
427
|
-
this.#transactions.forEach((transaction) => transaction.providerVerifier.fill(0));
|
|
428
874
|
this.#transactions.clear();
|
|
429
875
|
}
|
|
430
876
|
#failClosed() {
|
|
@@ -1019,6 +1465,19 @@ const OidcFlowValues = Object.freeze({
|
|
|
1019
1465
|
const value = now + ttl;
|
|
1020
1466
|
return OidcFlowValues.validTimestamp(value) ? value : undefined;
|
|
1021
1467
|
},
|
|
1468
|
+
/**
|
|
1469
|
+
* Converts safe epoch milliseconds to an exact Protobuf deadline.
|
|
1470
|
+
*
|
|
1471
|
+
* @param milliseconds Valid Unix epoch milliseconds.
|
|
1472
|
+
* @returns Timestamp without negative-time truncation.
|
|
1473
|
+
*/
|
|
1474
|
+
timestamp(milliseconds) {
|
|
1475
|
+
const seconds = Math.floor(milliseconds / 1_000);
|
|
1476
|
+
return create(TimestampSchema, {
|
|
1477
|
+
seconds: BigInt(seconds),
|
|
1478
|
+
nanos: (milliseconds - seconds * 1_000) * 1_000_000,
|
|
1479
|
+
});
|
|
1480
|
+
},
|
|
1022
1481
|
/**
|
|
1023
1482
|
* Creates a URL-safe SHA-256 challenge from PKCE material.
|
|
1024
1483
|
* @param value The ASCII PKCE verifier material to hash.
|