@wtfalch/auth 0.5.0 → 0.7.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/dist/auth.d.ts +19 -1
- package/dist/auth.js +107 -12
- package/dist/broker.d.ts +12 -0
- package/dist/broker.js +11 -1
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +4 -38
- package/dist/config.d.ts +12 -1
- package/dist/config.js +16 -0
- package/dist/cookies.d.ts +16 -0
- package/dist/cookies.js +51 -3
- package/dist/index.d.ts +1 -0
- package/dist/namespace-browser.d.ts +4 -0
- package/dist/namespace-browser.js +232 -0
- package/dist/namespace-server.d.ts +12 -0
- package/dist/namespace-server.js +68 -0
- package/dist/namespace-session.d.ts +42 -0
- package/dist/namespace-session.js +278 -0
- package/dist/namespaces.d.ts +153 -0
- package/dist/namespaces.js +280 -0
- package/dist/next.js +14 -2
- package/dist/oidc.d.ts +1 -1
- package/dist/sessions.d.ts +69 -0
- package/dist/sessions.js +119 -0
- package/package.json +15 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
import { resolveNamespaceContext } from './namespaces.js';
|
|
3
|
+
import { safeNextPath } from './redirect.js';
|
|
4
|
+
const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
|
|
5
|
+
const ttl = 10 * 60_000;
|
|
6
|
+
const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
7
|
+
function base64url(bytes) {
|
|
8
|
+
return btoa(String.fromCharCode(...bytes))
|
|
9
|
+
.replaceAll('+', '-')
|
|
10
|
+
.replaceAll('/', '_')
|
|
11
|
+
.replace(/=+$/, '');
|
|
12
|
+
}
|
|
13
|
+
function read(storage, key) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(storage.getItem(key) ?? 'null');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Isolated implementation: legacy storage and callback behavior stay unchanged. */
|
|
22
|
+
export function createNamespaceBrowserAuth(options, selection) {
|
|
23
|
+
const origin = options.origin ?? window.location.origin;
|
|
24
|
+
if (origin !== window.location.origin)
|
|
25
|
+
throw new Error('Namespace client must run on its registered origin');
|
|
26
|
+
const redirectUri = `${origin}${options.callbackPath ?? '/auth/callback'}`;
|
|
27
|
+
const binding = resolveNamespaceContext(selection, {
|
|
28
|
+
issuer: options.issuer,
|
|
29
|
+
clientId: options.clientId,
|
|
30
|
+
appOrigin: origin,
|
|
31
|
+
redirectUri,
|
|
32
|
+
});
|
|
33
|
+
const scope = options.scope ?? 'openid email profile offline_access';
|
|
34
|
+
const scopes = new Set(scope.split(/\s+/).filter(Boolean));
|
|
35
|
+
if (!scopes.has('openid') ||
|
|
36
|
+
[...scopes].some((s) => s.startsWith('urn:zitadel:iam:org:') &&
|
|
37
|
+
s !== `urn:zitadel:iam:org:id:${binding.organizationId}`))
|
|
38
|
+
throw new Error('Namespace client has conflicting organization scopes');
|
|
39
|
+
scopes.add(`urn:zitadel:iam:org:id:${binding.organizationId}`);
|
|
40
|
+
scopes.add('urn:zitadel:iam:user:resourceowner');
|
|
41
|
+
const prefix = `wtfalch.auth.namespace.v1:${binding.context}`;
|
|
42
|
+
const tokenKey = `${prefix}:token`;
|
|
43
|
+
const txKey = `${prefix}:transaction`;
|
|
44
|
+
const triedKey = `${prefix}:tried`;
|
|
45
|
+
// Shared across tabs and namespace clients in this service, never a token.
|
|
46
|
+
const serviceKey = `wtfalch.auth.active.v1:${JSON.stringify([origin, binding.serviceId, binding.deploymentId])}`;
|
|
47
|
+
const pendingKey = `${serviceKey}:pending`;
|
|
48
|
+
const pending = () => read(localStorage, pendingKey);
|
|
49
|
+
const ownsPending = (state) => {
|
|
50
|
+
const current = pending();
|
|
51
|
+
return current?.context === binding.context && current.state === state;
|
|
52
|
+
};
|
|
53
|
+
let keys;
|
|
54
|
+
const active = () => read(localStorage, serviceKey);
|
|
55
|
+
const stored = () => read(sessionStorage, tokenKey);
|
|
56
|
+
const accountChanged = () => {
|
|
57
|
+
const current = active();
|
|
58
|
+
const session = stored();
|
|
59
|
+
return (!!current &&
|
|
60
|
+
(current.context !== binding.context ||
|
|
61
|
+
(!!session && current.generation !== session.generation)));
|
|
62
|
+
};
|
|
63
|
+
const currentSession = () => {
|
|
64
|
+
const session = stored();
|
|
65
|
+
const current = active();
|
|
66
|
+
return session &&
|
|
67
|
+
current?.context === binding.context &&
|
|
68
|
+
current.generation === session.generation &&
|
|
69
|
+
typeof session.accessToken === 'string' &&
|
|
70
|
+
Number.isFinite(session.expiresAt) &&
|
|
71
|
+
session.expiresAt - 60_000 > Date.now()
|
|
72
|
+
? session
|
|
73
|
+
: null;
|
|
74
|
+
};
|
|
75
|
+
const silentSignInAvailable = () => !accountChanged() && !currentSession() && !sessionStorage.getItem(triedKey);
|
|
76
|
+
const authorize = async (prompt, returnTo) => {
|
|
77
|
+
const transaction = {
|
|
78
|
+
context: binding.context,
|
|
79
|
+
state: random(),
|
|
80
|
+
nonce: random(),
|
|
81
|
+
verifier: random(),
|
|
82
|
+
returnTo: safeNextPath(returnTo, '/'),
|
|
83
|
+
started: Date.now(),
|
|
84
|
+
};
|
|
85
|
+
// Storage is required in namespace mode. If unavailable, fail before leaving.
|
|
86
|
+
sessionStorage.setItem(txKey, JSON.stringify(transaction));
|
|
87
|
+
localStorage.setItem(pendingKey, JSON.stringify({ context: binding.context, state: transaction.state }));
|
|
88
|
+
const url = new URL(`${binding.issuer}/oauth/v2/authorize`);
|
|
89
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(transaction.verifier));
|
|
90
|
+
url.search = new URLSearchParams({
|
|
91
|
+
client_id: binding.clientId,
|
|
92
|
+
redirect_uri: redirectUri,
|
|
93
|
+
response_type: 'code',
|
|
94
|
+
scope: [...scopes].join(' '),
|
|
95
|
+
state: transaction.state,
|
|
96
|
+
nonce: transaction.nonce,
|
|
97
|
+
code_challenge: base64url(new Uint8Array(digest)),
|
|
98
|
+
code_challenge_method: 'S256',
|
|
99
|
+
...(prompt ? { prompt } : {}),
|
|
100
|
+
}).toString();
|
|
101
|
+
if (!ownsPending(transaction.state))
|
|
102
|
+
throw new Error('Another sign-in replaced this attempt');
|
|
103
|
+
window.location.assign(url.href);
|
|
104
|
+
};
|
|
105
|
+
const completeSignIn = async (search = window.location.search) => {
|
|
106
|
+
const reject = () => ({
|
|
107
|
+
kind: 'error',
|
|
108
|
+
error: 'This sign-in no longer matches the active namespace; start again',
|
|
109
|
+
});
|
|
110
|
+
const params = new URLSearchParams(search);
|
|
111
|
+
const tx = read(sessionStorage, txKey);
|
|
112
|
+
if (!tx ||
|
|
113
|
+
tx.context !== binding.context ||
|
|
114
|
+
typeof tx.state !== 'string' ||
|
|
115
|
+
typeof tx.nonce !== 'string' ||
|
|
116
|
+
typeof tx.verifier !== 'string' ||
|
|
117
|
+
typeof tx.returnTo !== 'string' ||
|
|
118
|
+
!Number.isFinite(tx.started) ||
|
|
119
|
+
tx.started > Date.now() ||
|
|
120
|
+
Date.now() - tx.started > ttl ||
|
|
121
|
+
params.getAll('state').length !== 1 ||
|
|
122
|
+
params.get('state') !== tx.state ||
|
|
123
|
+
!ownsPending(tx.state) ||
|
|
124
|
+
(params.has('iss') && params.get('iss') !== binding.issuer))
|
|
125
|
+
return reject();
|
|
126
|
+
sessionStorage.removeItem(txKey);
|
|
127
|
+
const error = params.get('error');
|
|
128
|
+
if (error) {
|
|
129
|
+
localStorage.removeItem(pendingKey);
|
|
130
|
+
return ['login_required', 'interaction_required', 'consent_required'].includes(error)
|
|
131
|
+
? { kind: 'silent-refused' }
|
|
132
|
+
: { kind: 'error', error: 'The issuer refused this sign-in' };
|
|
133
|
+
}
|
|
134
|
+
if (params.getAll('code').length !== 1 || !params.get('code'))
|
|
135
|
+
return reject();
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetch(`${binding.issuer}/oauth/v2/token`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
140
|
+
body: new URLSearchParams({
|
|
141
|
+
grant_type: 'authorization_code',
|
|
142
|
+
client_id: binding.clientId,
|
|
143
|
+
redirect_uri: redirectUri,
|
|
144
|
+
code: params.get('code'),
|
|
145
|
+
code_verifier: tx.verifier,
|
|
146
|
+
}),
|
|
147
|
+
signal: AbortSignal.timeout(15_000),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok)
|
|
150
|
+
return reject();
|
|
151
|
+
const token = await response.json();
|
|
152
|
+
if (typeof token.access_token !== 'string' ||
|
|
153
|
+
!token.access_token ||
|
|
154
|
+
typeof token.id_token !== 'string' ||
|
|
155
|
+
!Number.isFinite(token.expires_in) ||
|
|
156
|
+
token.expires_in <= 0)
|
|
157
|
+
return reject();
|
|
158
|
+
if (!keys) {
|
|
159
|
+
const discoveryResponse = await fetch(`${binding.issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(15_000) });
|
|
160
|
+
if (!discoveryResponse.ok)
|
|
161
|
+
return reject();
|
|
162
|
+
const discovery = await discoveryResponse.json();
|
|
163
|
+
if (discovery.issuer !== binding.issuer || typeof discovery.jwks_uri !== 'string')
|
|
164
|
+
return reject();
|
|
165
|
+
const jwks = new URL(discovery.jwks_uri);
|
|
166
|
+
if (jwks.origin !== binding.issuer || jwks.username || jwks.password || jwks.hash)
|
|
167
|
+
return reject();
|
|
168
|
+
keys = createRemoteJWKSet(jwks);
|
|
169
|
+
}
|
|
170
|
+
const { payload } = await jwtVerify(token.id_token, keys, {
|
|
171
|
+
issuer: binding.issuer,
|
|
172
|
+
audience: binding.clientId,
|
|
173
|
+
algorithms: ['RS256'],
|
|
174
|
+
requiredClaims: ['exp', 'iat', 'sub'],
|
|
175
|
+
clockTolerance: 60,
|
|
176
|
+
});
|
|
177
|
+
if (payload.nonce !== tx.nonce ||
|
|
178
|
+
payload[OWNER] !== binding.organizationId ||
|
|
179
|
+
(payload.azp !== undefined && payload.azp !== binding.clientId) ||
|
|
180
|
+
(Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp !== binding.clientId))
|
|
181
|
+
return reject();
|
|
182
|
+
// A different tab may have switched or signed out during the network calls.
|
|
183
|
+
if (!ownsPending(tx.state))
|
|
184
|
+
return reject();
|
|
185
|
+
const generation = random();
|
|
186
|
+
const session = {
|
|
187
|
+
accessToken: token.access_token,
|
|
188
|
+
expiresAt: Date.now() + token.expires_in * 1000,
|
|
189
|
+
namespaceId: binding.namespaceId,
|
|
190
|
+
bindingId: binding.id,
|
|
191
|
+
generation,
|
|
192
|
+
};
|
|
193
|
+
localStorage.setItem(serviceKey, JSON.stringify({ context: binding.context, generation }));
|
|
194
|
+
localStorage.removeItem(pendingKey);
|
|
195
|
+
sessionStorage.setItem(tokenKey, JSON.stringify(session));
|
|
196
|
+
sessionStorage.removeItem(triedKey);
|
|
197
|
+
return { kind: 'signed-in', session, returnTo: safeNextPath(tx.returnTo, '/') };
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return reject();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
accountChanged,
|
|
205
|
+
currentSession,
|
|
206
|
+
silentSignInAvailable,
|
|
207
|
+
completeSignIn,
|
|
208
|
+
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
209
|
+
if (!silentSignInAvailable())
|
|
210
|
+
return false;
|
|
211
|
+
sessionStorage.setItem(triedKey, '1');
|
|
212
|
+
await authorize('none', returnTo);
|
|
213
|
+
return true;
|
|
214
|
+
},
|
|
215
|
+
async signIn(returnTo = window.location.pathname) {
|
|
216
|
+
sessionStorage.removeItem(triedKey);
|
|
217
|
+
await authorize(undefined, returnTo);
|
|
218
|
+
},
|
|
219
|
+
forget() {
|
|
220
|
+
sessionStorage.removeItem(tokenKey);
|
|
221
|
+
sessionStorage.removeItem(txKey);
|
|
222
|
+
sessionStorage.setItem(triedKey, '1');
|
|
223
|
+
// Cancel this namespace's pending exchange even when another account is active.
|
|
224
|
+
if (pending()?.context === binding.context)
|
|
225
|
+
localStorage.removeItem(pendingKey);
|
|
226
|
+
// A stale X tab must not sign Y out.
|
|
227
|
+
if (!active() || active()?.context === binding.context) {
|
|
228
|
+
localStorage.setItem(serviceKey, JSON.stringify({ context: binding.context, generation: random() }));
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type NamespaceRegistry, type NamespaceSelection } from './namespaces.js';
|
|
2
|
+
/** Resolve a shared callback from its authenticated transaction, never a URL
|
|
3
|
+
* namespace hint. The second open checks the entire current registry binding. */
|
|
4
|
+
export declare function resolveNamespaceCallback(input: {
|
|
5
|
+
registry: NamespaceRegistry;
|
|
6
|
+
serviceId: string;
|
|
7
|
+
deploymentId: string;
|
|
8
|
+
appOrigin: string;
|
|
9
|
+
cookieSecret: string;
|
|
10
|
+
cookieHeader: string | null;
|
|
11
|
+
basePath?: string;
|
|
12
|
+
}): Promise<NamespaceSelection | null>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jwtDecrypt } from 'jose';
|
|
2
|
+
import { resolveOptions } from './config.js';
|
|
3
|
+
import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
|
|
4
|
+
import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
|
|
5
|
+
/** Resolve a shared callback from its authenticated transaction, never a URL
|
|
6
|
+
* namespace hint. The second open checks the entire current registry binding. */
|
|
7
|
+
export async function resolveNamespaceCallback(input) {
|
|
8
|
+
try {
|
|
9
|
+
const registry = parseNamespaceRegistry(input.registry);
|
|
10
|
+
const callback = new URL(`${input.basePath ?? '/auth'}/callback`, input.appOrigin).href;
|
|
11
|
+
const candidates = registry.namespaces.filter((n) => n.status === 'active' &&
|
|
12
|
+
n.services
|
|
13
|
+
.filter(isWebService)
|
|
14
|
+
.some((s) => s.serviceId === input.serviceId &&
|
|
15
|
+
s.deploymentId === input.deploymentId &&
|
|
16
|
+
s.appOrigin === input.appOrigin &&
|
|
17
|
+
s.redirectUris.includes(callback)));
|
|
18
|
+
const optionsFor = (namespace) => {
|
|
19
|
+
const service = namespace.services
|
|
20
|
+
.filter(isWebService)
|
|
21
|
+
.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
|
|
22
|
+
if (!service)
|
|
23
|
+
throw new Error('Missing service');
|
|
24
|
+
const selection = {
|
|
25
|
+
registry,
|
|
26
|
+
namespaceId: namespace.id,
|
|
27
|
+
serviceId: input.serviceId,
|
|
28
|
+
deploymentId: input.deploymentId,
|
|
29
|
+
};
|
|
30
|
+
return resolveOptions({
|
|
31
|
+
namespace: selection,
|
|
32
|
+
issuer: registry.issuer,
|
|
33
|
+
appUrl: input.appOrigin,
|
|
34
|
+
clientId: service.clientId,
|
|
35
|
+
organizationId: namespace.organizationId,
|
|
36
|
+
cookieSecret: input.cookieSecret,
|
|
37
|
+
basePath: input.basePath,
|
|
38
|
+
afterLogout: service.postLogoutRedirectUris[0],
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
if (!candidates.length)
|
|
42
|
+
return null;
|
|
43
|
+
const first = optionsFor(candidates[0]);
|
|
44
|
+
const value = cookieFrom(input.cookieHeader, transactionCookieName(first));
|
|
45
|
+
if (!value)
|
|
46
|
+
return null;
|
|
47
|
+
// No identity is returned based on this hint alone. It only chooses which
|
|
48
|
+
// current binding's audience to validate next, so decryption is bounded.
|
|
49
|
+
const { payload } = await jwtDecrypt(value, first.cookieKey, {
|
|
50
|
+
issuer: input.appOrigin,
|
|
51
|
+
requiredClaims: ['exp', 'iat', 'aud'],
|
|
52
|
+
contentEncryptionAlgorithms: ['A256GCM'],
|
|
53
|
+
keyManagementAlgorithms: ['dir'],
|
|
54
|
+
});
|
|
55
|
+
const namespace = candidates.find((n) => n.id === payload.ns);
|
|
56
|
+
if (!namespace || !(await openTransaction(optionsFor(namespace), value)))
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
registry,
|
|
60
|
+
namespaceId: namespace.id,
|
|
61
|
+
serviceId: input.serviceId,
|
|
62
|
+
deploymentId: input.deploymentId,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { JWTPayload } from 'jose';
|
|
2
|
+
import { type Broker } from './broker.js';
|
|
3
|
+
import type { ResolvedOptions } from './config.js';
|
|
4
|
+
import { type SetCookie } from './cookies.js';
|
|
5
|
+
import { type Oidc, type Tokens } from './oidc.js';
|
|
6
|
+
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
7
|
+
export declare const STATUS_FRESH_MS = 60000;
|
|
8
|
+
/** How long the last good answer carries on while the service cannot be asked. */
|
|
9
|
+
export declare const STATUS_STALE_IF_ERROR_MS: number;
|
|
10
|
+
export type SessionRead = {
|
|
11
|
+
kind: 'none';
|
|
12
|
+
}
|
|
13
|
+
/** A cookie for another binding, revision or format. */
|
|
14
|
+
| {
|
|
15
|
+
kind: 'changed';
|
|
16
|
+
}
|
|
17
|
+
/** The binding is suspended, or its status cannot be confirmed. */
|
|
18
|
+
| {
|
|
19
|
+
kind: 'unavailable';
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'user';
|
|
22
|
+
claims: JWTPayload;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Server-side sessions for namespace mode. The cookie holds a random handle;
|
|
26
|
+
* the record holds the tokens. Refreshes are serialised across replicas by a
|
|
27
|
+
* lease on the record, so the issuer's refresh-token rotation cannot log a
|
|
28
|
+
* person out when two requests arrive together, and no refresh ever sets a
|
|
29
|
+
* cookie.
|
|
30
|
+
*/
|
|
31
|
+
export declare function namespaceSessions(deps: {
|
|
32
|
+
options: () => ResolvedOptions;
|
|
33
|
+
oidc: () => Oidc;
|
|
34
|
+
broker: () => Broker;
|
|
35
|
+
}): {
|
|
36
|
+
bindingActive: () => Promise<boolean>;
|
|
37
|
+
create: (tokens: Tokens) => Promise<SetCookie>;
|
|
38
|
+
end: (value: string | undefined) => Promise<"ended" | "changed" | "none">;
|
|
39
|
+
read: (value: string | undefined, force?: boolean) => Promise<SessionRead>;
|
|
40
|
+
revokeSubject: (subject: string) => Promise<number>;
|
|
41
|
+
keyOf: (value: string | undefined) => Promise<string | null>;
|
|
42
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import * as client from 'openid-client';
|
|
2
|
+
import { BrokerError } from './broker.js';
|
|
3
|
+
import { contextDigest, digest, openHandle, openRecord, sealHandle, sealRecord, } from './cookies.js';
|
|
4
|
+
import { AuthError } from './oidc.js';
|
|
5
|
+
/** How long a binding status answer is trusted. The propagation bound for a suspension. */
|
|
6
|
+
export const STATUS_FRESH_MS = 60_000;
|
|
7
|
+
/** How long the last good answer carries on while the service cannot be asked. */
|
|
8
|
+
export const STATUS_STALE_IF_ERROR_MS = 5 * 60_000;
|
|
9
|
+
const STATUS_RETRY_MS = 5_000;
|
|
10
|
+
/**
|
|
11
|
+
* Twice openid-client's 30 second request timeout, so a slow but live refresh
|
|
12
|
+
* is never mistaken for a dead one and taken over mid-flight.
|
|
13
|
+
*/
|
|
14
|
+
const LEASE_SECONDS = 60;
|
|
15
|
+
const WAIT_STEP_MS = 100;
|
|
16
|
+
const WAIT_STEPS = 50;
|
|
17
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
18
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
/**
|
|
20
|
+
* Server-side sessions for namespace mode. The cookie holds a random handle;
|
|
21
|
+
* the record holds the tokens. Refreshes are serialised across replicas by a
|
|
22
|
+
* lease on the record, so the issuer's refresh-token rotation cannot log a
|
|
23
|
+
* person out when two requests arrive together, and no refresh ever sets a
|
|
24
|
+
* cookie.
|
|
25
|
+
*/
|
|
26
|
+
export function namespaceSessions(deps) {
|
|
27
|
+
const options = deps.options;
|
|
28
|
+
const store = () => {
|
|
29
|
+
const s = options().sessionStore;
|
|
30
|
+
if (!s)
|
|
31
|
+
throw new Error('@wtfalch/auth: namespace mode needs a sessionStore');
|
|
32
|
+
return s;
|
|
33
|
+
};
|
|
34
|
+
// ---- binding status -----------------------------------------------------
|
|
35
|
+
let status = null;
|
|
36
|
+
let retryAt = 0;
|
|
37
|
+
let pending = null;
|
|
38
|
+
// People whose sessions this instance has already ended for the current
|
|
39
|
+
// deactivation. Someone who leaves the list, by reactivation or by ageing
|
|
40
|
+
// out of the service's window, is forgotten, so a second deactivation
|
|
41
|
+
// ends their sessions again.
|
|
42
|
+
const ended = new Set();
|
|
43
|
+
const endDeactivated = async (revoked) => {
|
|
44
|
+
if (!Array.isArray(revoked))
|
|
45
|
+
return;
|
|
46
|
+
const current = new Set(revoked.filter((s) => typeof s === 'string' && !!s));
|
|
47
|
+
for (const subject of ended)
|
|
48
|
+
if (!current.has(subject))
|
|
49
|
+
ended.delete(subject);
|
|
50
|
+
for (const subject of current) {
|
|
51
|
+
if (ended.has(subject))
|
|
52
|
+
continue;
|
|
53
|
+
await revokeSubject(subject);
|
|
54
|
+
ended.add(subject);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const askService = async () => {
|
|
58
|
+
const namespace = options().namespace;
|
|
59
|
+
if (!namespace)
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
const routed = await deps.broker().binding();
|
|
63
|
+
const active = routed.organizationId === namespace.organizationId &&
|
|
64
|
+
routed.clientIds.includes(namespace.clientId) &&
|
|
65
|
+
routed.origins.includes(namespace.appOrigin);
|
|
66
|
+
// Deactivated anywhere, by any service or in the issuer's console: their
|
|
67
|
+
// sessions here end before this answer is used, so within the same bound
|
|
68
|
+
// as a suspension. Only a list about this binding's own organization counts.
|
|
69
|
+
if (routed.organizationId === namespace.organizationId)
|
|
70
|
+
await endDeactivated(routed.revoked);
|
|
71
|
+
return active;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
// Suspension removes the binding's key, so the service no longer knows it.
|
|
75
|
+
if (error instanceof BrokerError && (error.status === 401 || error.status === 403))
|
|
76
|
+
return false;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Whether the service still routes this binding. An answer is trusted for a
|
|
82
|
+
* minute from when it was asked, so a suspension reaches every request
|
|
83
|
+
* within that. While the service cannot be asked the last good answer
|
|
84
|
+
* carries on for five minutes, then this fails closed.
|
|
85
|
+
*/
|
|
86
|
+
const bindingActive = async () => {
|
|
87
|
+
const asked = Date.now();
|
|
88
|
+
if (status && asked - status.at < STATUS_FRESH_MS)
|
|
89
|
+
return status.active;
|
|
90
|
+
if (asked >= retryAt) {
|
|
91
|
+
pending ??= askService().finally(() => {
|
|
92
|
+
pending = null;
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const active = await pending;
|
|
96
|
+
if (!status || status.at < asked)
|
|
97
|
+
status = { active, at: asked };
|
|
98
|
+
return active;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
retryAt = Date.now() + STATUS_RETRY_MS;
|
|
102
|
+
console.error('@wtfalch/auth: could not confirm the namespace binding', error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return status?.active === true && Date.now() - status.at < STATUS_STALE_IF_ERROR_MS;
|
|
106
|
+
};
|
|
107
|
+
// ---- records ------------------------------------------------------------
|
|
108
|
+
const load = async (key) => {
|
|
109
|
+
const record = await store().get(key);
|
|
110
|
+
if (!record ||
|
|
111
|
+
record.revokedAt !== null ||
|
|
112
|
+
record.expiresAt <= now() ||
|
|
113
|
+
record.context !== (await contextDigest(options())))
|
|
114
|
+
return null;
|
|
115
|
+
const tokens = await openRecord(options(), key, record.payload);
|
|
116
|
+
return tokens ? { key, record, tokens } : null;
|
|
117
|
+
};
|
|
118
|
+
const keyOf = async (value) => {
|
|
119
|
+
const sid = await openHandle(options(), value);
|
|
120
|
+
return sid ? digest(sid) : null;
|
|
121
|
+
};
|
|
122
|
+
const seal = (key, tokens) => sealRecord(options(), { k: key, idt: tokens.idToken, rt: tokens.refreshToken });
|
|
123
|
+
const revokeToken = (refreshToken) => {
|
|
124
|
+
if (refreshToken)
|
|
125
|
+
deps
|
|
126
|
+
.broker()
|
|
127
|
+
.revoke(refreshToken)
|
|
128
|
+
.catch(() => { });
|
|
129
|
+
};
|
|
130
|
+
/** A new record and the cookie that points at it. */
|
|
131
|
+
const create = async (tokens) => {
|
|
132
|
+
const sid = client.randomState();
|
|
133
|
+
const key = await digest(sid);
|
|
134
|
+
await store().create({
|
|
135
|
+
key,
|
|
136
|
+
context: await contextDigest(options()),
|
|
137
|
+
subject: String(tokens.claims.sub),
|
|
138
|
+
generation: 0,
|
|
139
|
+
leaseUntil: null,
|
|
140
|
+
payload: await seal(key, tokens),
|
|
141
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
142
|
+
revokedAt: null,
|
|
143
|
+
});
|
|
144
|
+
return sealHandle(options(), sid);
|
|
145
|
+
};
|
|
146
|
+
/** Ends the record behind this cookie, if it is one of this binding's. */
|
|
147
|
+
const end = async (value) => {
|
|
148
|
+
if (!value)
|
|
149
|
+
return 'none';
|
|
150
|
+
const key = await keyOf(value);
|
|
151
|
+
if (!key)
|
|
152
|
+
return 'changed';
|
|
153
|
+
const loaded = await load(key);
|
|
154
|
+
await store().revoke(key, now());
|
|
155
|
+
revokeToken(loaded?.tokens.rt);
|
|
156
|
+
return 'ended';
|
|
157
|
+
};
|
|
158
|
+
const revokeSubject = async (subject) => store().revokeSubject(await contextDigest(options()), subject, now());
|
|
159
|
+
// ---- refresh ------------------------------------------------------------
|
|
160
|
+
const definitive = (error) => error instanceof AuthError ||
|
|
161
|
+
(error instanceof client.ResponseBodyError && error.error === 'invalid_grant');
|
|
162
|
+
/**
|
|
163
|
+
* Refresh under the lease. Resolves the freshest usable tokens: new ones,
|
|
164
|
+
* another request's, or null when the session is over. While another
|
|
165
|
+
* request holds the lease, a current token with time left is used as it is;
|
|
166
|
+
* without one, this waits a few seconds for that refresh to land, and takes
|
|
167
|
+
* the lease over if its holder has gone quiet past its expiry.
|
|
168
|
+
*/
|
|
169
|
+
const refresh = async (loaded, stillValid) => {
|
|
170
|
+
const key = loaded.key;
|
|
171
|
+
let current = loaded;
|
|
172
|
+
for (let attempt = 0;; attempt++) {
|
|
173
|
+
const { record } = current;
|
|
174
|
+
if (attempt > 0 &&
|
|
175
|
+
record.generation !== loaded.record.generation &&
|
|
176
|
+
record.leaseUntil === null)
|
|
177
|
+
return current;
|
|
178
|
+
const leased = record.leaseUntil !== null && record.leaseUntil > now();
|
|
179
|
+
if (!leased &&
|
|
180
|
+
(await store().advance(key, record.generation, {
|
|
181
|
+
payload: record.payload,
|
|
182
|
+
expiresAt: record.expiresAt,
|
|
183
|
+
leaseUntil: now() + LEASE_SECONDS,
|
|
184
|
+
})))
|
|
185
|
+
return refreshLeased(current, stillValid);
|
|
186
|
+
if (stillValid)
|
|
187
|
+
return current;
|
|
188
|
+
if (attempt >= WAIT_STEPS)
|
|
189
|
+
return null;
|
|
190
|
+
await sleep(WAIT_STEP_MS);
|
|
191
|
+
const latest = await load(key);
|
|
192
|
+
if (!latest)
|
|
193
|
+
return null;
|
|
194
|
+
current = latest;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const refreshLeased = async (leased, stillValid) => {
|
|
198
|
+
const { key, record, tokens } = leased;
|
|
199
|
+
const generation = record.generation + 1;
|
|
200
|
+
let fresh;
|
|
201
|
+
try {
|
|
202
|
+
fresh = await deps.oidc().refresh(tokens.rt);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (definitive(error)) {
|
|
206
|
+
// The lease means nobody else spent the token: the issuer has ended it.
|
|
207
|
+
await store().revoke(key, now());
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
await store()
|
|
211
|
+
.advance(key, generation, {
|
|
212
|
+
payload: record.payload,
|
|
213
|
+
expiresAt: record.expiresAt,
|
|
214
|
+
leaseUntil: null,
|
|
215
|
+
})
|
|
216
|
+
.catch(() => { });
|
|
217
|
+
return stillValid ? leased : null;
|
|
218
|
+
}
|
|
219
|
+
const committed = await store().advance(key, generation, {
|
|
220
|
+
payload: await seal(key, fresh),
|
|
221
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
222
|
+
leaseUntil: null,
|
|
223
|
+
});
|
|
224
|
+
if (!committed) {
|
|
225
|
+
// Revoked while we asked, or our lease ran out and another request took
|
|
226
|
+
// over. These tokens are kept nowhere, so they are not left live.
|
|
227
|
+
if (fresh.refreshToken !== tokens.rt)
|
|
228
|
+
revokeToken(fresh.refreshToken);
|
|
229
|
+
return load(key);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
key,
|
|
233
|
+
record: { ...record, generation: generation + 1, leaseUntil: null },
|
|
234
|
+
tokens: { k: key, idt: fresh.idToken, rt: fresh.refreshToken },
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* The person behind this cookie. Never sets a cookie: a dead or revoked
|
|
239
|
+
* handle just reads as nobody until the next sign-in replaces it.
|
|
240
|
+
*/
|
|
241
|
+
const read = async (value, force = false) => {
|
|
242
|
+
if (!value)
|
|
243
|
+
return { kind: 'none' };
|
|
244
|
+
const key = await keyOf(value);
|
|
245
|
+
if (!key)
|
|
246
|
+
return { kind: 'changed' };
|
|
247
|
+
if (!(await bindingActive()))
|
|
248
|
+
return { kind: 'unavailable' };
|
|
249
|
+
let loaded = await load(key);
|
|
250
|
+
if (!loaded)
|
|
251
|
+
return { kind: 'none' };
|
|
252
|
+
const exp = expiryOf(loaded.tokens.idt);
|
|
253
|
+
const valid = exp !== null && exp > now() - 60;
|
|
254
|
+
const due = force || exp === null || exp - options().refreshWindow <= now();
|
|
255
|
+
if (due && loaded.tokens.rt) {
|
|
256
|
+
loaded = await refresh(loaded, valid);
|
|
257
|
+
if (!loaded)
|
|
258
|
+
return { kind: 'none' };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
return { kind: 'user', claims: await deps.oidc().verify(loaded.tokens.idt) };
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return { kind: 'none' };
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
return { bindingActive, create, end, read, revokeSubject, keyOf };
|
|
268
|
+
}
|
|
269
|
+
function expiryOf(idToken) {
|
|
270
|
+
try {
|
|
271
|
+
const [, payload] = idToken.split('.');
|
|
272
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
273
|
+
return typeof json.exp === 'number' ? json.exp : null;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|