@north-light/sdk 0.0.0 → 0.3.415
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/LICENSE +18 -0
- package/README.md +187 -2
- package/dist/client.d.ts +176 -0
- package/dist/client.js +582 -0
- package/dist/crtr.d.ts +24 -0
- package/dist/crtr.js +50 -0
- package/dist/env-command.d.ts +15 -0
- package/dist/env-command.js +138 -0
- package/dist/error-codes.d.ts +7 -0
- package/dist/error-codes.js +43 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +101 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +17 -0
- package/dist/keygen-cli.d.ts +2 -0
- package/dist/keygen-cli.js +11 -0
- package/dist/keygen-command.d.ts +6 -0
- package/dist/keygen-command.js +131 -0
- package/dist/oauth/index.d.ts +235 -0
- package/dist/oauth/index.js +621 -0
- package/dist/oauth/keygen.d.ts +18 -0
- package/dist/oauth/keygen.js +46 -0
- package/dist/oauth/refusal.d.ts +32 -0
- package/dist/oauth/refusal.js +17 -0
- package/dist/resources/activity.d.ts +22 -0
- package/dist/resources/activity.js +43 -0
- package/dist/resources/attachments.d.ts +15 -0
- package/dist/resources/attachments.js +13 -0
- package/dist/resources/bash.d.ts +9 -0
- package/dist/resources/bash.js +15 -0
- package/dist/resources/canvas/history.d.ts +11 -0
- package/dist/resources/canvas/history.js +20 -0
- package/dist/resources/canvas.d.ts +19 -0
- package/dist/resources/canvas.js +42 -0
- package/dist/resources/crons.d.ts +15 -0
- package/dist/resources/crons.js +33 -0
- package/dist/resources/custom-objects.d.ts +20 -0
- package/dist/resources/custom-objects.js +84 -0
- package/dist/resources/files.d.ts +34 -0
- package/dist/resources/files.js +34 -0
- package/dist/resources/follower.d.ts +71 -0
- package/dist/resources/follower.js +202 -0
- package/dist/resources/forward.d.ts +7 -0
- package/dist/resources/forward.js +67 -0
- package/dist/resources/grant-watcher.d.ts +57 -0
- package/dist/resources/grant-watcher.js +100 -0
- package/dist/resources/human/inbox.d.ts +14 -0
- package/dist/resources/human/inbox.js +30 -0
- package/dist/resources/human/requests.d.ts +13 -0
- package/dist/resources/human/requests.js +26 -0
- package/dist/resources/human.d.ts +8 -0
- package/dist/resources/human.js +10 -0
- package/dist/resources/identifiers.d.ts +8 -0
- package/dist/resources/identifiers.js +33 -0
- package/dist/resources/memory.d.ts +69 -0
- package/dist/resources/memory.js +30 -0
- package/dist/resources/models/config.d.ts +8 -0
- package/dist/resources/models/config.js +9 -0
- package/dist/resources/models/credentials.d.ts +10 -0
- package/dist/resources/models/credentials.js +17 -0
- package/dist/resources/models.d.ts +8 -0
- package/dist/resources/models.js +10 -0
- package/dist/resources/node-stream.d.ts +34 -0
- package/dist/resources/node-stream.js +176 -0
- package/dist/resources/nodes/jobs.d.ts +9 -0
- package/dist/resources/nodes/jobs.js +14 -0
- package/dist/resources/nodes/result.d.ts +8 -0
- package/dist/resources/nodes/result.js +11 -0
- package/dist/resources/nodes/worktree.d.ts +9 -0
- package/dist/resources/nodes/worktree.js +14 -0
- package/dist/resources/nodes.d.ts +23 -0
- package/dist/resources/nodes.js +47 -0
- package/dist/resources/providers.d.ts +51 -0
- package/dist/resources/providers.js +15 -0
- package/dist/resources/questions.d.ts +49 -0
- package/dist/resources/questions.js +21 -0
- package/dist/resources/request.d.ts +12 -0
- package/dist/resources/request.js +50 -0
- package/dist/resources/run-notice.d.ts +34 -0
- package/dist/resources/run-notice.js +67 -0
- package/dist/resources/run-reply.d.ts +79 -0
- package/dist/resources/run-reply.js +93 -0
- package/dist/resources/run-stream.d.ts +65 -0
- package/dist/resources/run-stream.js +243 -0
- package/dist/resources/run-watcher.d.ts +98 -0
- package/dist/resources/run-watcher.js +162 -0
- package/dist/resources/runs.d.ts +199 -0
- package/dist/resources/runs.js +228 -0
- package/dist/resources/shares.d.ts +40 -0
- package/dist/resources/shares.js +19 -0
- package/dist/resources/structured-requests.d.ts +14 -0
- package/dist/resources/structured-requests.js +19 -0
- package/dist/resources/uploads.d.ts +27 -0
- package/dist/resources/uploads.js +11 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +10 -0
- package/dist/stores/postgres.d.ts +32 -0
- package/dist/stores/postgres.js +92 -0
- package/dist/testing/clock.d.ts +7 -0
- package/dist/testing/clock.js +12 -0
- package/dist/testing/cloud.d.ts +228 -0
- package/dist/testing/cloud.js +914 -0
- package/dist/testing/conformance/index.d.ts +2 -0
- package/dist/testing/conformance/index.js +3 -0
- package/dist/testing/conformance/keyed-write.d.ts +43 -0
- package/dist/testing/conformance/keyed-write.js +124 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/notices.d.ts +76 -0
- package/dist/testing/notices.js +209 -0
- package/dist/testing/run.d.ts +174 -0
- package/dist/testing/run.js +429 -0
- package/dist/testing/users.d.ts +127 -0
- package/dist/testing/users.js +78 -0
- package/dist/testing/wire.d.ts +62 -0
- package/dist/testing/wire.js +148 -0
- package/dist/types.d.ts +88 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/package.json +60 -5
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, importJWK, importPKCS8, SignJWT } from 'jose';
|
|
2
|
+
import { boundedFetch, FetchDeadlineError, JwksCache, parseScope, verifyIdToken } from '@north-light/identity';
|
|
3
|
+
import { deadlineExceeded, sleepWithin } from '@north-light/api';
|
|
4
|
+
import { APIError, CrouterError, mapError } from '../errors.js';
|
|
5
|
+
import { Crouter } from '../client.js';
|
|
6
|
+
import { withinDeadline } from '../resources/request.js';
|
|
7
|
+
import { RunWatcher } from '../resources/run-watcher.js';
|
|
8
|
+
import { GrantWatcher } from '../resources/grant-watcher.js';
|
|
9
|
+
import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
|
|
10
|
+
export { refusalOf } from './refusal.js';
|
|
11
|
+
/** A connection store for one process (local development and tests): its lock does not reach other processes. */
|
|
12
|
+
export class MemoryConnectionStore {
|
|
13
|
+
connections = new Map();
|
|
14
|
+
locks = new Map();
|
|
15
|
+
savedAt = new Map();
|
|
16
|
+
async load(userId) { return this.connections.get(userId) ?? null; }
|
|
17
|
+
async save(connection) {
|
|
18
|
+
this.connections.set(connection.userId, connection);
|
|
19
|
+
this.savedAt.set(connection.userId, Date.now());
|
|
20
|
+
}
|
|
21
|
+
async remove(userId, refreshToken) {
|
|
22
|
+
if (this.connections.get(userId)?.refreshToken !== refreshToken)
|
|
23
|
+
return false;
|
|
24
|
+
this.connections.delete(userId);
|
|
25
|
+
this.savedAt.delete(userId);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
async listStale(before) {
|
|
29
|
+
return [...this.connections.values()].filter((connection) => (this.savedAt.get(connection.userId) ?? Infinity) < before.getTime());
|
|
30
|
+
}
|
|
31
|
+
/** Not reentrant: calling `lock` for the same user from inside `fn` waits forever. */
|
|
32
|
+
async lock(userId, fn) {
|
|
33
|
+
const prior = this.locks.get(userId);
|
|
34
|
+
let release;
|
|
35
|
+
const done = new Promise((resolve) => { release = resolve; });
|
|
36
|
+
this.locks.set(userId, done);
|
|
37
|
+
await prior;
|
|
38
|
+
try {
|
|
39
|
+
return await fn();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
release();
|
|
43
|
+
if (this.locks.get(userId) === done)
|
|
44
|
+
this.locks.delete(userId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The `profile` claims a verified ID token carries, each only when present: use it on `Connection.idToken` after `verifyIdToken` for a sign-in that requested runtime scopes (`exchangeCode`), as `exchangeIdentity` does for a sign-in-only one. */
|
|
49
|
+
export function profileFromClaims(claims) {
|
|
50
|
+
const text = (value) => typeof value === 'string' && value !== '' ? value : undefined;
|
|
51
|
+
const [name, givenName, familyName, picture] = [text(claims.name), text(claims.given_name), text(claims.family_name), httpsUrl(claims.picture)];
|
|
52
|
+
return { ...(name ? { name } : {}), ...(givenName ? { givenName } : {}), ...(familyName ? { familyName } : {}), ...(picture ? { picture } : {}) };
|
|
53
|
+
}
|
|
54
|
+
/** `value` when it is a string parsing as an `https:` URL, else undefined: a `picture` claim is rendered as an image source, so no other scheme gets through. */
|
|
55
|
+
function httpsUrl(value) {
|
|
56
|
+
if (typeof value !== 'string')
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return new URL(value).protocol === 'https:' ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** A runtime client on one person's stored connection (`oauth.client(userId)`): a `Crouter` that refreshes its
|
|
66
|
+
* token through the `OAuth2Client`'s store, plus whose connection it is and the runtime it calls. */
|
|
67
|
+
export class ConnectedCrouter extends Crouter {
|
|
68
|
+
/** The person whose connection this client uses (`user:<id>`). */
|
|
69
|
+
userId;
|
|
70
|
+
/** The person's runtime URL this client calls, as the stored connection named it. */
|
|
71
|
+
runtimeUrl;
|
|
72
|
+
constructor(connection, options) {
|
|
73
|
+
super({ ...options, baseURL: connection.runtimeUrl });
|
|
74
|
+
this.userId = connection.userId;
|
|
75
|
+
this.runtimeUrl = connection.runtimeUrl;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The default deadline of one directory operation (`OAuth2Options.timeoutMs`): a discovery read is forgotten when it passes. */
|
|
79
|
+
export const DEFAULT_DIRECTORY_TIMEOUT_MS = 10_000;
|
|
80
|
+
const assertionType = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
81
|
+
let refusalScope;
|
|
82
|
+
function handlerScope() {
|
|
83
|
+
if (refusalScope !== undefined)
|
|
84
|
+
return refusalScope;
|
|
85
|
+
const hooks = globalThis.process?.getBuiltinModule?.('node:async_hooks');
|
|
86
|
+
refusalScope = hooks ? new hooks.AsyncLocalStorage() : null;
|
|
87
|
+
return refusalScope;
|
|
88
|
+
}
|
|
89
|
+
export class OAuth2Client {
|
|
90
|
+
options;
|
|
91
|
+
issuer;
|
|
92
|
+
fetcher;
|
|
93
|
+
timeoutMs;
|
|
94
|
+
store;
|
|
95
|
+
jwks;
|
|
96
|
+
discoveryPromise;
|
|
97
|
+
/** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
|
|
98
|
+
inFlight = new Map();
|
|
99
|
+
/** Refusals whose connection the SDK removed (or found already replaced) after `onRefusal` resolved. */
|
|
100
|
+
settled = new WeakSet();
|
|
101
|
+
constructor(options) {
|
|
102
|
+
this.options = options;
|
|
103
|
+
if (!options.issuer || !options.issuer.trim())
|
|
104
|
+
throw new CrouterError('OAuth2Client requires an issuer');
|
|
105
|
+
if (!options.store)
|
|
106
|
+
throw new CrouterError('OAuth2Client requires a store (a ConnectionStore such as PostgresConnectionStore)');
|
|
107
|
+
this.issuer = options.issuer;
|
|
108
|
+
this.store = options.store;
|
|
109
|
+
this.fetcher = options.fetch ?? fetch;
|
|
110
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_DIRECTORY_TIMEOUT_MS;
|
|
111
|
+
if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647)
|
|
112
|
+
throw new CrouterError('OAuth2Options.timeoutMs must be a positive finite number of at most 2147483647');
|
|
113
|
+
}
|
|
114
|
+
/** The issuer's discovery document. The first successful read is kept for the client's life; a read that fails or
|
|
115
|
+
* passes the deadline (`timeoutMs`) rejects and is forgotten, so the next call reads again. */
|
|
116
|
+
discovery() {
|
|
117
|
+
if (this.discoveryPromise)
|
|
118
|
+
return this.discoveryPromise;
|
|
119
|
+
const read = (async () => {
|
|
120
|
+
const value = await this.bounded(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {}, Date.now() + this.timeoutMs, async (response) => {
|
|
121
|
+
if (!response.ok)
|
|
122
|
+
throw new CrouterError(`Directory discovery failed: ${response.status}`);
|
|
123
|
+
return await response.json();
|
|
124
|
+
});
|
|
125
|
+
return parseDiscovery(value, this.issuer);
|
|
126
|
+
})();
|
|
127
|
+
this.discoveryPromise = read;
|
|
128
|
+
// Only this read's own failure clears the memo, never a newer read started after it.
|
|
129
|
+
read.catch(() => { if (this.discoveryPromise === read)
|
|
130
|
+
this.discoveryPromise = undefined; });
|
|
131
|
+
return read;
|
|
132
|
+
}
|
|
133
|
+
/** One directory request and the reading of its answer under `deadlineAt`; a deadline rejects `DeadlineExceededError`. */
|
|
134
|
+
async bounded(url, init, deadlineAt, read) {
|
|
135
|
+
try {
|
|
136
|
+
return await boundedFetch(this.fetcher, url, init, { deadlineAt }, read);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof FetchDeadlineError)
|
|
140
|
+
throw mapError(deadlineExceeded(error.deadlineAt));
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The discovery document within the operation's `deadlineAt`: the memoised read keeps its own deadline, and this
|
|
145
|
+
* caller stops waiting for it at its own. */
|
|
146
|
+
async discoveryWithin(deadlineAt) {
|
|
147
|
+
try {
|
|
148
|
+
return await withinDeadline(this.discovery(), deadlineAt);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw error instanceof APIError ? mapError(error) : error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The app's page on the signed-in person's crouter cloud account, where they change or remove what the app may do:
|
|
156
|
+
* the discovery document's `account_app_uri_template` with its one `{client_id}` replaced by the encoded client id.
|
|
157
|
+
* Needs no user or connection; its only request is the shared discovery read. Throws `CrouterError` when the field
|
|
158
|
+
* is absent, does not contain `{client_id}` exactly once, or does not expand to a URL on the issuer's origin.
|
|
159
|
+
*/
|
|
160
|
+
async accountAppUrl() {
|
|
161
|
+
const template = (await this.discovery()).account_app_uri_template;
|
|
162
|
+
if (typeof template !== 'string' || !template)
|
|
163
|
+
throw new CrouterError('Directory discovery has no account_app_uri_template');
|
|
164
|
+
const parts = template.split('{client_id}');
|
|
165
|
+
if (parts.length !== 2)
|
|
166
|
+
throw new CrouterError("Directory discovery's account_app_uri_template must contain {client_id} exactly once");
|
|
167
|
+
const expanded = parts.join(encodeURIComponent(this.options.clientId));
|
|
168
|
+
const origin = new URL(this.issuer).origin;
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = new URL(expanded);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
parsed = undefined;
|
|
175
|
+
}
|
|
176
|
+
if (parsed?.origin !== origin)
|
|
177
|
+
throw new CrouterError(`Directory discovery's account_app_uri_template is not a URL on ${origin}`);
|
|
178
|
+
return expanded;
|
|
179
|
+
}
|
|
180
|
+
/** Start a sign-in. When `scopes` includes `openid` the result carries the `nonce` to pass to the exchange; with literal scopes its type is `string`.
|
|
181
|
+
* `prompt: 'consent'` makes the directory show its consent screen even when the person already approved these scopes. */
|
|
182
|
+
async authorizeUrl({ scopes, state, codeVerifier, prompt }) {
|
|
183
|
+
for (const scope of scopes) {
|
|
184
|
+
if (scope === 'openid' || scope === 'email' || scope === 'profile')
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
parseScope(scope);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
throw new APIError(400, 'invalid_scope', `Unsupported scope: ${scope}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const verifier = codeVerifier ?? randomBase64url(32);
|
|
194
|
+
const nonce = scopes.includes('openid') ? randomBase64url(32) : undefined;
|
|
195
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
196
|
+
const challenge = base64url(new Uint8Array(hash));
|
|
197
|
+
const url = new URL((await this.discovery()).authorization_endpoint);
|
|
198
|
+
url.search = new URLSearchParams({
|
|
199
|
+
client_id: this.options.clientId, response_type: 'code', redirect_uri: this.options.redirectUri,
|
|
200
|
+
scope: scopes.join(' '), state, code_challenge: challenge, code_challenge_method: 'S256',
|
|
201
|
+
...(nonce ? { nonce } : {}), ...(prompt === undefined ? {} : { prompt }),
|
|
202
|
+
}).toString();
|
|
203
|
+
return { url: url.href, codeVerifier: verifier, state, ...(nonce ? { nonce } : {}) };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Finish a sign-in that asked for runtime scopes and return the person's `Connection`. A sign-in-only
|
|
207
|
+
* request (`openid`, `openid email`, `openid profile`, or `openid email profile`) gets no connection from the directory; finish it with
|
|
208
|
+
* `exchangeIdentity`, which this method names when it receives such a response.
|
|
209
|
+
*/
|
|
210
|
+
async exchangeCode({ code, codeVerifier, state, expectedState, nonce }) {
|
|
211
|
+
assertState(state, expectedState);
|
|
212
|
+
const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
|
|
213
|
+
if (response.refresh_token === undefined && response.runtimeUrl === undefined && typeof response.id_token === 'string') {
|
|
214
|
+
throw new CrouterError('The directory answered a sign-in-only request (openid, optionally with email and profile) with an identity and no connection; finish a sign-in-only request with exchangeIdentity(), not exchangeCode()', 'oauth_exchange_mismatch');
|
|
215
|
+
}
|
|
216
|
+
const connection = this.connectionFrom(response);
|
|
217
|
+
if (connection.idToken) {
|
|
218
|
+
const claims = await this.verifyIdToken(connection.idToken, { nonce });
|
|
219
|
+
if (typeof claims.nonce !== 'string' || claims.nonce !== nonce)
|
|
220
|
+
throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
|
|
221
|
+
}
|
|
222
|
+
return connection;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Finish a sign-in-only request (`authorizeUrl` with scopes `openid` or `openid email`) and return the
|
|
226
|
+
* person's verified identity. There is no connection, refresh token, or runtime access on this path.
|
|
227
|
+
* `nonce` is the value `authorizeUrl` returned and is required. A response without an ID token, or with
|
|
228
|
+
* one that fails verification, throws `id_token_invalid`; a response carrying a runtime connection (the
|
|
229
|
+
* request asked for runtime scopes) throws `oauth_exchange_mismatch` and must be finished with `exchangeCode`.
|
|
230
|
+
*/
|
|
231
|
+
async exchangeIdentity({ code, codeVerifier, state, expectedState, nonce }) {
|
|
232
|
+
assertState(state, expectedState);
|
|
233
|
+
if (typeof nonce !== 'string' || !nonce)
|
|
234
|
+
throw new CrouterError('exchangeIdentity requires the nonce authorizeUrl returned', 'oauth_nonce_missing');
|
|
235
|
+
const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
|
|
236
|
+
if (response.refresh_token !== undefined || response.runtimeUrl !== undefined) {
|
|
237
|
+
throw new CrouterError('The directory answered with a runtime connection, so the request asked for runtime scopes; finish it with exchangeCode(), or request only openid or openid email for a sign-in-only visitor', 'oauth_exchange_mismatch');
|
|
238
|
+
}
|
|
239
|
+
if (typeof response.id_token !== 'string' || !response.id_token)
|
|
240
|
+
throw new CrouterError('Directory returned no ID token for a sign-in-only request', 'id_token_invalid');
|
|
241
|
+
const claims = await this.verifyIdToken(response.id_token, { nonce });
|
|
242
|
+
return {
|
|
243
|
+
sub: claims.sub,
|
|
244
|
+
...(typeof claims['email'] === 'string' ? { email: claims['email'] } : {}),
|
|
245
|
+
...(typeof claims['email_verified'] === 'boolean' ? { emailVerified: claims['email_verified'] } : {}),
|
|
246
|
+
...profileFromClaims(claims),
|
|
247
|
+
claims,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Refresh the store's idle connections once (the app schedules it, for example daily, with `olderThanMs` of 30
|
|
252
|
+
* days). Each refusal goes through `onRefusal` and the connection's removal, and is counted in `refused`.
|
|
253
|
+
* `failures` holds every connection still stored that could not be refreshed: an error other than a refusal,
|
|
254
|
+
* or a refusal whose `onRefusal` threw (`refusalOf(failure.error)` is set and `failure.error.cause` is what the
|
|
255
|
+
* handler threw); the next run retries those. A connection removed before its turn is skipped.
|
|
256
|
+
*/
|
|
257
|
+
async refreshIdleConnections({ olderThanMs }) {
|
|
258
|
+
if (!this.store.listStale)
|
|
259
|
+
throw new CrouterError('ConnectionStore.listStale(before) is required to refresh idle connections');
|
|
260
|
+
if (!Number.isFinite(olderThanMs) || olderThanMs <= 0)
|
|
261
|
+
throw new CrouterError('olderThanMs must be a positive number');
|
|
262
|
+
const stale = await this.store.listStale(new Date(Date.now() - olderThanMs));
|
|
263
|
+
let refreshed = 0;
|
|
264
|
+
let refused = 0;
|
|
265
|
+
const failures = [];
|
|
266
|
+
for (const connection of stale) {
|
|
267
|
+
try {
|
|
268
|
+
// refresh() holds the cross-process store lock and reloads before rotating.
|
|
269
|
+
const current = await this.refresh(connection);
|
|
270
|
+
if (current.refreshToken !== connection.refreshToken)
|
|
271
|
+
refreshed++;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error instanceof CrouterError && error.code === 'not_connected')
|
|
275
|
+
continue;
|
|
276
|
+
if (error instanceof APIError && this.settled.has(error))
|
|
277
|
+
refused++;
|
|
278
|
+
else
|
|
279
|
+
failures.push({ userId: connection.userId, error });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { refreshed, refused, failures };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Rotate `connection`'s refresh token through the store: under the user's lock it reloads, and when another
|
|
286
|
+
* process already rotated (or the person signed in again) it returns the stored connection without calling the
|
|
287
|
+
* directory. Nothing stored throws `CrouterError` `not_connected`. A refusal runs `onRefusal`, removes the
|
|
288
|
+
* connection, and rejects with the directory's `AuthenticationError` (`refusalOf(error)` set).
|
|
289
|
+
*/
|
|
290
|
+
refresh(connection) {
|
|
291
|
+
assertNotInHandler(connection.userId);
|
|
292
|
+
const key = `${connection.userId}\u0000${connection.refreshToken}`;
|
|
293
|
+
const existing = this.inFlight.get(key);
|
|
294
|
+
if (existing)
|
|
295
|
+
return existing;
|
|
296
|
+
const pending = this.rotate(connection);
|
|
297
|
+
this.inFlight.set(key, pending);
|
|
298
|
+
const forget = () => { if (this.inFlight.get(key) === pending)
|
|
299
|
+
this.inFlight.delete(key); };
|
|
300
|
+
pending.then(forget, forget);
|
|
301
|
+
return pending;
|
|
302
|
+
}
|
|
303
|
+
async rotate(connection) {
|
|
304
|
+
const { userId } = connection;
|
|
305
|
+
// C1/M1: the locked closure never throws a refusal (a throw rolls a Postgres store back) and never runs app code.
|
|
306
|
+
const rotation = await this.store.lock(userId, async () => {
|
|
307
|
+
const stored = await this.store.load(userId);
|
|
308
|
+
if (!stored)
|
|
309
|
+
return { kind: 'missing' };
|
|
310
|
+
if (stored.refreshToken !== connection.refreshToken)
|
|
311
|
+
return { kind: 'current', connection: stored };
|
|
312
|
+
let response;
|
|
313
|
+
// The refresh's one deadline starts here, with the lock held, so the lock is held no longer than `timeoutMs`
|
|
314
|
+
// for the discovery read, the signing and the token request together (plus the store's own time).
|
|
315
|
+
try {
|
|
316
|
+
response = await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: stored.refreshToken }, Date.now() + this.timeoutMs);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (error instanceof APIError && error.origin === 'directory' && isRefusalCode(error.code))
|
|
320
|
+
return { kind: 'refused', error, stored };
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
const updated = this.connectionFrom(response, stored);
|
|
324
|
+
await this.store.save(updated);
|
|
325
|
+
return { kind: 'current', connection: updated };
|
|
326
|
+
});
|
|
327
|
+
if (rotation.kind === 'current')
|
|
328
|
+
return rotation.connection;
|
|
329
|
+
if (rotation.kind === 'missing')
|
|
330
|
+
throw notConnected(userId);
|
|
331
|
+
const { error, stored } = rotation;
|
|
332
|
+
const refusal = { kind: refusalKindOf(error.code), code: error.code, userId, grantId: stored.grantId, error };
|
|
333
|
+
attachRefusal(error, refusal);
|
|
334
|
+
const handler = this.options.onRefusal;
|
|
335
|
+
if (handler) {
|
|
336
|
+
const scope = handlerScope();
|
|
337
|
+
try {
|
|
338
|
+
await (scope ? scope.run(userId, () => handler(refusal)) : handler(refusal));
|
|
339
|
+
}
|
|
340
|
+
catch (handlerError) {
|
|
341
|
+
// The connection stays stored, so the next use is refused again and calls the handler again.
|
|
342
|
+
Object.defineProperty(error, 'cause', { value: handlerError, configurable: true, writable: true });
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await this.store.lock(userId, () => this.store.remove(userId, stored.refreshToken));
|
|
348
|
+
}
|
|
349
|
+
catch (removeError) {
|
|
350
|
+
Object.defineProperty(error, 'cause', { value: removeError, configurable: true, writable: true });
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
this.settled.add(error);
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A runtime client on `userId`'s stored connection; `runtimeUrl` on it names the runtime it calls. Nothing
|
|
358
|
+
* stored throws `CrouterError` `not_connected` (show "sign in again"). The client refreshes through the store;
|
|
359
|
+
* once its connection is refused (and `onRefusal` resolved) it throws that same refusal on every later call
|
|
360
|
+
* without asking the directory again. Called for the user whose `onRefusal` is running, it throws
|
|
361
|
+
* `refusal_handler_reentry`.
|
|
362
|
+
*/
|
|
363
|
+
async client(userId) {
|
|
364
|
+
assertNotInHandler(userId);
|
|
365
|
+
const connection = await this.store.load(userId);
|
|
366
|
+
if (!connection)
|
|
367
|
+
throw notConnected(userId);
|
|
368
|
+
let current = connection;
|
|
369
|
+
let refused;
|
|
370
|
+
const refresh = async (force) => {
|
|
371
|
+
if (refused)
|
|
372
|
+
throw refused;
|
|
373
|
+
if (!force && current.accessToken && current.accessTokenExpiresAt - Date.now() > 30_000)
|
|
374
|
+
return;
|
|
375
|
+
try {
|
|
376
|
+
current = await this.refresh(current);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (error instanceof APIError && this.settled.has(error))
|
|
380
|
+
refused = error;
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return new ConnectedCrouter(connection, {
|
|
385
|
+
tokenSource: async () => { await refresh(false); return current.accessToken; },
|
|
386
|
+
onUnauthorized: async () => { await refresh(true); },
|
|
387
|
+
...(this.options.fetch ? { fetch: this.options.fetch } : {}),
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
|
|
392
|
+
* `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
|
|
393
|
+
*/
|
|
394
|
+
watchRuns(options) {
|
|
395
|
+
return new RunWatcher((userId) => this.client(userId), options);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Follow the grants of the people `users()` names, and hand each change (and each user's first reading in this
|
|
399
|
+
* process) to `onChanged` (see `GrantWatchOptions`). Grants are read with `client(userId)`, so refusals reach
|
|
400
|
+
* `onRefusal`. Call `start()`.
|
|
401
|
+
*/
|
|
402
|
+
watchGrants(options) {
|
|
403
|
+
return new GrantWatcher((userId) => this.client(userId), options);
|
|
404
|
+
}
|
|
405
|
+
/** Verify an ID token: ES256 signature against the discovered JWKS, `iss`, `aud` = client id, `exp`, and `nonce` when given. A token that fails any check throws `id_token_invalid`; a JWKS that can't be fetched or is malformed rethrows that error. */
|
|
406
|
+
async verifyIdToken(idToken, { nonce } = {}) {
|
|
407
|
+
// One deadline for the operation: the discovery read and the key-set read share it.
|
|
408
|
+
const deadlineAt = Date.now() + this.timeoutMs;
|
|
409
|
+
const discovery = await this.discoveryWithin(deadlineAt);
|
|
410
|
+
this.jwks ??= new JwksCache(this.issuer, this.fetcher, discovery.jwks_uri, { timeoutMs: this.timeoutMs });
|
|
411
|
+
const invalid = (reason) => new CrouterError(`ID token failed verification: ${reason}`, 'id_token_invalid');
|
|
412
|
+
try {
|
|
413
|
+
decodeProtectedHeader(idToken);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
throw invalid('it is not a signed JWT');
|
|
417
|
+
}
|
|
418
|
+
let claims;
|
|
419
|
+
try {
|
|
420
|
+
claims = await withinDeadline(verifyIdToken({ token: idToken, jwks: this.jwks, issuer: this.issuer, audience: this.options.clientId }), deadlineAt);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
// A token that fails verification is id_token_invalid; a JWKS that cannot be fetched or is malformed is the
|
|
424
|
+
// directory's fault, not the token's, and is rethrown as is. jose's token errors (bad signature, wrong iss/aud,
|
|
425
|
+
// expired, no matching key) carry an ERR_J* code; ERR_JWKS_INVALID and ERR_JWKS_TIMEOUT describe the key set.
|
|
426
|
+
// The identity package's own checks throw "Invalid ID token …". Duck-typed, since jose may be a separate copy.
|
|
427
|
+
const joseCode = error?.code;
|
|
428
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
429
|
+
if (error instanceof FetchDeadlineError)
|
|
430
|
+
throw mapError(deadlineExceeded(error.deadlineAt));
|
|
431
|
+
if (error instanceof APIError)
|
|
432
|
+
throw mapError(error);
|
|
433
|
+
const keySetFault = joseCode === 'ERR_JWKS_INVALID' || joseCode === 'ERR_JWKS_TIMEOUT';
|
|
434
|
+
if ((typeof joseCode === 'string' && joseCode.startsWith('ERR_J') && !keySetFault) || /^Invalid ID token/.test(message))
|
|
435
|
+
throw invalid(message);
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
if (nonce !== undefined && claims.nonce !== nonce)
|
|
439
|
+
throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
|
|
440
|
+
return claims;
|
|
441
|
+
}
|
|
442
|
+
async revoke(connection) {
|
|
443
|
+
const deadlineAt = Date.now() + this.timeoutMs;
|
|
444
|
+
const metadata = await this.discoveryWithin(deadlineAt);
|
|
445
|
+
if (!metadata.revocation_endpoint)
|
|
446
|
+
return false;
|
|
447
|
+
await this.post(metadata.revocation_endpoint, { token: connection.refreshToken, token_type_hint: 'refresh_token' }, true, deadlineAt);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
/** A token request. `deadlineAt` is the whole operation's deadline (discovery included); without one it starts now. */
|
|
451
|
+
async tokenResponse(params, deadlineAt = Date.now() + this.timeoutMs) {
|
|
452
|
+
const metadata = await this.discoveryWithin(deadlineAt);
|
|
453
|
+
if (!metadata.token_endpoint_auth_methods_supported?.includes('private_key_jwt'))
|
|
454
|
+
throw new CrouterError('Directory does not support private_key_jwt');
|
|
455
|
+
return await this.post(metadata.token_endpoint, params, false, deadlineAt);
|
|
456
|
+
}
|
|
457
|
+
connectionFrom(response, previous) {
|
|
458
|
+
if (typeof response.access_token !== 'string' || typeof response.refresh_token !== 'string')
|
|
459
|
+
throw new CrouterError('Directory returned an invalid token response');
|
|
460
|
+
const claims = decodeJwt(response.access_token);
|
|
461
|
+
const runtimeUrl = typeof response.runtimeUrl === 'string' && response.runtimeUrl ? response.runtimeUrl : previous?.runtimeUrl;
|
|
462
|
+
if (typeof claims.sub !== 'string' || typeof claims.grant !== 'string' || typeof claims.exp !== 'number' || !runtimeUrl) {
|
|
463
|
+
throw new CrouterError('Directory returned an incomplete connection');
|
|
464
|
+
}
|
|
465
|
+
const idToken = typeof response.id_token === 'string' ? response.id_token : previous?.idToken;
|
|
466
|
+
return {
|
|
467
|
+
userId: claims.sub, runtimeUrl, refreshToken: response.refresh_token,
|
|
468
|
+
grantId: claims.grant,
|
|
469
|
+
accessToken: response.access_token, accessTokenExpiresAt: claims.exp * 1_000,
|
|
470
|
+
...(idToken ? { idToken } : {}),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async post(endpoint, values, revocation = false, deadlineAt = Date.now() + this.timeoutMs) {
|
|
474
|
+
const key = this.options.privateKey;
|
|
475
|
+
if (typeof key !== 'string' && key.alg !== undefined && key.alg !== 'ES256' && key.alg !== 'RS256')
|
|
476
|
+
throw new CrouterError('privateKey must use ES256 or RS256');
|
|
477
|
+
let alg;
|
|
478
|
+
let imported;
|
|
479
|
+
if (typeof key === 'string') {
|
|
480
|
+
try {
|
|
481
|
+
imported = await importPKCS8(key, 'ES256');
|
|
482
|
+
alg = 'ES256';
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
imported = await importPKCS8(key, 'RS256');
|
|
486
|
+
alg = 'RS256';
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
alg = key.alg === 'RS256' || key.kty === 'RSA' ? 'RS256' : 'ES256';
|
|
491
|
+
imported = await importJWK(key, alg);
|
|
492
|
+
}
|
|
493
|
+
const kid = this.options.keyId ?? (typeof key === 'string' ? undefined : key.kid);
|
|
494
|
+
// `deadlineAt` is the whole operation's: discovery, signing, every attempt, the reading of each answer and each retry wait.
|
|
495
|
+
for (let attempt = 0;; attempt++) {
|
|
496
|
+
const now = Math.floor(Date.now() / 1000);
|
|
497
|
+
const assertion = await new SignJWT({})
|
|
498
|
+
.setProtectedHeader({ alg, typ: 'JWT', ...(kid ? { kid } : {}) })
|
|
499
|
+
.setIssuer(this.options.clientId).setSubject(this.options.clientId).setAudience(endpoint)
|
|
500
|
+
.setIssuedAt(now).setExpirationTime(now + 60).setJti(crypto.randomUUID()).sign(imported);
|
|
501
|
+
const answer = await this.bounded(endpoint, {
|
|
502
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
503
|
+
body: new URLSearchParams({ ...values, client_id: this.options.clientId, client_assertion_type: assertionType, client_assertion: assertion }),
|
|
504
|
+
}, deadlineAt, async (response) => {
|
|
505
|
+
if (revocation && response.ok)
|
|
506
|
+
return { response, payload: undefined };
|
|
507
|
+
return { response, payload: await response.json() };
|
|
508
|
+
});
|
|
509
|
+
const { response, payload } = answer;
|
|
510
|
+
if (revocation && response.ok)
|
|
511
|
+
return undefined;
|
|
512
|
+
if (response.ok)
|
|
513
|
+
return payload;
|
|
514
|
+
const refusal = payload;
|
|
515
|
+
// invalid_grant carries the directory's reason as its error_description; a reason it does not name is grant_revoked.
|
|
516
|
+
const description = refusal.error_description ?? '';
|
|
517
|
+
const code = refusal.error === 'invalid_grant'
|
|
518
|
+
? description === 'runtime_provisioning' || isRefusalCode(description) ? description : 'grant_revoked'
|
|
519
|
+
: refusal.error === 'invalid_client' ? 'unauthorized' : 'invalid_request';
|
|
520
|
+
const reconnect = isRefusalCode(code);
|
|
521
|
+
if (code === 'runtime_provisioning' && attempt < 2) {
|
|
522
|
+
const retryAfter = refusal.retry_after_s ?? Number(response.headers.get('retry-after'));
|
|
523
|
+
// A Retry-After longer than the time left fails now (DeadlineExceededError) instead of sleeping past the deadline.
|
|
524
|
+
try {
|
|
525
|
+
await sleepWithin(500 * 2 ** attempt * (0.5 + Math.random()), { deadlineAt }, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined);
|
|
526
|
+
}
|
|
527
|
+
catch (stop) {
|
|
528
|
+
throw stop instanceof APIError ? mapError(stop) : stop;
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const message = refusal.error_description ?? refusal.error ?? 'Directory token request failed';
|
|
533
|
+
const keyHint = code === 'unauthorized' ? await describeSigningKey(key, kid) : undefined;
|
|
534
|
+
// mapError gives the refusal its status class (401 → AuthenticationError) as errors.md documents.
|
|
535
|
+
throw mapError(new APIError(code === 'runtime_provisioning' ? 503 : reconnect || code === 'unauthorized' ? 401 : response.status, code, keyHint ? `${message}: ${keyHint.hint}` : message, code === 'invalid_request' ? { oauth_error: refusal.error }
|
|
536
|
+
: keyHint ? { oauth_error: 'invalid_client', kid: keyHint.kid, ...(keyHint.thumbprint ? { key_thumbprint: keyHint.thumbprint } : {}) } : undefined, response.headers, { origin: 'directory', type: code === 'runtime_provisioning' ? 'server_error' : code === 'invalid_request' ? 'invalid_request_error' : 'authentication_error',
|
|
537
|
+
retryable: code === 'runtime_provisioning', user_action: reconnect ? 'reconnect' : undefined }));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Name the key an `invalid_client` refusal rejected, so a developer who switched key files before
|
|
543
|
+
* uploading the public JWK sees which key the SDK signed with and where it must be registered.
|
|
544
|
+
*/
|
|
545
|
+
async function describeSigningKey(key, kid) {
|
|
546
|
+
const question = "is this key's public JWK registered on the app's Sign-in tab in the console?";
|
|
547
|
+
if (kid)
|
|
548
|
+
return { kid, hint: `the directory rejected the client assertion signed with key id (kid) "${kid}"; ${question}` };
|
|
549
|
+
// An assertion without a kid: the console lists such a key by its RFC 7638 thumbprint.
|
|
550
|
+
let thumbprint;
|
|
551
|
+
try {
|
|
552
|
+
thumbprint = typeof key === 'string'
|
|
553
|
+
? await calculateJwkThumbprint(await exportJWK(await importPKCS8(key, 'ES256', { extractable: true }).catch(() => importPKCS8(key, 'RS256', { extractable: true }))), 'sha256')
|
|
554
|
+
: await calculateJwkThumbprint(key, 'sha256');
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
thumbprint = undefined;
|
|
558
|
+
}
|
|
559
|
+
const named = thumbprint ? `signed without a kid by the key whose thumbprint is "${thumbprint}"` : 'signed without a kid';
|
|
560
|
+
return { kid: null, ...(thumbprint ? { thumbprint } : {}), hint: `the directory rejected the client assertion ${named}; ${question}` };
|
|
561
|
+
}
|
|
562
|
+
/** The discovery JSON as a `Discovery`, or `CrouterError` naming the field that is wrong. `authorization_endpoint`,
|
|
563
|
+
* `token_endpoint` and `jwks_uri` must be absolute `https:` URLs (`http:` when the issuer itself is `http:`, for a
|
|
564
|
+
* local directory); `revocation_endpoint`, when present, likewise; `token_endpoint_auth_methods_supported`, when
|
|
565
|
+
* present, an array of strings. Which origin an endpoint may sit on is not restricted: the directory chooses it. */
|
|
566
|
+
function parseDiscovery(value, issuer) {
|
|
567
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
568
|
+
throw new CrouterError('Directory discovery is not a JSON object');
|
|
569
|
+
const record = value;
|
|
570
|
+
const allowHttp = new URL(issuer).protocol === 'http:';
|
|
571
|
+
const endpoint = (name) => {
|
|
572
|
+
const field = record[name];
|
|
573
|
+
if (field === undefined || field === null || field === '')
|
|
574
|
+
throw new CrouterError('Directory discovery has missing endpoints');
|
|
575
|
+
if (typeof field !== 'string')
|
|
576
|
+
throw new CrouterError(`Directory discovery's ${name} must be a URL string`);
|
|
577
|
+
let url;
|
|
578
|
+
try {
|
|
579
|
+
url = new URL(field);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
throw new CrouterError(`Directory discovery's ${name} is not an absolute URL`);
|
|
583
|
+
}
|
|
584
|
+
if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:'))
|
|
585
|
+
throw new CrouterError(`Directory discovery's ${name} must be an https URL`);
|
|
586
|
+
return field;
|
|
587
|
+
};
|
|
588
|
+
const methods = record['token_endpoint_auth_methods_supported'];
|
|
589
|
+
if (methods !== undefined && (!Array.isArray(methods) || methods.some((method) => typeof method !== 'string'))) {
|
|
590
|
+
throw new CrouterError("Directory discovery's token_endpoint_auth_methods_supported must be an array of strings");
|
|
591
|
+
}
|
|
592
|
+
const template = record['account_app_uri_template'];
|
|
593
|
+
return {
|
|
594
|
+
authorization_endpoint: endpoint('authorization_endpoint'),
|
|
595
|
+
token_endpoint: endpoint('token_endpoint'),
|
|
596
|
+
jwks_uri: endpoint('jwks_uri'),
|
|
597
|
+
...(record['revocation_endpoint'] === undefined || record['revocation_endpoint'] === null ? {} : { revocation_endpoint: endpoint('revocation_endpoint') }),
|
|
598
|
+
...(methods === undefined ? {} : { token_endpoint_auth_methods_supported: methods }),
|
|
599
|
+
...(typeof template === 'string' ? { account_app_uri_template: template } : {}),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function notConnected(userId) {
|
|
603
|
+
return new CrouterError(`No stored connection for ${userId}: they must sign in with crouter cloud again`, 'not_connected');
|
|
604
|
+
}
|
|
605
|
+
function assertNotInHandler(userId) {
|
|
606
|
+
if (handlerScope()?.getStore() === userId) {
|
|
607
|
+
throw new CrouterError(`onRefusal for ${userId} called that user's runtime; their connection is dead, so act only on the app's own data there`, 'refusal_handler_reentry');
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// expectedState is the state saved by the app at authorizeUrl(), not a value from the callback.
|
|
611
|
+
function assertState(state, expectedState) {
|
|
612
|
+
if (!state || !expectedState || state !== expectedState)
|
|
613
|
+
throw new CrouterError('OAuth state is missing or does not match', 'oauth_state_mismatch');
|
|
614
|
+
}
|
|
615
|
+
function randomBase64url(length) { return base64url(crypto.getRandomValues(new Uint8Array(length))); }
|
|
616
|
+
function base64url(bytes) {
|
|
617
|
+
let binary = '';
|
|
618
|
+
for (const byte of bytes)
|
|
619
|
+
binary += String.fromCharCode(byte);
|
|
620
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
621
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type JWK } from 'jose';
|
|
2
|
+
/** Make an app signing key and the public JWK to register in the directory. */
|
|
3
|
+
export declare function generateKeyPair(): Promise<{
|
|
4
|
+
privateJwk: JWK;
|
|
5
|
+
publicJwk: JWK;
|
|
6
|
+
kid: string;
|
|
7
|
+
}>;
|
|
8
|
+
/** Check that `value` (a JWK object or its JSON text) is a private signing key with a `kid`, and return it as
|
|
9
|
+
* `OAuth2Options.privateKey` takes it. `source` names where it came from in the error, e.g. a file path. */
|
|
10
|
+
export declare function parsePrivateJwk(value: string | JWK, source?: string): JWK & {
|
|
11
|
+
d: string;
|
|
12
|
+
kid: string;
|
|
13
|
+
};
|
|
14
|
+
/** Read a private JWK file, such as the one `crouter-sdk keygen` writes, and check it as `parsePrivateJwk` does. Node only. */
|
|
15
|
+
export declare function privateKeyFromFile(path: string): Promise<JWK & {
|
|
16
|
+
d: string;
|
|
17
|
+
kid: string;
|
|
18
|
+
}>;
|