@wtfalch/auth 0.4.1 → 0.6.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 +39 -1
- package/dist/auth.js +127 -20
- package/dist/broker.d.ts +9 -0
- package/dist/broker.js +9 -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 +64 -0
- package/dist/namespace-session.d.ts +42 -0
- package/dist/namespace-session.js +253 -0
- package/dist/namespaces.d.ts +118 -0
- package/dist/namespaces.js +244 -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 +14 -1
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
const askService = async () => {
|
|
39
|
+
const namespace = options().namespace;
|
|
40
|
+
if (!namespace)
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
const routed = await deps.broker().binding();
|
|
44
|
+
return (routed.organizationId === namespace.organizationId &&
|
|
45
|
+
routed.clientIds.includes(namespace.clientId) &&
|
|
46
|
+
routed.origins.includes(namespace.appOrigin));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
// Suspension removes the binding's key, so the service no longer knows it.
|
|
50
|
+
if (error instanceof BrokerError && (error.status === 401 || error.status === 403))
|
|
51
|
+
return false;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Whether the service still routes this binding. An answer is trusted for a
|
|
57
|
+
* minute from when it was asked, so a suspension reaches every request
|
|
58
|
+
* within that. While the service cannot be asked the last good answer
|
|
59
|
+
* carries on for five minutes, then this fails closed.
|
|
60
|
+
*/
|
|
61
|
+
const bindingActive = async () => {
|
|
62
|
+
const asked = Date.now();
|
|
63
|
+
if (status && asked - status.at < STATUS_FRESH_MS)
|
|
64
|
+
return status.active;
|
|
65
|
+
if (asked >= retryAt) {
|
|
66
|
+
pending ??= askService().finally(() => {
|
|
67
|
+
pending = null;
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const active = await pending;
|
|
71
|
+
if (!status || status.at < asked)
|
|
72
|
+
status = { active, at: asked };
|
|
73
|
+
return active;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
retryAt = Date.now() + STATUS_RETRY_MS;
|
|
77
|
+
console.error('@wtfalch/auth: could not confirm the namespace binding', error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return status?.active === true && Date.now() - status.at < STATUS_STALE_IF_ERROR_MS;
|
|
81
|
+
};
|
|
82
|
+
// ---- records ------------------------------------------------------------
|
|
83
|
+
const load = async (key) => {
|
|
84
|
+
const record = await store().get(key);
|
|
85
|
+
if (!record ||
|
|
86
|
+
record.revokedAt !== null ||
|
|
87
|
+
record.expiresAt <= now() ||
|
|
88
|
+
record.context !== (await contextDigest(options())))
|
|
89
|
+
return null;
|
|
90
|
+
const tokens = await openRecord(options(), key, record.payload);
|
|
91
|
+
return tokens ? { key, record, tokens } : null;
|
|
92
|
+
};
|
|
93
|
+
const keyOf = async (value) => {
|
|
94
|
+
const sid = await openHandle(options(), value);
|
|
95
|
+
return sid ? digest(sid) : null;
|
|
96
|
+
};
|
|
97
|
+
const seal = (key, tokens) => sealRecord(options(), { k: key, idt: tokens.idToken, rt: tokens.refreshToken });
|
|
98
|
+
const revokeToken = (refreshToken) => {
|
|
99
|
+
if (refreshToken)
|
|
100
|
+
deps
|
|
101
|
+
.broker()
|
|
102
|
+
.revoke(refreshToken)
|
|
103
|
+
.catch(() => { });
|
|
104
|
+
};
|
|
105
|
+
/** A new record and the cookie that points at it. */
|
|
106
|
+
const create = async (tokens) => {
|
|
107
|
+
const sid = client.randomState();
|
|
108
|
+
const key = await digest(sid);
|
|
109
|
+
await store().create({
|
|
110
|
+
key,
|
|
111
|
+
context: await contextDigest(options()),
|
|
112
|
+
subject: String(tokens.claims.sub),
|
|
113
|
+
generation: 0,
|
|
114
|
+
leaseUntil: null,
|
|
115
|
+
payload: await seal(key, tokens),
|
|
116
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
117
|
+
revokedAt: null,
|
|
118
|
+
});
|
|
119
|
+
return sealHandle(options(), sid);
|
|
120
|
+
};
|
|
121
|
+
/** Ends the record behind this cookie, if it is one of this binding's. */
|
|
122
|
+
const end = async (value) => {
|
|
123
|
+
if (!value)
|
|
124
|
+
return 'none';
|
|
125
|
+
const key = await keyOf(value);
|
|
126
|
+
if (!key)
|
|
127
|
+
return 'changed';
|
|
128
|
+
const loaded = await load(key);
|
|
129
|
+
await store().revoke(key, now());
|
|
130
|
+
revokeToken(loaded?.tokens.rt);
|
|
131
|
+
return 'ended';
|
|
132
|
+
};
|
|
133
|
+
const revokeSubject = async (subject) => store().revokeSubject(await contextDigest(options()), subject, now());
|
|
134
|
+
// ---- refresh ------------------------------------------------------------
|
|
135
|
+
const definitive = (error) => error instanceof AuthError ||
|
|
136
|
+
(error instanceof client.ResponseBodyError && error.error === 'invalid_grant');
|
|
137
|
+
/**
|
|
138
|
+
* Refresh under the lease. Resolves the freshest usable tokens: new ones,
|
|
139
|
+
* another request's, or null when the session is over. While another
|
|
140
|
+
* request holds the lease, a current token with time left is used as it is;
|
|
141
|
+
* without one, this waits a few seconds for that refresh to land, and takes
|
|
142
|
+
* the lease over if its holder has gone quiet past its expiry.
|
|
143
|
+
*/
|
|
144
|
+
const refresh = async (loaded, stillValid) => {
|
|
145
|
+
const key = loaded.key;
|
|
146
|
+
let current = loaded;
|
|
147
|
+
for (let attempt = 0;; attempt++) {
|
|
148
|
+
const { record } = current;
|
|
149
|
+
if (attempt > 0 &&
|
|
150
|
+
record.generation !== loaded.record.generation &&
|
|
151
|
+
record.leaseUntil === null)
|
|
152
|
+
return current;
|
|
153
|
+
const leased = record.leaseUntil !== null && record.leaseUntil > now();
|
|
154
|
+
if (!leased &&
|
|
155
|
+
(await store().advance(key, record.generation, {
|
|
156
|
+
payload: record.payload,
|
|
157
|
+
expiresAt: record.expiresAt,
|
|
158
|
+
leaseUntil: now() + LEASE_SECONDS,
|
|
159
|
+
})))
|
|
160
|
+
return refreshLeased(current, stillValid);
|
|
161
|
+
if (stillValid)
|
|
162
|
+
return current;
|
|
163
|
+
if (attempt >= WAIT_STEPS)
|
|
164
|
+
return null;
|
|
165
|
+
await sleep(WAIT_STEP_MS);
|
|
166
|
+
const latest = await load(key);
|
|
167
|
+
if (!latest)
|
|
168
|
+
return null;
|
|
169
|
+
current = latest;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const refreshLeased = async (leased, stillValid) => {
|
|
173
|
+
const { key, record, tokens } = leased;
|
|
174
|
+
const generation = record.generation + 1;
|
|
175
|
+
let fresh;
|
|
176
|
+
try {
|
|
177
|
+
fresh = await deps.oidc().refresh(tokens.rt);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (definitive(error)) {
|
|
181
|
+
// The lease means nobody else spent the token: the issuer has ended it.
|
|
182
|
+
await store().revoke(key, now());
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
await store()
|
|
186
|
+
.advance(key, generation, {
|
|
187
|
+
payload: record.payload,
|
|
188
|
+
expiresAt: record.expiresAt,
|
|
189
|
+
leaseUntil: null,
|
|
190
|
+
})
|
|
191
|
+
.catch(() => { });
|
|
192
|
+
return stillValid ? leased : null;
|
|
193
|
+
}
|
|
194
|
+
const committed = await store().advance(key, generation, {
|
|
195
|
+
payload: await seal(key, fresh),
|
|
196
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
197
|
+
leaseUntil: null,
|
|
198
|
+
});
|
|
199
|
+
if (!committed) {
|
|
200
|
+
// Revoked while we asked, or our lease ran out and another request took
|
|
201
|
+
// over. These tokens are kept nowhere, so they are not left live.
|
|
202
|
+
if (fresh.refreshToken !== tokens.rt)
|
|
203
|
+
revokeToken(fresh.refreshToken);
|
|
204
|
+
return load(key);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
key,
|
|
208
|
+
record: { ...record, generation: generation + 1, leaseUntil: null },
|
|
209
|
+
tokens: { k: key, idt: fresh.idToken, rt: fresh.refreshToken },
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* The person behind this cookie. Never sets a cookie: a dead or revoked
|
|
214
|
+
* handle just reads as nobody until the next sign-in replaces it.
|
|
215
|
+
*/
|
|
216
|
+
const read = async (value, force = false) => {
|
|
217
|
+
if (!value)
|
|
218
|
+
return { kind: 'none' };
|
|
219
|
+
const key = await keyOf(value);
|
|
220
|
+
if (!key)
|
|
221
|
+
return { kind: 'changed' };
|
|
222
|
+
if (!(await bindingActive()))
|
|
223
|
+
return { kind: 'unavailable' };
|
|
224
|
+
let loaded = await load(key);
|
|
225
|
+
if (!loaded)
|
|
226
|
+
return { kind: 'none' };
|
|
227
|
+
const exp = expiryOf(loaded.tokens.idt);
|
|
228
|
+
const valid = exp !== null && exp > now() - 60;
|
|
229
|
+
const due = force || exp === null || exp - options().refreshWindow <= now();
|
|
230
|
+
if (due && loaded.tokens.rt) {
|
|
231
|
+
loaded = await refresh(loaded, valid);
|
|
232
|
+
if (!loaded)
|
|
233
|
+
return { kind: 'none' };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return { kind: 'user', claims: await deps.oidc().verify(loaded.tokens.idt) };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { kind: 'none' };
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
return { bindingActive, create, end, read, revokeSubject, keyOf };
|
|
243
|
+
}
|
|
244
|
+
function expiryOf(idToken) {
|
|
245
|
+
try {
|
|
246
|
+
const [, payload] = idToken.split('.');
|
|
247
|
+
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
248
|
+
return typeof json.exp === 'number' ? json.exp : null;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Versioned server-owned configuration. URL/email input is a lookup key, never
|
|
2
|
+
* authority. Workspace membership and session/transaction binding remain the
|
|
3
|
+
* responsibility of the consumer; this module does not establish a session. */
|
|
4
|
+
export type NamespaceStatus = 'pending' | 'active' | 'suspended' | 'failed';
|
|
5
|
+
export interface NamespaceDomain {
|
|
6
|
+
domain: string;
|
|
7
|
+
purpose: 'account' | 'login';
|
|
8
|
+
status: 'pending' | 'active';
|
|
9
|
+
/** Operator-owned evidence reference; this is not an automated DNS verifier. */
|
|
10
|
+
verificationRef?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface NamespaceService {
|
|
13
|
+
id: string;
|
|
14
|
+
serviceId: string;
|
|
15
|
+
deploymentId: string;
|
|
16
|
+
appOrigin: string;
|
|
17
|
+
redirectUris: string[];
|
|
18
|
+
postLogoutRedirectUris: string[];
|
|
19
|
+
credentialRef: string;
|
|
20
|
+
registration: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface NamespaceSpec {
|
|
23
|
+
id: string;
|
|
24
|
+
displayName: string;
|
|
25
|
+
revision: number;
|
|
26
|
+
status: Exclude<NamespaceStatus, 'failed'>;
|
|
27
|
+
loginOrigin: string;
|
|
28
|
+
domains: NamespaceDomain[];
|
|
29
|
+
mail: {
|
|
30
|
+
from: string;
|
|
31
|
+
productName: string;
|
|
32
|
+
locale: string;
|
|
33
|
+
};
|
|
34
|
+
services: NamespaceService[];
|
|
35
|
+
}
|
|
36
|
+
export interface NamespaceManifest {
|
|
37
|
+
version: 1;
|
|
38
|
+
issuer: string;
|
|
39
|
+
namespaces: NamespaceSpec[];
|
|
40
|
+
}
|
|
41
|
+
export interface NamespaceRecord extends Omit<NamespaceSpec, 'status' | 'services'> {
|
|
42
|
+
status: NamespaceStatus;
|
|
43
|
+
organizationId: string;
|
|
44
|
+
projectId: string;
|
|
45
|
+
services: (NamespaceService & {
|
|
46
|
+
appId: string;
|
|
47
|
+
clientId: string;
|
|
48
|
+
})[];
|
|
49
|
+
}
|
|
50
|
+
export interface NamespaceRegistry {
|
|
51
|
+
version: 1;
|
|
52
|
+
issuer: string;
|
|
53
|
+
namespaces: NamespaceRecord[];
|
|
54
|
+
}
|
|
55
|
+
export interface WorkspaceBinding {
|
|
56
|
+
serviceId: string;
|
|
57
|
+
workspaceId: string;
|
|
58
|
+
namespaceId: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseNamespaceManifest(raw: unknown): NamespaceManifest;
|
|
61
|
+
export declare function parseNamespaceRegistry(raw: unknown): NamespaceRegistry;
|
|
62
|
+
/** Exact server-side configuration lookup. It grants no workspace membership. */
|
|
63
|
+
export declare function resolveNamespaceBinding(registry: NamespaceRegistry, input: {
|
|
64
|
+
namespaceId: string;
|
|
65
|
+
serviceId: string;
|
|
66
|
+
deploymentId: string;
|
|
67
|
+
appOrigin: string;
|
|
68
|
+
}): {
|
|
69
|
+
id: string;
|
|
70
|
+
serviceId: string;
|
|
71
|
+
deploymentId: string;
|
|
72
|
+
appOrigin: string;
|
|
73
|
+
redirectUris: string[];
|
|
74
|
+
postLogoutRedirectUris: string[];
|
|
75
|
+
credentialRef: string;
|
|
76
|
+
registration: boolean;
|
|
77
|
+
appId: string;
|
|
78
|
+
clientId: string;
|
|
79
|
+
issuer: string;
|
|
80
|
+
namespaceId: string;
|
|
81
|
+
organizationId: string;
|
|
82
|
+
revision: number;
|
|
83
|
+
loginOrigin: string;
|
|
84
|
+
};
|
|
85
|
+
/** Apps store these mappings themselves; email addresses are deliberately absent. */
|
|
86
|
+
export declare function parseWorkspaceBindings(raw: unknown, registry: NamespaceRegistry): WorkspaceBinding[];
|
|
87
|
+
/** Trusted server configuration, or its public projection for a browser client. */
|
|
88
|
+
export interface NamespaceSelection {
|
|
89
|
+
registry: NamespaceRegistry;
|
|
90
|
+
namespaceId: string;
|
|
91
|
+
serviceId: string;
|
|
92
|
+
deploymentId: string;
|
|
93
|
+
}
|
|
94
|
+
/** A deterministic binding used inside protected cookies and browser transactions. */
|
|
95
|
+
export declare function resolveNamespaceContext(selection: NamespaceSelection, options: {
|
|
96
|
+
issuer: string;
|
|
97
|
+
clientId: string;
|
|
98
|
+
appOrigin: string;
|
|
99
|
+
redirectUri: string;
|
|
100
|
+
organizationId?: string;
|
|
101
|
+
}): {
|
|
102
|
+
context: string;
|
|
103
|
+
id: string;
|
|
104
|
+
serviceId: string;
|
|
105
|
+
deploymentId: string;
|
|
106
|
+
appOrigin: string;
|
|
107
|
+
redirectUris: string[];
|
|
108
|
+
postLogoutRedirectUris: string[];
|
|
109
|
+
credentialRef: string;
|
|
110
|
+
registration: boolean;
|
|
111
|
+
appId: string;
|
|
112
|
+
clientId: string;
|
|
113
|
+
issuer: string;
|
|
114
|
+
namespaceId: string;
|
|
115
|
+
organizationId: string;
|
|
116
|
+
revision: number;
|
|
117
|
+
loginOrigin: string;
|
|
118
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
function invalid(field) {
|
|
2
|
+
throw new Error(`Invalid namespace configuration: ${field}`);
|
|
3
|
+
}
|
|
4
|
+
function object(value, field) {
|
|
5
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
6
|
+
invalid(field);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
function string(value, field) {
|
|
10
|
+
if (typeof value !== 'string' ||
|
|
11
|
+
!value ||
|
|
12
|
+
value.trim() !== value ||
|
|
13
|
+
value.length > 250 ||
|
|
14
|
+
[...value].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127))
|
|
15
|
+
invalid(field);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function id(value, field) {
|
|
19
|
+
const result = string(value, field);
|
|
20
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(result) ||
|
|
21
|
+
['constructor', 'prototype', '__proto__'].includes(result))
|
|
22
|
+
invalid(field);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
function array(value, field) {
|
|
26
|
+
if (!Array.isArray(value) || value.length > 1000)
|
|
27
|
+
invalid(field);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function oneOf(value, choices, field) {
|
|
31
|
+
if (!choices.includes(value))
|
|
32
|
+
invalid(field);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function url(value, field, originOnly, allowLocal = false) {
|
|
36
|
+
const raw = string(value, field);
|
|
37
|
+
let result;
|
|
38
|
+
try {
|
|
39
|
+
result = new URL(raw);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return invalid(field);
|
|
43
|
+
}
|
|
44
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(result.hostname) ||
|
|
45
|
+
result.hostname.endsWith('.localhost');
|
|
46
|
+
if ((result.protocol !== 'https:' && !(allowLocal && local && result.protocol === 'http:')) ||
|
|
47
|
+
result.username ||
|
|
48
|
+
result.password ||
|
|
49
|
+
result.hash ||
|
|
50
|
+
result.hostname.endsWith('.'))
|
|
51
|
+
invalid(field);
|
|
52
|
+
if (originOnly && (result.pathname !== '/' || result.search))
|
|
53
|
+
invalid(field);
|
|
54
|
+
return originOnly ? result.origin : result.href;
|
|
55
|
+
}
|
|
56
|
+
function unique(values, field) {
|
|
57
|
+
if (new Set(values).size !== values.length)
|
|
58
|
+
invalid(`duplicate ${field}`);
|
|
59
|
+
}
|
|
60
|
+
function uris(value, origin, field) {
|
|
61
|
+
const result = array(value, field).map((v) => url(v, field, false, true));
|
|
62
|
+
if (!result.length || result.some((v) => new URL(v).origin !== origin || v.includes('*')))
|
|
63
|
+
invalid(field);
|
|
64
|
+
unique(result, field);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
function domain(value) {
|
|
68
|
+
const d = object(value, 'domain');
|
|
69
|
+
const name = string(d.domain, 'domain').toLowerCase();
|
|
70
|
+
if (name.length > 253 ||
|
|
71
|
+
!name.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
|
|
72
|
+
invalid('domain');
|
|
73
|
+
const purpose = oneOf(d.purpose, ['account', 'login'], 'domain purpose');
|
|
74
|
+
const status = oneOf(d.status, ['pending', 'active'], 'domain status');
|
|
75
|
+
const verificationRef = d.verificationRef === undefined ? undefined : string(d.verificationRef, 'verificationRef');
|
|
76
|
+
if (status === 'active' && !verificationRef)
|
|
77
|
+
invalid('active domain needs verificationRef');
|
|
78
|
+
return { domain: name, purpose, status, ...(verificationRef ? { verificationRef } : {}) };
|
|
79
|
+
}
|
|
80
|
+
function service(value) {
|
|
81
|
+
const s = object(value, 'service');
|
|
82
|
+
const appOrigin = url(s.appOrigin, 'appOrigin', true, true);
|
|
83
|
+
const credentialRef = string(s.credentialRef, 'credentialRef');
|
|
84
|
+
if (!/^[A-Z][A-Z0-9_]{2,100}$/.test(credentialRef) || credentialRef.endsWith('_PAT'))
|
|
85
|
+
invalid('credentialRef');
|
|
86
|
+
if (s.registration !== undefined && typeof s.registration !== 'boolean')
|
|
87
|
+
invalid('registration');
|
|
88
|
+
return {
|
|
89
|
+
id: id(s.id, 'binding id'),
|
|
90
|
+
serviceId: id(s.serviceId, 'serviceId'),
|
|
91
|
+
deploymentId: id(s.deploymentId, 'deploymentId'),
|
|
92
|
+
appOrigin,
|
|
93
|
+
redirectUris: uris(s.redirectUris, appOrigin, 'redirectUris'),
|
|
94
|
+
postLogoutRedirectUris: uris(s.postLogoutRedirectUris, appOrigin, 'postLogoutRedirectUris'),
|
|
95
|
+
credentialRef,
|
|
96
|
+
registration: s.registration === true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function parse(raw, resolved) {
|
|
100
|
+
const input = object(raw, 'document');
|
|
101
|
+
if (input.version !== 1)
|
|
102
|
+
invalid('version');
|
|
103
|
+
const issuer = url(input.issuer, 'issuer', true);
|
|
104
|
+
const namespaces = array(input.namespaces, 'namespaces').map((value) => {
|
|
105
|
+
const n = object(value, 'namespace');
|
|
106
|
+
const mail = object(n.mail, 'mail');
|
|
107
|
+
if (!Number.isSafeInteger(n.revision) || n.revision < 1)
|
|
108
|
+
invalid('revision');
|
|
109
|
+
const loginOrigin = url(n.loginOrigin, 'loginOrigin', true);
|
|
110
|
+
const domains = array(n.domains, 'domains').map(domain);
|
|
111
|
+
unique(domains.map((d) => `${d.purpose}:${d.domain}`), 'domain purpose');
|
|
112
|
+
const status = oneOf(n.status, resolved
|
|
113
|
+
? ['pending', 'active', 'suspended', 'failed']
|
|
114
|
+
: ['pending', 'active', 'suspended'], 'status');
|
|
115
|
+
const loginDomain = domains.find((d) => d.purpose === 'login' && d.domain === new URL(loginOrigin).hostname);
|
|
116
|
+
if (loginOrigin !== issuer && !loginDomain)
|
|
117
|
+
invalid('custom login origin needs a domain binding');
|
|
118
|
+
if (status === 'active' &&
|
|
119
|
+
(domains.some((d) => d.status !== 'active') ||
|
|
120
|
+
(loginOrigin !== issuer && loginDomain?.status !== 'active')))
|
|
121
|
+
invalid('active namespace has unverified domains');
|
|
122
|
+
const services = array(n.services, 'services').map((value) => {
|
|
123
|
+
const spec = service(value);
|
|
124
|
+
if (!resolved)
|
|
125
|
+
return spec;
|
|
126
|
+
const r = object(value, 'resolved service');
|
|
127
|
+
return { ...spec, appId: string(r.appId, 'appId'), clientId: string(r.clientId, 'clientId') };
|
|
128
|
+
});
|
|
129
|
+
if (!services.length)
|
|
130
|
+
invalid('namespace needs a service');
|
|
131
|
+
unique(services.map((s) => `${s.serviceId}:${s.deploymentId}`), 'namespace service/deployment');
|
|
132
|
+
const common = {
|
|
133
|
+
id: id(n.id, 'namespace id'),
|
|
134
|
+
displayName: string(n.displayName, 'displayName'),
|
|
135
|
+
revision: n.revision,
|
|
136
|
+
status,
|
|
137
|
+
loginOrigin,
|
|
138
|
+
domains,
|
|
139
|
+
services,
|
|
140
|
+
mail: {
|
|
141
|
+
from: string(mail.from, 'mail.from'),
|
|
142
|
+
productName: string(mail.productName, 'mail.productName'),
|
|
143
|
+
locale: string(mail.locale, 'mail.locale'),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
return resolved
|
|
147
|
+
? {
|
|
148
|
+
...common,
|
|
149
|
+
organizationId: string(n.organizationId, 'organizationId'),
|
|
150
|
+
projectId: string(n.projectId, 'projectId'),
|
|
151
|
+
}
|
|
152
|
+
: common;
|
|
153
|
+
});
|
|
154
|
+
unique(namespaces.map((n) => n.id), 'namespace id');
|
|
155
|
+
unique(namespaces.flatMap((n) => n.services.map((s) => s.id)), 'binding id');
|
|
156
|
+
unique(namespaces.flatMap((n) => n.services.map((s) => s.credentialRef)), 'credential reference');
|
|
157
|
+
const domains = new Map();
|
|
158
|
+
for (const n of namespaces)
|
|
159
|
+
for (const d of n.domains) {
|
|
160
|
+
const owner = domains.get(d.domain);
|
|
161
|
+
// Pending and suspended claims also reserve ownership until an explicit
|
|
162
|
+
// migration releases them. A registry edit cannot silently reassign a domain.
|
|
163
|
+
if (owner && owner !== n.id)
|
|
164
|
+
invalid('domain belongs to multiple namespaces');
|
|
165
|
+
domains.set(d.domain, n.id);
|
|
166
|
+
}
|
|
167
|
+
if (resolved) {
|
|
168
|
+
const records = namespaces;
|
|
169
|
+
unique(records.map((n) => n.organizationId), 'organizationId');
|
|
170
|
+
unique(records.flatMap((n) => n.services.map((s) => s.clientId)), 'clientId');
|
|
171
|
+
}
|
|
172
|
+
return { version: 1, issuer, namespaces };
|
|
173
|
+
}
|
|
174
|
+
export function parseNamespaceManifest(raw) {
|
|
175
|
+
return parse(raw, false);
|
|
176
|
+
}
|
|
177
|
+
export function parseNamespaceRegistry(raw) {
|
|
178
|
+
return parse(raw, true);
|
|
179
|
+
}
|
|
180
|
+
/** Exact server-side configuration lookup. It grants no workspace membership. */
|
|
181
|
+
export function resolveNamespaceBinding(registry, input) {
|
|
182
|
+
const validated = parseNamespaceRegistry(registry);
|
|
183
|
+
const namespace = validated.namespaces.find((n) => n.id === input.namespaceId);
|
|
184
|
+
if (!namespace || namespace.status !== 'active')
|
|
185
|
+
invalid('namespace unavailable');
|
|
186
|
+
const binding = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
|
|
187
|
+
if (!binding || binding.appOrigin !== url(input.appOrigin, 'appOrigin', true, true))
|
|
188
|
+
invalid('service binding mismatch');
|
|
189
|
+
return {
|
|
190
|
+
issuer: validated.issuer,
|
|
191
|
+
namespaceId: namespace.id,
|
|
192
|
+
organizationId: namespace.organizationId,
|
|
193
|
+
revision: namespace.revision,
|
|
194
|
+
loginOrigin: namespace.loginOrigin,
|
|
195
|
+
...binding,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** Apps store these mappings themselves; email addresses are deliberately absent. */
|
|
199
|
+
export function parseWorkspaceBindings(raw, registry) {
|
|
200
|
+
const validated = parseNamespaceRegistry(registry);
|
|
201
|
+
const result = array(raw, 'workspace bindings').map((value) => {
|
|
202
|
+
const w = object(value, 'workspace binding');
|
|
203
|
+
const binding = {
|
|
204
|
+
serviceId: id(w.serviceId, 'serviceId'),
|
|
205
|
+
workspaceId: id(w.workspaceId, 'workspaceId'),
|
|
206
|
+
namespaceId: id(w.namespaceId, 'namespaceId'),
|
|
207
|
+
};
|
|
208
|
+
const namespace = validated.namespaces.find((n) => n.id === binding.namespaceId);
|
|
209
|
+
if (!namespace?.services.some((s) => s.serviceId === binding.serviceId))
|
|
210
|
+
invalid('workspace service/namespace mismatch');
|
|
211
|
+
return binding;
|
|
212
|
+
});
|
|
213
|
+
unique(result.map((w) => `${w.serviceId}:${w.workspaceId}`), 'workspace binding');
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
/** A deterministic binding used inside protected cookies and browser transactions. */
|
|
217
|
+
export function resolveNamespaceContext(selection, options) {
|
|
218
|
+
const binding = resolveNamespaceBinding(selection.registry, {
|
|
219
|
+
...selection,
|
|
220
|
+
appOrigin: options.appOrigin,
|
|
221
|
+
});
|
|
222
|
+
if (binding.issuer !== options.issuer ||
|
|
223
|
+
binding.clientId !== options.clientId ||
|
|
224
|
+
(options.organizationId !== undefined && binding.organizationId !== options.organizationId) ||
|
|
225
|
+
!binding.redirectUris.includes(options.redirectUri))
|
|
226
|
+
invalid('authentication options disagree with binding');
|
|
227
|
+
return {
|
|
228
|
+
...binding,
|
|
229
|
+
context: JSON.stringify([
|
|
230
|
+
1,
|
|
231
|
+
binding.namespaceId,
|
|
232
|
+
binding.id,
|
|
233
|
+
binding.serviceId,
|
|
234
|
+
binding.deploymentId,
|
|
235
|
+
binding.revision,
|
|
236
|
+
binding.issuer,
|
|
237
|
+
binding.organizationId,
|
|
238
|
+
binding.clientId,
|
|
239
|
+
binding.appOrigin,
|
|
240
|
+
options.redirectUri,
|
|
241
|
+
binding.loginOrigin,
|
|
242
|
+
]),
|
|
243
|
+
};
|
|
244
|
+
}
|
package/dist/next.js
CHANGED
|
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server';
|
|
|
4
4
|
import { createAuth, } from './auth.js';
|
|
5
5
|
import { BrokerError } from './broker.js';
|
|
6
6
|
import { withCookies } from './cookies.js';
|
|
7
|
+
import { AuthError } from './oidc.js';
|
|
7
8
|
/**
|
|
8
9
|
* Which of the two failures it was, in the vocabulary `auth_error` already
|
|
9
10
|
* uses. A refusal under 500 is about this request — the broker says unknown,
|
|
@@ -24,7 +25,13 @@ export function nextAuth(options) {
|
|
|
24
25
|
response = NextResponse.redirect(gate.location, 303);
|
|
25
26
|
}
|
|
26
27
|
else if (gate.kind === 'deny') {
|
|
27
|
-
response = new NextResponse('
|
|
28
|
+
response = new NextResponse(gate.reason === 'account_changed'
|
|
29
|
+
? 'Account changed; choose an account to continue'
|
|
30
|
+
: gate.reason === 'unavailable'
|
|
31
|
+
? 'Sign-in is unavailable for this workspace'
|
|
32
|
+
: 'Unauthorized', {
|
|
33
|
+
status: gate.reason === 'account_changed' ? 409 : gate.reason === 'unavailable' ? 503 : 401,
|
|
34
|
+
});
|
|
28
35
|
}
|
|
29
36
|
else if (gate.cookies.length > 0) {
|
|
30
37
|
const headers = new Headers(request.headers);
|
|
@@ -46,7 +53,12 @@ export function nextAuth(options) {
|
|
|
46
53
|
return user;
|
|
47
54
|
};
|
|
48
55
|
const requireUser = async (next) => {
|
|
49
|
-
const
|
|
56
|
+
const store = await cookies();
|
|
57
|
+
const { user, accountChanged, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, { refresh: false });
|
|
58
|
+
if (accountChanged)
|
|
59
|
+
throw new AuthError('account_changed', 'Choose an account to continue');
|
|
60
|
+
if (unavailable)
|
|
61
|
+
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
50
62
|
if (!user)
|
|
51
63
|
redirect(auth.startUrl(next));
|
|
52
64
|
return user;
|
package/dist/oidc.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare class Oidc {
|
|
|
28
28
|
verify(idToken: string): Promise<jose.JWTPayload>;
|
|
29
29
|
private assertOrganization;
|
|
30
30
|
}
|
|
31
|
-
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request';
|
|
31
|
+
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable';
|
|
32
32
|
export declare class AuthError extends Error {
|
|
33
33
|
readonly reason: AuthErrorReason;
|
|
34
34
|
constructor(reason: AuthErrorReason, message: string);
|