@wtfalch/auth 0.29.0 → 0.31.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 +112 -0
- package/dist/auth.d.ts +29 -1
- package/dist/auth.js +138 -4
- package/dist/broker.d.ts +6 -0
- package/dist/broker.js +5 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +17 -1
- package/dist/config.d.ts +34 -4
- package/dist/config.js +1 -0
- package/dist/desktop.d.ts +199 -0
- package/dist/desktop.js +528 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +15 -4
- package/dist/mcp.js +24 -10
- package/dist/names.d.ts +5 -3
- package/dist/names.js +15 -5
- package/dist/namespace-browser.js +4 -3
- package/dist/namespace-session.js +2 -2
- package/dist/next.js +1 -1
- package/dist/oidc.d.ts +24 -1
- package/dist/oidc.js +57 -1
- package/dist/onboarding.d.ts +28 -0
- package/dist/onboarding.js +91 -0
- package/dist/person-token.d.ts +44 -10
- package/dist/person-token.js +108 -36
- package/dist/qr-door-scene.d.ts +5 -0
- package/dist/qr-door-scene.js +14 -0
- package/dist/qr-door.js +14 -3
- package/dist/redirect.js +4 -3
- package/dist/sessions.d.ts +0 -5
- package/dist/sessions.js +0 -60
- package/dist/util.d.ts +14 -0
- package/dist/util.js +16 -0
- package/dist/worker-session.d.ts +14 -0
- package/dist/worker-session.js +29 -16
- package/package.json +8 -4
- package/dist/testing.d.ts +0 -57
- package/dist/testing.js +0 -138
package/dist/names.d.ts
CHANGED
|
@@ -39,9 +39,11 @@ export interface NameRules {
|
|
|
39
39
|
}
|
|
40
40
|
export declare const DEFAULT_RULES: NameRules;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* NFKC-normalised and confusables-resolved -- so a look-alike from another
|
|
43
|
+
* script (Cyrillic `а` in `аdmin`) reads as the Latin letter it imitates
|
|
44
|
+
* instead of being dropped -- then lowercase letters with the digits read as
|
|
45
|
+
* letters and nothing else, runs collapsed. Every reading, since `1` may be
|
|
46
|
+
* an `i` or an `l`; the first is the one a list entry is stored under.
|
|
45
47
|
*/
|
|
46
48
|
export declare function flattenings(name: string): string[];
|
|
47
49
|
/** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
|
package/dist/names.js
CHANGED
|
@@ -82,14 +82,22 @@ const LETTERS = {
|
|
|
82
82
|
'8': ['b'],
|
|
83
83
|
'9': ['g'],
|
|
84
84
|
};
|
|
85
|
+
/** Confusables resolved per character, outside any matcher, so a plain string can be normalised. */
|
|
86
|
+
const confusablesTransform = resolveConfusablesTransformer().transform;
|
|
85
87
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
88
|
+
* NFKC-normalised and confusables-resolved -- so a look-alike from another
|
|
89
|
+
* script (Cyrillic `а` in `аdmin`) reads as the Latin letter it imitates
|
|
90
|
+
* instead of being dropped -- then lowercase letters with the digits read as
|
|
91
|
+
* letters and nothing else, runs collapsed. Every reading, since `1` may be
|
|
92
|
+
* an `i` or an `l`; the first is the one a list entry is stored under.
|
|
89
93
|
*/
|
|
90
94
|
export function flattenings(name) {
|
|
95
|
+
const resolved = Array.from(name.normalize('NFKC'), (char) => {
|
|
96
|
+
const codePoint = char.codePointAt(0);
|
|
97
|
+
return String.fromCodePoint(confusablesTransform(codePoint) ?? codePoint);
|
|
98
|
+
}).join('');
|
|
91
99
|
let forms = [''];
|
|
92
|
-
for (const char of
|
|
100
|
+
for (const char of resolved.toLowerCase()) {
|
|
93
101
|
const readings = /[a-z]/.test(char) ? [char] : (LETTERS[char] ?? []);
|
|
94
102
|
if (readings.length === 0)
|
|
95
103
|
continue;
|
|
@@ -147,7 +155,9 @@ export function checkName(name, rules = DEFAULT_RULES) {
|
|
|
147
155
|
return { ok: false, reason: 'disallowed' };
|
|
148
156
|
}
|
|
149
157
|
// Last, so that a number that means something (`14.88` reads as `lab`) is refused for what it is.
|
|
150
|
-
|
|
158
|
+
// Digits on the ends are also read as a counter, so `admin1` is `admin`.
|
|
159
|
+
const forms = [typed, typed.replace(/^\d+|\d+$/g, '')].flatMap(flattenings);
|
|
160
|
+
if (forms.some((form) => reserved.has(form))) {
|
|
151
161
|
return { ok: false, reason: 'reserved' };
|
|
152
162
|
}
|
|
153
163
|
return { ok: true };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
2
|
import { resolveNamespaceContext } from './namespaces.js';
|
|
3
3
|
import { safeLoginHint, safeNextPath } from './redirect.js';
|
|
4
|
-
import { base64url } from './util.js';
|
|
5
|
-
const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
|
|
4
|
+
import { ORG_CLAIM, base64url } from './util.js';
|
|
6
5
|
const ttl = 10 * 60_000;
|
|
7
6
|
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
8
7
|
function read(storage, key) {
|
|
@@ -173,7 +172,7 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
173
172
|
clockTolerance: 60,
|
|
174
173
|
});
|
|
175
174
|
if (payload.nonce !== tx.nonce ||
|
|
176
|
-
payload[
|
|
175
|
+
payload[ORG_CLAIM] !== binding.organizationId ||
|
|
177
176
|
(payload.azp !== undefined && payload.azp !== binding.clientId) ||
|
|
178
177
|
(Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== binding.clientId))
|
|
179
178
|
return reject();
|
|
@@ -201,6 +200,8 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
201
200
|
return {
|
|
202
201
|
accountChanged,
|
|
203
202
|
currentSession,
|
|
203
|
+
// This path keeps no ID token, so there is no hint to build a sign-out URL from.
|
|
204
|
+
endSessionUrl: () => null,
|
|
204
205
|
silentSignInAvailable,
|
|
205
206
|
completeSignIn,
|
|
206
207
|
async refresh() {
|
|
@@ -3,7 +3,7 @@ import * as client from 'openid-client';
|
|
|
3
3
|
import { BrokerError } from './broker.js';
|
|
4
4
|
import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
|
|
5
5
|
import { AuthError, isOAuthRefusal, rejected } from './oidc.js';
|
|
6
|
-
import { expiryOf, issuedAtOf } from './util.js';
|
|
6
|
+
import { expiryOf, issuedAtOf, recheckSeconds } from './util.js';
|
|
7
7
|
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
8
8
|
export const STATUS_FRESH_MS = 60_000;
|
|
9
9
|
/** How long the last good answer carries on while the service cannot be asked. */
|
|
@@ -435,7 +435,7 @@ export function namespaceSessions(deps) {
|
|
|
435
435
|
const exp = expiryOf(loaded.tokens.idt);
|
|
436
436
|
const iat = issuedAtOf(loaded.tokens.idt);
|
|
437
437
|
const valid = exp !== null && exp > now() - 60;
|
|
438
|
-
const stale = iat !== null && now() - iat >= options().freshness;
|
|
438
|
+
const stale = iat !== null && now() - iat >= recheckSeconds(options().freshness);
|
|
439
439
|
const due = force || exp === null || exp - options().refreshWindow <= now() || stale;
|
|
440
440
|
if (due && loaded.tokens.rt) {
|
|
441
441
|
loaded = await refresh(loaded, valid);
|
package/dist/next.js
CHANGED
|
@@ -99,7 +99,7 @@ export function nextAuth(options) {
|
|
|
99
99
|
* fresh id, which fails the same way — two requests to the issuer per lap,
|
|
100
100
|
* no error page, nothing in a log, forever. That is not hypothetical: a
|
|
101
101
|
* client id missing from the broker's registry produced exactly this on
|
|
102
|
-
*
|
|
102
|
+
* boule.companyincluded.com, and the visible symptom was Cloudflare rate-limiting
|
|
103
103
|
* the operator, twenty minutes before anybody could see a 403 underneath
|
|
104
104
|
* it. The refusal was diagnosable from the first request and the loop hid
|
|
105
105
|
* it behind thousands.
|
package/dist/oidc.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import type { ResolvedOptions } from './config.js';
|
|
3
|
-
|
|
3
|
+
import { ORG_CLAIM } from './util.js';
|
|
4
|
+
export { ORG_CLAIM };
|
|
4
5
|
/**
|
|
5
6
|
* How long a rotated refresh token still answers with the tokens it rotated
|
|
6
7
|
* to. A request that started just before another one finished the same
|
|
@@ -32,6 +33,19 @@ export interface Tokens {
|
|
|
32
33
|
/** Epoch ms `accessToken` expires at. Present exactly when `accessToken` is and the issuer sent `expires_in`. */
|
|
33
34
|
accessTokenExpiresAt?: number;
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The oldest a logout token may be, by its `iat`, before it is refused (the
|
|
38
|
+
* 60 s clock tolerance comes on top). There is no `jti` store, so this and
|
|
39
|
+
* the required `exp` are all that stop a captured token being replayed.
|
|
40
|
+
*/
|
|
41
|
+
export declare const LOGOUT_TOKEN_MAX_AGE_SECONDS = 120;
|
|
42
|
+
/** The `events` claim key a back-channel logout token carries (OpenID Connect Back-Channel Logout 1.0 §2.4). */
|
|
43
|
+
export declare const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
44
|
+
/** What a verified logout token named: the issuer's user id, its own session id, or (rarely) both. */
|
|
45
|
+
export interface LogoutTokenClaims {
|
|
46
|
+
sub?: string;
|
|
47
|
+
sid?: string;
|
|
48
|
+
}
|
|
35
49
|
export declare class Oidc {
|
|
36
50
|
private readonly options;
|
|
37
51
|
private configuration;
|
|
@@ -62,6 +76,15 @@ export declare class Oidc {
|
|
|
62
76
|
endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
|
|
63
77
|
private tokensFrom;
|
|
64
78
|
verify(idToken: string): Promise<jose.JWTPayload>;
|
|
79
|
+
/**
|
|
80
|
+
* A back-channel logout token (OpenID Connect Back-Channel Logout 1.0
|
|
81
|
+
* §2.4-2.5): signed by this issuer for this client, carrying the
|
|
82
|
+
* back-channel-logout event and neither a `nonce` (the spec forbids one,
|
|
83
|
+
* since this token never rode a browser redirect) nor an empty `sub`/`sid`
|
|
84
|
+
* pair. Throws the same rejected-token errors `verify` does; the caller
|
|
85
|
+
* decides what that means for its own HTTP response.
|
|
86
|
+
*/
|
|
87
|
+
verifyLogoutToken(logoutToken: string): Promise<LogoutTokenClaims>;
|
|
65
88
|
private assertIdentityClaims;
|
|
66
89
|
private assertOrganization;
|
|
67
90
|
}
|
package/dist/oidc.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
|
-
|
|
3
|
+
import { ORG_CLAIM } from './util.js';
|
|
4
|
+
export { ORG_CLAIM };
|
|
4
5
|
/**
|
|
5
6
|
* How long a rotated refresh token still answers with the tokens it rotated
|
|
6
7
|
* to. A request that started just before another one finished the same
|
|
@@ -39,6 +40,14 @@ export function scopeFor(organizationId, extraScopes) {
|
|
|
39
40
|
}
|
|
40
41
|
return scopes.join(' ');
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The oldest a logout token may be, by its `iat`, before it is refused (the
|
|
45
|
+
* 60 s clock tolerance comes on top). There is no `jti` store, so this and
|
|
46
|
+
* the required `exp` are all that stop a captured token being replayed.
|
|
47
|
+
*/
|
|
48
|
+
export const LOGOUT_TOKEN_MAX_AGE_SECONDS = 120;
|
|
49
|
+
/** The `events` claim key a back-channel logout token carries (OpenID Connect Back-Channel Logout 1.0 §2.4). */
|
|
50
|
+
export const BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';
|
|
42
51
|
export class Oidc {
|
|
43
52
|
options;
|
|
44
53
|
configuration = null;
|
|
@@ -181,6 +190,53 @@ export class Oidc {
|
|
|
181
190
|
this.assertOrganization(payload);
|
|
182
191
|
return payload;
|
|
183
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* A back-channel logout token (OpenID Connect Back-Channel Logout 1.0
|
|
195
|
+
* §2.4-2.5): signed by this issuer for this client, carrying the
|
|
196
|
+
* back-channel-logout event and neither a `nonce` (the spec forbids one,
|
|
197
|
+
* since this token never rode a browser redirect) nor an empty `sub`/`sid`
|
|
198
|
+
* pair. Throws the same rejected-token errors `verify` does; the caller
|
|
199
|
+
* decides what that means for its own HTTP response.
|
|
200
|
+
*/
|
|
201
|
+
async verifyLogoutToken(logoutToken) {
|
|
202
|
+
let payload;
|
|
203
|
+
try {
|
|
204
|
+
({ payload } = await jose.jwtVerify(logoutToken, await this.keys(), {
|
|
205
|
+
issuer: this.options.issuer,
|
|
206
|
+
audience: this.options.clientId,
|
|
207
|
+
algorithms: ['RS256'],
|
|
208
|
+
clockTolerance: 60,
|
|
209
|
+
maxTokenAge: LOGOUT_TOKEN_MAX_AGE_SECONDS,
|
|
210
|
+
requiredClaims: ['iat', 'exp', 'events'],
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
// A kid this issuer's published keys do not contain refuses the logout
|
|
215
|
+
// token. It is deliberately not part of `rejected()`: for a session's
|
|
216
|
+
// id token the same error can be a just-rotated key inside the JWKS
|
|
217
|
+
// cooldown, which must not sign the person out.
|
|
218
|
+
if (error instanceof jose.errors.JWKSNoMatchingKey) {
|
|
219
|
+
throw new AuthError('exchange', 'the logout token is signed with an unknown key');
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
if (payload.nonce !== undefined) {
|
|
224
|
+
throw new AuthError('exchange', 'a logout token must not carry a nonce');
|
|
225
|
+
}
|
|
226
|
+
const events = payload.events;
|
|
227
|
+
if (typeof events !== 'object' ||
|
|
228
|
+
events === null ||
|
|
229
|
+
Array.isArray(events) ||
|
|
230
|
+
!Object.hasOwn(events, BACKCHANNEL_LOGOUT_EVENT)) {
|
|
231
|
+
throw new AuthError('exchange', 'the logout token carries no back-channel-logout event');
|
|
232
|
+
}
|
|
233
|
+
const sub = typeof payload.sub === 'string' && payload.sub ? payload.sub : undefined;
|
|
234
|
+
const sid = typeof payload.sid === 'string' && payload.sid ? payload.sid : undefined;
|
|
235
|
+
if (!sub && !sid) {
|
|
236
|
+
throw new AuthError('exchange', 'the logout token names neither a subject nor a session');
|
|
237
|
+
}
|
|
238
|
+
return { sub, sid };
|
|
239
|
+
}
|
|
184
240
|
// Both grant responses and stored/device tokens become a User. Keep their
|
|
185
241
|
// identity and time shapes consistent, including optional freshness claims.
|
|
186
242
|
assertIdentityClaims(claims) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding hold: what the broker hands an app's onboarding page during an
|
|
3
|
+
* MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
|
|
4
|
+
* caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
|
|
5
|
+
*
|
|
6
|
+
* `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
|
|
7
|
+
* where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
|
|
8
|
+
* organisation id and `e` the expiry in epoch seconds. The key is the app's
|
|
9
|
+
* broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
|
|
10
|
+
* is a bearer proof that the broker just signed this person in; it carries no
|
|
11
|
+
* token and it is single use at the broker.
|
|
12
|
+
*/
|
|
13
|
+
export interface OnboardingHold {
|
|
14
|
+
holdId: string;
|
|
15
|
+
sub: string;
|
|
16
|
+
organizationId: string;
|
|
17
|
+
}
|
|
18
|
+
/** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
|
|
19
|
+
export declare function signOnboardingHold(input: OnboardingHold & {
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
}, appKey: string): Promise<string>;
|
|
22
|
+
/**
|
|
23
|
+
* The hold's contents when it is well formed, signed with `appKey` and not
|
|
24
|
+
* expired; otherwise null, with no hint which check failed. `now` is unix
|
|
25
|
+
* seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
|
|
26
|
+
* which is constant time.
|
|
27
|
+
*/
|
|
28
|
+
export declare function verifyOnboardingHold(hold: string, appKey: string, now?: number): Promise<OnboardingHold | null>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding hold: what the broker hands an app's onboarding page during an
|
|
3
|
+
* MCP sign-in (ADR 0002), and what the app checks before it trusts the page's
|
|
4
|
+
* caller. Only WebCrypto, so this entry runs on workerd and in Node alike.
|
|
5
|
+
*
|
|
6
|
+
* `HOLD = base64url(JSON {h, s, o, e}) . base64url(HMAC-SHA256(appKey, firstPart))`
|
|
7
|
+
* where `h` is the hold id, `s` the person's ZITADEL user id, `o` their
|
|
8
|
+
* organisation id and `e` the expiry in epoch seconds. The key is the app's
|
|
9
|
+
* broker key (`AUTH_APP_KEY`), which only the broker and that app hold. A hold
|
|
10
|
+
* is a bearer proof that the broker just signed this person in; it carries no
|
|
11
|
+
* token and it is single use at the broker.
|
|
12
|
+
*/
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const HOLD_RE = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/;
|
|
15
|
+
const MAX_HOLD_LENGTH = 2048;
|
|
16
|
+
const toBase64Url = (bytes) => {
|
|
17
|
+
let binary = '';
|
|
18
|
+
for (const byte of bytes)
|
|
19
|
+
binary += String.fromCharCode(byte);
|
|
20
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
21
|
+
};
|
|
22
|
+
const fromBase64Url = (value) => {
|
|
23
|
+
try {
|
|
24
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
25
|
+
const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4));
|
|
26
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const hmacKey = (appKey, usage) => crypto.subtle.importKey('raw', encoder.encode(appKey), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
|
33
|
+
usage,
|
|
34
|
+
]);
|
|
35
|
+
/** Signs a hold for `appKey`. `expiresAt` is epoch seconds. */
|
|
36
|
+
export async function signOnboardingHold(input, appKey) {
|
|
37
|
+
const payload = toBase64Url(encoder.encode(JSON.stringify({
|
|
38
|
+
h: input.holdId,
|
|
39
|
+
s: input.sub,
|
|
40
|
+
o: input.organizationId,
|
|
41
|
+
e: input.expiresAt,
|
|
42
|
+
})));
|
|
43
|
+
const mac = await crypto.subtle.sign('HMAC', await hmacKey(appKey, 'sign'), encoder.encode(payload));
|
|
44
|
+
return `${payload}.${toBase64Url(new Uint8Array(mac))}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The hold's contents when it is well formed, signed with `appKey` and not
|
|
48
|
+
* expired; otherwise null, with no hint which check failed. `now` is unix
|
|
49
|
+
* seconds, the unit of the hold's `e` (a test seam). The signature is compared by `crypto.subtle.verify`,
|
|
50
|
+
* which is constant time.
|
|
51
|
+
*/
|
|
52
|
+
export async function verifyOnboardingHold(hold, appKey, now = Math.floor(Date.now() / 1000)) {
|
|
53
|
+
if (typeof hold !== 'string' || hold.length > MAX_HOLD_LENGTH || !appKey)
|
|
54
|
+
return null;
|
|
55
|
+
const match = HOLD_RE.exec(hold);
|
|
56
|
+
if (!match)
|
|
57
|
+
return null;
|
|
58
|
+
const [, payload, signature] = match;
|
|
59
|
+
const mac = fromBase64Url(signature);
|
|
60
|
+
if (!mac)
|
|
61
|
+
return null;
|
|
62
|
+
let valid;
|
|
63
|
+
try {
|
|
64
|
+
valid = await crypto.subtle.verify('HMAC', await hmacKey(appKey, 'verify'), mac, encoder.encode(payload));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!valid)
|
|
70
|
+
return null;
|
|
71
|
+
const bytes = fromBase64Url(payload);
|
|
72
|
+
if (!bytes)
|
|
73
|
+
return null;
|
|
74
|
+
let claims;
|
|
75
|
+
try {
|
|
76
|
+
claims = JSON.parse(new TextDecoder().decode(bytes));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (claims === null || typeof claims !== 'object')
|
|
82
|
+
return null;
|
|
83
|
+
const { h, s, o, e } = claims;
|
|
84
|
+
if (typeof h !== 'string' || !h || typeof s !== 'string' || !s)
|
|
85
|
+
return null;
|
|
86
|
+
if (typeof o !== 'string' || !o || typeof e !== 'number' || !Number.isFinite(e))
|
|
87
|
+
return null;
|
|
88
|
+
if (e <= now)
|
|
89
|
+
return null;
|
|
90
|
+
return { holdId: h, sub: s, organizationId: o };
|
|
91
|
+
}
|
package/dist/person-token.d.ts
CHANGED
|
@@ -26,21 +26,55 @@ import { type JWTPayload } from 'jose';
|
|
|
26
26
|
*
|
|
27
27
|
* A network failure or a non-2xx from either the discovery document or the
|
|
28
28
|
* introspection endpoint fails closed: `verify` returns `null`, exactly
|
|
29
|
-
* like a bad token, never treated as good.
|
|
30
|
-
* "could not check" versus "checked and refused" -- a caller
|
|
31
|
-
*
|
|
29
|
+
* like a bad token, never treated as good. `verify` has no second outcome
|
|
30
|
+
* for "could not check" versus "checked and refused" -- a caller that needs
|
|
31
|
+
* one calls `verifyOutcome` instead. That method also introspects a JWT
|
|
32
|
+
* (live revocation only shows up there), so it needs the introspection
|
|
33
|
+
* credentials for every token, and tells `invalid`, `expired`, `revoked`
|
|
34
|
+
* and `unavailable` apart. A positive introspection answer is cached for at
|
|
35
|
+
* most 60 seconds; a refusal or an `unavailable` never is.
|
|
32
36
|
*/
|
|
37
|
+
export interface PersonIdentity {
|
|
38
|
+
sub: string;
|
|
39
|
+
/** The ZITADEL organisation that issued the token. Absent when the claim
|
|
40
|
+
* is absent, empty or not a string: a malformed claim is treated as
|
|
41
|
+
* missing and the token still verifies. */
|
|
42
|
+
organizationId?: string;
|
|
43
|
+
}
|
|
44
|
+
/** Why `verifyOutcome` refused a token. `unavailable` means the issuer could
|
|
45
|
+
* not be asked (network error, non-2xx, unreadable answer): retry, and never
|
|
46
|
+
* remember it as a refusal. The others are verdicts: `invalid` (bad
|
|
47
|
+
* signature, issuer, audience, shape or claims; also no introspection
|
|
48
|
+
* credentials), `expired`, and `revoked` (the issuer reports the token
|
|
49
|
+
* inactive). */
|
|
50
|
+
export type PersonTokenRefusal = 'invalid' | 'expired' | 'revoked' | 'unavailable';
|
|
51
|
+
export type PersonTokenOutcome = {
|
|
52
|
+
ok: true;
|
|
53
|
+
identity: PersonIdentity;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
reason: PersonTokenRefusal;
|
|
57
|
+
};
|
|
33
58
|
export interface PersonTokenVerifier {
|
|
34
|
-
/** The token's subject (`sub`), or null for anything that
|
|
35
|
-
* up: bad signature, wrong issuer/audience, expired,
|
|
36
|
-
* unreachable. Refusals never say which.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
/** The token's subject (`sub`) and organisation, or null for anything that
|
|
60
|
+
* does not stand up: bad signature, wrong issuer/audience, expired,
|
|
61
|
+
* inactive, or unreachable. Refusals never say which.
|
|
62
|
+
*
|
|
63
|
+
* `organizationId` is read from the verified JWT claims or the
|
|
64
|
+
* introspection response, same claim either way. Present only when the
|
|
65
|
+
* claim is a non-empty string (every token minted through this SDK's own
|
|
66
|
+
* `scopeFor` carries it); never guessed or defaulted. Neither path checks
|
|
67
|
+
* it against anything fixed: see the module doc above. */
|
|
68
|
+
verify(accessToken: string): Promise<PersonIdentity | null>;
|
|
69
|
+
/** Like `verify`, but says why a token was refused, and also introspects a
|
|
70
|
+
* JWT after its signature checks pass, so a revoked JWT is refused as
|
|
71
|
+
* `revoked` before its `exp`. Needs `introspectionClientId`/`Secret`:
|
|
72
|
+
* without them every token is `invalid`. */
|
|
73
|
+
verifyOutcome(accessToken: string): Promise<PersonTokenOutcome>;
|
|
40
74
|
}
|
|
41
75
|
export interface PersonTokenConfig {
|
|
42
76
|
/** The issuer every token must have been minted by, e.g.
|
|
43
|
-
* `https://auth.
|
|
77
|
+
* `https://auth.companyincluded.com`. */
|
|
44
78
|
issuer: string;
|
|
45
79
|
/** The ZITADEL project id a person's token must carry in `aud` --
|
|
46
80
|
* requested by whatever app mints the token (an `extraScopes` entry of
|
package/dist/person-token.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
|
|
2
|
-
import { timeoutSignal } from './util.js';
|
|
2
|
+
import { ORG_CLAIM, timeoutSignal } from './util.js';
|
|
3
3
|
/** A JWT is three base64url segments; a ZITADEL opaque token is not shaped
|
|
4
4
|
* like that at all, so this is enough to route between `jwtVerify` and
|
|
5
5
|
* introspection without ever trying to parse either kind as the other. */
|
|
@@ -8,6 +8,8 @@ const JWT_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
|
8
8
|
* checks again -- bounded well under the token's own `exp` so a revoked
|
|
9
9
|
* token can't outlive a ZITADEL-side revocation for long. */
|
|
10
10
|
const INTROSPECTION_CACHE_TTL_MS = 60_000;
|
|
11
|
+
/** How long the issuer's discovery document, and the key set built from it, are reused before they are fetched again, so a moved `jwks_uri` or `introspection_endpoint` is picked up without a restart. */
|
|
12
|
+
const DISCOVERY_TTL_MS = 10 * 60_000;
|
|
11
13
|
/** An optional endpoint from the discovery document, required to be on the
|
|
12
14
|
* issuer's own origin -- a compromised or misconfigured discovery
|
|
13
15
|
* response must never point signature checking, or a token's liveness,
|
|
@@ -77,24 +79,50 @@ class PositiveIntrospectionCache {
|
|
|
77
79
|
this.entries.set(token, { payload, expiresAt });
|
|
78
80
|
}
|
|
79
81
|
}
|
|
82
|
+
/** A key-set request that did not complete (network failure, abort). Tagged so it is told apart from every other error a verification can raise. */
|
|
83
|
+
class KeyFetchError extends Error {
|
|
84
|
+
constructor() {
|
|
85
|
+
super('person-token: the key set could not be fetched');
|
|
86
|
+
this.name = 'KeyFetchError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
80
89
|
/** @internal */
|
|
81
90
|
export function createPersonTokenCore(config) {
|
|
82
91
|
const fetchImpl = config.fetch ?? fetch;
|
|
83
92
|
let discovery = null;
|
|
93
|
+
let discoveredAt = 0;
|
|
84
94
|
let jwks = null;
|
|
85
95
|
const introspectionCache = new PositiveIntrospectionCache();
|
|
86
96
|
const document = () => {
|
|
87
|
-
discovery
|
|
97
|
+
if (discovery && Date.now() - discoveredAt >= DISCOVERY_TTL_MS) {
|
|
88
98
|
discovery = null;
|
|
89
|
-
|
|
90
|
-
}
|
|
99
|
+
jwks = null;
|
|
100
|
+
}
|
|
101
|
+
if (!discovery) {
|
|
102
|
+
discoveredAt = Date.now();
|
|
103
|
+
const fetched = discoverDocument(config.issuer, fetchImpl).catch((error) => {
|
|
104
|
+
if (discovery === fetched) {
|
|
105
|
+
discovery = null;
|
|
106
|
+
jwks = null;
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
discovery = fetched;
|
|
111
|
+
}
|
|
91
112
|
return discovery;
|
|
92
113
|
};
|
|
93
114
|
const keys = () => {
|
|
94
115
|
jwks ??= document()
|
|
95
116
|
.then((doc) => createRemoteJWKSet(new URL(doc.jwksUri), {
|
|
96
117
|
cooldownDuration: config.jwksCooldownMs ?? 5_000,
|
|
97
|
-
[customFetch]: (url, init) =>
|
|
118
|
+
[customFetch]: async (url, init) => {
|
|
119
|
+
try {
|
|
120
|
+
return await fetchImpl(url, init);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new KeyFetchError();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
98
126
|
}))
|
|
99
127
|
.catch((error) => {
|
|
100
128
|
jwks = null;
|
|
@@ -125,8 +153,13 @@ export function createPersonTokenCore(config) {
|
|
|
125
153
|
const code = error?.code;
|
|
126
154
|
if (code === 'ERR_JWT_EXPIRED')
|
|
127
155
|
return { ok: false, reason: 'expired' };
|
|
128
|
-
// A key fetch that failed
|
|
129
|
-
|
|
156
|
+
// A key fetch that failed, timed out or got a non-200/unreadable answer
|
|
157
|
+
// is the issuer being down, not the token being bad. jose reports the
|
|
158
|
+
// last two as a bare JOSEError; anything else without a code is a defect
|
|
159
|
+
// in the token or the call, so it is a refusal.
|
|
160
|
+
if (error instanceof KeyFetchError ||
|
|
161
|
+
code === 'ERR_JWKS_TIMEOUT' ||
|
|
162
|
+
code === 'ERR_JOSE_GENERIC') {
|
|
130
163
|
return { ok: false, reason: 'unavailable' };
|
|
131
164
|
}
|
|
132
165
|
return { ok: false, reason: 'invalid' };
|
|
@@ -137,30 +170,30 @@ export function createPersonTokenCore(config) {
|
|
|
137
170
|
return outcome.ok ? outcome.payload : null;
|
|
138
171
|
}
|
|
139
172
|
/**
|
|
140
|
-
* RFC 7662 token introspection
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* body -- every one of those returns `null`, refused, never accepted.
|
|
173
|
+
* RFC 7662 token introspection: POSTs `token=<accessToken>` to the
|
|
174
|
+
* issuer's `introspection_endpoint`, authenticating as this resource
|
|
175
|
+
* server with `client_secret_basic`. Fails closed on anything that keeps
|
|
176
|
+
* this from reaching a real `active`/`exp`/`aud`/`iss` verdict. A network
|
|
177
|
+
* failure, a non-2xx or an unreadable body is `unavailable`; the issuer's
|
|
178
|
+
* own answer is `revoked` (inactive), `expired` or `invalid`.
|
|
147
179
|
*/
|
|
148
|
-
async function
|
|
149
|
-
if (!config.introspectionClientId || !config.introspectionClientSecret)
|
|
150
|
-
return
|
|
180
|
+
async function introspectOutcome(accessToken) {
|
|
181
|
+
if (!config.introspectionClientId || !config.introspectionClientSecret) {
|
|
182
|
+
return { ok: false, reason: 'invalid' };
|
|
183
|
+
}
|
|
151
184
|
const now = Date.now();
|
|
152
185
|
const cached = introspectionCache.get(accessToken, now);
|
|
153
186
|
if (cached)
|
|
154
|
-
return cached;
|
|
187
|
+
return { ok: true, payload: cached };
|
|
155
188
|
let endpoint;
|
|
156
189
|
try {
|
|
157
190
|
({ introspectionEndpoint: endpoint } = await document());
|
|
158
191
|
}
|
|
159
192
|
catch {
|
|
160
|
-
return
|
|
193
|
+
return { ok: false, reason: 'unavailable' };
|
|
161
194
|
}
|
|
162
195
|
if (!endpoint)
|
|
163
|
-
return
|
|
196
|
+
return { ok: false, reason: 'invalid' };
|
|
164
197
|
const credentials = Buffer.from(`${config.introspectionClientId}:${config.introspectionClientSecret}`).toString('base64');
|
|
165
198
|
let response;
|
|
166
199
|
try {
|
|
@@ -175,30 +208,48 @@ export function createPersonTokenCore(config) {
|
|
|
175
208
|
});
|
|
176
209
|
}
|
|
177
210
|
catch {
|
|
178
|
-
return
|
|
211
|
+
return { ok: false, reason: 'unavailable' };
|
|
179
212
|
}
|
|
180
213
|
if (!response.ok)
|
|
181
|
-
return
|
|
214
|
+
return { ok: false, reason: 'unavailable' };
|
|
182
215
|
let body;
|
|
183
216
|
try {
|
|
184
|
-
|
|
217
|
+
const parsed = await response.json();
|
|
218
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
219
|
+
return { ok: false, reason: 'unavailable' };
|
|
220
|
+
}
|
|
221
|
+
body = parsed;
|
|
185
222
|
}
|
|
186
223
|
catch {
|
|
187
|
-
return
|
|
224
|
+
return { ok: false, reason: 'unavailable' };
|
|
188
225
|
}
|
|
189
226
|
if (body.active !== true)
|
|
190
|
-
return
|
|
191
|
-
if (typeof body.exp !== 'number'
|
|
192
|
-
return
|
|
227
|
+
return { ok: false, reason: 'revoked' };
|
|
228
|
+
if (typeof body.exp !== 'number')
|
|
229
|
+
return { ok: false, reason: 'invalid' };
|
|
230
|
+
if (body.exp <= now / 1000)
|
|
231
|
+
return { ok: false, reason: 'expired' };
|
|
193
232
|
const aud = body.aud;
|
|
194
233
|
const audiences = Array.isArray(aud) ? aud : aud === undefined ? [] : [aud];
|
|
195
234
|
if (!audiences.includes(config.audience))
|
|
196
|
-
return
|
|
197
|
-
if (
|
|
198
|
-
return
|
|
235
|
+
return { ok: false, reason: 'invalid' };
|
|
236
|
+
if (body.iss !== config.issuer)
|
|
237
|
+
return { ok: false, reason: 'invalid' };
|
|
199
238
|
const payload = body;
|
|
200
239
|
introspectionCache.set(accessToken, payload, Math.min(body.exp * 1000, now + INTROSPECTION_CACHE_TTL_MS));
|
|
201
|
-
return payload;
|
|
240
|
+
return { ok: true, payload };
|
|
241
|
+
}
|
|
242
|
+
async function introspect(accessToken) {
|
|
243
|
+
const outcome = await introspectOutcome(accessToken);
|
|
244
|
+
return outcome.ok ? outcome.payload : null;
|
|
245
|
+
}
|
|
246
|
+
function identityOf(payload) {
|
|
247
|
+
if (typeof payload.sub !== 'string' || !payload.sub)
|
|
248
|
+
return null;
|
|
249
|
+
const organizationId = payload[ORG_CLAIM];
|
|
250
|
+
return typeof organizationId === 'string' && organizationId
|
|
251
|
+
? { sub: payload.sub, organizationId }
|
|
252
|
+
: { sub: payload.sub };
|
|
202
253
|
}
|
|
203
254
|
return {
|
|
204
255
|
async verifyJwtClaims(accessToken) {
|
|
@@ -213,13 +264,34 @@ export function createPersonTokenCore(config) {
|
|
|
213
264
|
const payload = JWT_SHAPE.test(accessToken)
|
|
214
265
|
? await verifyJwt(accessToken)
|
|
215
266
|
: await introspect(accessToken);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
267
|
+
return payload ? identityOf(payload) : null;
|
|
268
|
+
},
|
|
269
|
+
async verifyOutcome(accessToken) {
|
|
270
|
+
let payload;
|
|
271
|
+
if (JWT_SHAPE.test(accessToken)) {
|
|
272
|
+
const jwt = await verifyJwtOutcome(accessToken);
|
|
273
|
+
if (!jwt.ok)
|
|
274
|
+
return jwt;
|
|
275
|
+
const live = await introspectOutcome(accessToken);
|
|
276
|
+
if (!live.ok)
|
|
277
|
+
return live;
|
|
278
|
+
if (typeof live.payload.sub === 'string' && live.payload.sub !== jwt.payload.sub) {
|
|
279
|
+
return { ok: false, reason: 'invalid' };
|
|
280
|
+
}
|
|
281
|
+
payload = jwt.payload;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const live = await introspectOutcome(accessToken);
|
|
285
|
+
if (!live.ok)
|
|
286
|
+
return live;
|
|
287
|
+
payload = live.payload;
|
|
288
|
+
}
|
|
289
|
+
const identity = identityOf(payload);
|
|
290
|
+
return identity ? { ok: true, identity } : { ok: false, reason: 'invalid' };
|
|
219
291
|
},
|
|
220
292
|
};
|
|
221
293
|
}
|
|
222
294
|
export function createPersonTokenVerifier(config) {
|
|
223
|
-
const { verify } = createPersonTokenCore(config);
|
|
224
|
-
return { verify };
|
|
295
|
+
const { verify, verifyOutcome } = createPersonTokenCore(config);
|
|
296
|
+
return { verify, verifyOutcome };
|
|
225
297
|
}
|