@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
package/dist/auth.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
|
|
|
3
3
|
import { type AuthOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
export interface User {
|
|
6
|
+
/** Present only for validated namespace sessions. Membership is app-owned. */
|
|
7
|
+
namespaceId?: string;
|
|
8
|
+
bindingId?: string;
|
|
6
9
|
id: string;
|
|
7
10
|
email: string | null;
|
|
8
11
|
emailVerified: boolean;
|
|
@@ -14,6 +17,10 @@ export interface User {
|
|
|
14
17
|
claims: JWTPayload;
|
|
15
18
|
}
|
|
16
19
|
export interface ReadResult {
|
|
20
|
+
/** A cookie exists for another binding/revision or cannot be opened. Do not auto-switch. */
|
|
21
|
+
accountChanged?: true;
|
|
22
|
+
/** Namespace mode: the binding is suspended or its status cannot be confirmed. Do not start a sign-in. */
|
|
23
|
+
unavailable?: true;
|
|
17
24
|
user: User | null;
|
|
18
25
|
/** What the response must set. Empty unless the session was refreshed or ended. */
|
|
19
26
|
cookies: SetCookie[];
|
|
@@ -35,6 +42,7 @@ export type Gate = {
|
|
|
35
42
|
| {
|
|
36
43
|
kind: 'deny';
|
|
37
44
|
cookies: SetCookie[];
|
|
45
|
+
reason?: 'account_changed' | 'unavailable';
|
|
38
46
|
};
|
|
39
47
|
export type Intent = 'login' | 'register';
|
|
40
48
|
export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
|
|
@@ -135,7 +143,10 @@ export interface Auth {
|
|
|
135
143
|
invited: boolean;
|
|
136
144
|
}>;
|
|
137
145
|
handle(request: Request): Promise<Response>;
|
|
138
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
|
|
148
|
+
* Namespace sessions refresh on the server and never set a cookie, so they ignore it.
|
|
149
|
+
*/
|
|
139
150
|
read(request: Request, opts?: {
|
|
140
151
|
refresh?: boolean;
|
|
141
152
|
}): Promise<ReadResult>;
|
|
@@ -171,6 +182,13 @@ export interface Auth {
|
|
|
171
182
|
userId: string;
|
|
172
183
|
state: string;
|
|
173
184
|
}>;
|
|
185
|
+
/**
|
|
186
|
+
* Namespace mode: end every session this person has in this service, on
|
|
187
|
+
* every replica, at their next request. Deactivating someone does this too.
|
|
188
|
+
* Their sign-in at the issuer is untouched, so an active person can sign
|
|
189
|
+
* straight back in.
|
|
190
|
+
*/
|
|
191
|
+
revokeSessions(userId: string): Promise<number>;
|
|
174
192
|
/** The auth request the issuer sent the browser here with. Refuses one for another application. */
|
|
175
193
|
authRequest(id: string): Promise<AuthRequest>;
|
|
176
194
|
signIn(input: {
|
package/dist/auth.js
CHANGED
|
@@ -3,15 +3,48 @@ import * as client from 'openid-client';
|
|
|
3
3
|
import { Broker, BrokerError } from './broker.js';
|
|
4
4
|
import { resolveOptions } from './config.js';
|
|
5
5
|
import { clearSession, clearTransaction, cookieFrom, openSession, openTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
6
|
+
import { namespaceSessions } from './namespace-session.js';
|
|
6
7
|
import { AuthError, ORG_CLAIM, Oidc } from './oidc.js';
|
|
7
8
|
import { safeNextPath } from './redirect.js';
|
|
8
9
|
export function createAuth(input) {
|
|
9
10
|
// Resolved on first use so `next build`, which imports every route module, needs none of the values set.
|
|
10
11
|
let resolved = null;
|
|
11
12
|
const options = () => {
|
|
12
|
-
|
|
13
|
+
if (!resolved) {
|
|
14
|
+
const candidate = resolveOptions(input);
|
|
15
|
+
if (candidate.namespace && (!candidate.sessionStore || !candidate.appKey))
|
|
16
|
+
throw new Error('@wtfalch/auth: namespace mode needs a sessionStore and an appKey');
|
|
17
|
+
resolved = candidate;
|
|
18
|
+
}
|
|
13
19
|
return resolved;
|
|
14
20
|
};
|
|
21
|
+
const boundUser = (claims) => {
|
|
22
|
+
const namespace = options().namespace;
|
|
23
|
+
return {
|
|
24
|
+
...userFrom(claims),
|
|
25
|
+
...(namespace
|
|
26
|
+
? {
|
|
27
|
+
namespaceId: namespace.namespaceId,
|
|
28
|
+
bindingId: namespace.id,
|
|
29
|
+
}
|
|
30
|
+
: {}),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
const validCallback = (raw) => {
|
|
34
|
+
if (!options().namespace)
|
|
35
|
+
return true;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(raw);
|
|
38
|
+
return (!url.username &&
|
|
39
|
+
!url.password &&
|
|
40
|
+
!url.hash &&
|
|
41
|
+
`${url.origin}${url.pathname}` ===
|
|
42
|
+
new URL(`${options().basePath}/callback`, options().appUrl).href);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
15
48
|
let oidcInstance = null;
|
|
16
49
|
const oidc = () => {
|
|
17
50
|
oidcInstance ??= new Oidc(options());
|
|
@@ -22,6 +55,11 @@ export function createAuth(input) {
|
|
|
22
55
|
brokerInstance ??= new Broker(options());
|
|
23
56
|
return brokerInstance;
|
|
24
57
|
};
|
|
58
|
+
const sessions = namespaceSessions({ options, oidc, broker });
|
|
59
|
+
const sessionCookie = (tokens) => options().namespace
|
|
60
|
+
? sessions.create(tokens)
|
|
61
|
+
: sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
|
|
62
|
+
const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
|
|
25
63
|
const startUrl = (next, intent = 'login') => {
|
|
26
64
|
const url = new URL(`${options().basePath}/start`, options().appUrl);
|
|
27
65
|
const path = safeNextPath(next, '');
|
|
@@ -34,6 +72,8 @@ export function createAuth(input) {
|
|
|
34
72
|
const start = async (request) => {
|
|
35
73
|
if (!isNavigation(request))
|
|
36
74
|
return new Response('Unauthorized', { status: 401 });
|
|
75
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
76
|
+
return unavailable();
|
|
37
77
|
const params = new URL(request.url).searchParams;
|
|
38
78
|
const next = safeNextPath(params.get('next'), options().afterLogin);
|
|
39
79
|
const state = client.randomState();
|
|
@@ -51,13 +91,17 @@ export function createAuth(input) {
|
|
|
51
91
|
return redirect(target.href, [transaction]);
|
|
52
92
|
};
|
|
53
93
|
const complete = async (callbackUrl, cookieHeader) => {
|
|
94
|
+
if (!validCallback(callbackUrl))
|
|
95
|
+
return failure('request', []);
|
|
54
96
|
const cleared = clearTransaction(options());
|
|
55
97
|
const transaction = await openTransaction(options(), cookieFrom(cookieHeader, transactionCookieName(options())));
|
|
56
98
|
if (!transaction)
|
|
57
|
-
return failure('expired', [cleared]);
|
|
99
|
+
return failure('expired', options().namespace ? [] : [cleared]);
|
|
58
100
|
const params = new URL(callbackUrl).searchParams;
|
|
59
101
|
if (params.get('state') !== transaction.st)
|
|
60
|
-
return failure('state', [cleared]);
|
|
102
|
+
return failure('state', options().namespace ? [] : [cleared]);
|
|
103
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
104
|
+
return failure('unavailable', [cleared]);
|
|
61
105
|
let session;
|
|
62
106
|
try {
|
|
63
107
|
const tokens = await oidc().exchange(params, {
|
|
@@ -65,11 +109,16 @@ export function createAuth(input) {
|
|
|
65
109
|
nonce: transaction.nc,
|
|
66
110
|
codeVerifier: transaction.cv,
|
|
67
111
|
});
|
|
68
|
-
session = await
|
|
112
|
+
session = await sessionCookie(tokens);
|
|
69
113
|
}
|
|
70
114
|
catch (error) {
|
|
71
115
|
return failure(reasonOf(error), [cleared]);
|
|
72
116
|
}
|
|
117
|
+
// Signing in again replaces this binding's previous session outright.
|
|
118
|
+
if (options().namespace)
|
|
119
|
+
await sessions
|
|
120
|
+
.end(cookieFrom(cookieHeader, sessionCookieName(options())))
|
|
121
|
+
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
73
122
|
return {
|
|
74
123
|
location: new URL(transaction.nx, options().appUrl).href,
|
|
75
124
|
cookies: [cleared, session],
|
|
@@ -83,7 +132,14 @@ export function createAuth(input) {
|
|
|
83
132
|
if (!isSameOrigin(request, options().appUrl.origin)) {
|
|
84
133
|
return new Response('Forbidden', { status: 403 });
|
|
85
134
|
}
|
|
86
|
-
const
|
|
135
|
+
const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
|
|
136
|
+
if (options().namespace) {
|
|
137
|
+
// Revoked in the store first: that is what ends copies of the cookie too.
|
|
138
|
+
if ((await sessions.end(value)) === 'changed')
|
|
139
|
+
return new Response('Account changed', { status: 409 });
|
|
140
|
+
return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
|
|
141
|
+
}
|
|
142
|
+
const session = await openSession(options(), value);
|
|
87
143
|
if (session?.rt)
|
|
88
144
|
await broker()
|
|
89
145
|
.revoke(session.rt)
|
|
@@ -106,7 +162,13 @@ export function createAuth(input) {
|
|
|
106
162
|
// The id token in the cookie still says unverified; the claim is what the
|
|
107
163
|
// app reads, so it is refreshed here rather than at the next expiry.
|
|
108
164
|
const cookies = [];
|
|
109
|
-
const
|
|
165
|
+
const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
|
|
166
|
+
if (options().namespace) {
|
|
167
|
+
await sessions.read(value, true).catch(() => { });
|
|
168
|
+
url.searchParams.set('verified', '1');
|
|
169
|
+
return redirect(url.href, cookies);
|
|
170
|
+
}
|
|
171
|
+
const session = await openSession(options(), value);
|
|
110
172
|
if (session?.rt) {
|
|
111
173
|
try {
|
|
112
174
|
const tokens = await oidc().refresh(session.rt);
|
|
@@ -135,6 +197,16 @@ export function createAuth(input) {
|
|
|
135
197
|
return new Response('Not found', { status: 404 });
|
|
136
198
|
};
|
|
137
199
|
const readCookie = async (value, { refresh = true } = {}) => {
|
|
200
|
+
if (options().namespace) {
|
|
201
|
+
const result = await sessions.read(value);
|
|
202
|
+
if (result.kind === 'user')
|
|
203
|
+
return { user: boundUser(result.claims), cookies: [] };
|
|
204
|
+
if (result.kind === 'changed')
|
|
205
|
+
return { user: null, cookies: [], accountChanged: true };
|
|
206
|
+
if (result.kind === 'unavailable')
|
|
207
|
+
return { user: null, cookies: [], unavailable: true };
|
|
208
|
+
return { user: null, cookies: [] };
|
|
209
|
+
}
|
|
138
210
|
const session = await openSession(options(), value);
|
|
139
211
|
if (!session)
|
|
140
212
|
return { user: null, cookies: [] };
|
|
@@ -149,7 +221,7 @@ export function createAuth(input) {
|
|
|
149
221
|
idt: tokens.idToken,
|
|
150
222
|
rt: tokens.refreshToken,
|
|
151
223
|
});
|
|
152
|
-
return { user:
|
|
224
|
+
return { user: boundUser(tokens.claims), cookies: [cookie] };
|
|
153
225
|
}
|
|
154
226
|
catch (error) {
|
|
155
227
|
// A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
|
|
@@ -161,7 +233,7 @@ export function createAuth(input) {
|
|
|
161
233
|
}
|
|
162
234
|
try {
|
|
163
235
|
const claims = await oidc().verify(session.idt);
|
|
164
|
-
return { user:
|
|
236
|
+
return { user: boundUser(claims), cookies: [] };
|
|
165
237
|
}
|
|
166
238
|
catch (error) {
|
|
167
239
|
return { user: null, cookies: refresh && rejected(error) ? [clearSession(options())] : [] };
|
|
@@ -173,27 +245,47 @@ export function createAuth(input) {
|
|
|
173
245
|
const isPublic = url.pathname === options().basePath ||
|
|
174
246
|
url.pathname.startsWith(`${options().basePath}/`) ||
|
|
175
247
|
matches(rules.public, url.pathname);
|
|
176
|
-
const { user, cookies } = await read(request);
|
|
248
|
+
const { user, cookies, accountChanged, unavailable } = await read(request);
|
|
177
249
|
if (user || isPublic)
|
|
178
250
|
return { kind: 'next', user, cookies };
|
|
251
|
+
if (accountChanged)
|
|
252
|
+
return { kind: 'deny', cookies, reason: 'account_changed' };
|
|
253
|
+
if (unavailable)
|
|
254
|
+
return { kind: 'deny', cookies, reason: 'unavailable' };
|
|
179
255
|
if (!isNavigation(request))
|
|
180
256
|
return { kind: 'deny', cookies };
|
|
181
257
|
return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
|
|
182
258
|
};
|
|
183
259
|
const people = (options) => broker().people(options);
|
|
184
|
-
const setPersonActive = (userId, active) =>
|
|
260
|
+
const setPersonActive = async (userId, active) => {
|
|
261
|
+
const result = await broker().setPersonActive(userId, active);
|
|
262
|
+
if (!active && options().namespace)
|
|
263
|
+
await sessions.revokeSubject(userId);
|
|
264
|
+
return result;
|
|
265
|
+
};
|
|
266
|
+
const revokeSessions = (userId) => {
|
|
267
|
+
if (!options().namespace)
|
|
268
|
+
throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
|
|
269
|
+
return sessions.revokeSubject(userId);
|
|
270
|
+
};
|
|
185
271
|
const authRequest = (id) => broker().authRequest(id);
|
|
186
272
|
/**
|
|
187
273
|
* An OIDC flow this app starts and finishes itself, for a person arriving
|
|
188
274
|
* from an email rather than from a browser the app sent to the issuer.
|
|
189
275
|
*/
|
|
190
276
|
const serverFlow = async () => {
|
|
277
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
278
|
+
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
191
279
|
const state = client.randomState();
|
|
192
280
|
const nonce = client.randomNonce();
|
|
193
281
|
const verifier = client.randomPKCECodeVerifier();
|
|
194
282
|
const target = await oidc().authorizationUrl(state, nonce, verifier);
|
|
195
283
|
const response = await options().fetch(target.href, { redirect: 'manual' });
|
|
196
284
|
const location = response.headers.get('location');
|
|
285
|
+
if (options().namespace &&
|
|
286
|
+
location &&
|
|
287
|
+
new URL(location, options().issuer).origin !== options().namespace?.loginOrigin)
|
|
288
|
+
throw new AuthError('request', 'issuer returned a different namespace login origin');
|
|
197
289
|
const id = location && new URL(location, options().issuer).searchParams.get('authRequest');
|
|
198
290
|
if (!id)
|
|
199
291
|
throw new AuthError('request', 'the issuer did not start an auth request');
|
|
@@ -201,16 +293,18 @@ export function createAuth(input) {
|
|
|
201
293
|
};
|
|
202
294
|
/** Exchanges a callback the service issued, and seals the cookie. */
|
|
203
295
|
const finish = async (callbackUrl, flow, next) => {
|
|
296
|
+
if (!validCallback(callbackUrl))
|
|
297
|
+
throw new AuthError('request', 'callback does not match namespace binding');
|
|
204
298
|
const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
|
|
205
299
|
state: flow.state,
|
|
206
300
|
nonce: flow.nonce,
|
|
207
301
|
codeVerifier: flow.verifier,
|
|
208
302
|
});
|
|
209
|
-
const cookie = await
|
|
303
|
+
const cookie = await sessionCookie(tokens);
|
|
210
304
|
return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
|
|
211
305
|
};
|
|
212
306
|
const hostedUrl = (id) => {
|
|
213
|
-
const url = new URL('/login', options().issuer);
|
|
307
|
+
const url = new URL('/login', options().namespace?.loginOrigin ?? options().issuer);
|
|
214
308
|
url.searchParams.set('authRequest', id);
|
|
215
309
|
return url.href;
|
|
216
310
|
};
|
|
@@ -358,6 +452,7 @@ export function createAuth(input) {
|
|
|
358
452
|
errorUrl,
|
|
359
453
|
people,
|
|
360
454
|
setPersonActive,
|
|
455
|
+
revokeSessions,
|
|
361
456
|
authRequest,
|
|
362
457
|
signIn,
|
|
363
458
|
signUp,
|
package/dist/broker.d.ts
CHANGED
|
@@ -86,6 +86,18 @@ export declare class Broker {
|
|
|
86
86
|
sendLink(email: string, next: string): Promise<unknown>;
|
|
87
87
|
sendReset(email: string, next: string): Promise<unknown>;
|
|
88
88
|
sendVerification(email: string, next: string): Promise<unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* What the service routes this key to. A suspended binding's key is refused.
|
|
91
|
+
* Bounded, because every namespace request waits on this answer when it is due.
|
|
92
|
+
* `revoked` names the organization's recently deactivated people. It is absent
|
|
93
|
+
* from an older service, or while the service cannot ask the issuer.
|
|
94
|
+
*/
|
|
95
|
+
binding(): Promise<{
|
|
96
|
+
organizationId: string;
|
|
97
|
+
clientIds: string[];
|
|
98
|
+
origins: string[];
|
|
99
|
+
revoked?: string[];
|
|
100
|
+
}>;
|
|
89
101
|
verifyEmail(userId: string, code: string): Promise<unknown>;
|
|
90
102
|
/** A public client revoking its own token: no key, no service. */
|
|
91
103
|
revoke(refreshToken: string): Promise<void>;
|
package/dist/broker.js
CHANGED
|
@@ -20,7 +20,7 @@ export class Broker {
|
|
|
20
20
|
constructor(options) {
|
|
21
21
|
this.options = options;
|
|
22
22
|
}
|
|
23
|
-
async call(method, path, body) {
|
|
23
|
+
async call(method, path, body, signal) {
|
|
24
24
|
if (!this.options.appKey) {
|
|
25
25
|
throw new Error('@wtfalch/auth: appKey is required to sign people in on the app');
|
|
26
26
|
}
|
|
@@ -30,6 +30,7 @@ export class Broker {
|
|
|
30
30
|
url.searchParams.set(k, String(v));
|
|
31
31
|
const response = await this.options.fetch(url.href, {
|
|
32
32
|
method,
|
|
33
|
+
signal,
|
|
33
34
|
headers: {
|
|
34
35
|
authorization: `Bearer ${this.options.appKey}`,
|
|
35
36
|
'content-type': 'application/json',
|
|
@@ -95,6 +96,15 @@ export class Broker {
|
|
|
95
96
|
sendVerification(email, next) {
|
|
96
97
|
return this.call('POST', '/verification/send', { email, next });
|
|
97
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* What the service routes this key to. A suspended binding's key is refused.
|
|
101
|
+
* Bounded, because every namespace request waits on this answer when it is due.
|
|
102
|
+
* `revoked` names the organization's recently deactivated people. It is absent
|
|
103
|
+
* from an older service, or while the service cannot ask the issuer.
|
|
104
|
+
*/
|
|
105
|
+
binding() {
|
|
106
|
+
return this.call('GET', '/binding', {}, AbortSignal.timeout(5_000));
|
|
107
|
+
}
|
|
98
108
|
verifyEmail(userId, code) {
|
|
99
109
|
return this.call('POST', '/verify', { userId, code });
|
|
100
110
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { NamespaceSelection } from './namespaces.js';
|
|
1
2
|
/**
|
|
2
3
|
* Signing in from a browser app, and being *already* signed in when another
|
|
3
4
|
* subdomain did it.
|
|
@@ -37,6 +38,8 @@
|
|
|
37
38
|
* bounce somebody straight back in — a sign-out that undoes itself is not one.
|
|
38
39
|
*/
|
|
39
40
|
export interface BrowserAuthOptions {
|
|
41
|
+
/** Public namespace registry projection; never include broker or cookie secrets. */
|
|
42
|
+
namespace?: NamespaceSelection;
|
|
40
43
|
/** The issuer, e.g. `https://auth.wtfalch.dev`. */
|
|
41
44
|
issuer: string;
|
|
42
45
|
/** This app's client id at the issuer. */
|
|
@@ -56,6 +59,8 @@ export interface BrowserAuthOptions {
|
|
|
56
59
|
scope?: string;
|
|
57
60
|
}
|
|
58
61
|
export interface BrowserSession {
|
|
62
|
+
namespaceId?: string;
|
|
63
|
+
bindingId?: string;
|
|
59
64
|
accessToken: string;
|
|
60
65
|
/** Epoch milliseconds. */
|
|
61
66
|
expiresAt: number;
|
|
@@ -78,6 +83,8 @@ export type SignInOutcome = {
|
|
|
78
83
|
error: string;
|
|
79
84
|
};
|
|
80
85
|
export interface BrowserAuth {
|
|
86
|
+
/** Another account/binding replaced this context; require a deliberate sign-in. */
|
|
87
|
+
accountChanged(): boolean;
|
|
81
88
|
/** The session this tab holds, or null. Expiry is checked with a minute of
|
|
82
89
|
* slack: a token that dies mid-request is worse than one renewed early. */
|
|
83
90
|
currentSession(): BrowserSession | null;
|
package/dist/browser.js
CHANGED
|
@@ -1,41 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* Signing in from a browser app, and being *already* signed in when another
|
|
3
|
-
* subdomain did it.
|
|
4
|
-
*
|
|
5
|
-
* The rest of this package is for a server: it holds a session in a cookie and
|
|
6
|
-
* the person never sees a token. A browser app cannot do that — whatever it
|
|
7
|
-
* holds, the person holding the browser holds too — so this is a public client
|
|
8
|
-
* using PKCE, the flow designed for exactly that.
|
|
9
|
-
*
|
|
10
|
-
* **Why a session cannot simply be shared across subdomains.** The server
|
|
11
|
-
* side's cookie is `__Host-` prefixed, and that prefix is defined as `Secure`,
|
|
12
|
-
* `Path=/` and *no* `Domain` attribute: host-only, by specification. It is
|
|
13
|
-
* what stops one subdomain writing a session cookie the next one would trust.
|
|
14
|
-
* Widening it to `.<apex>` to get single sign-on would trade that away, so the
|
|
15
|
-
* shared thing is not a cookie: it is the **session at the issuer**. Every
|
|
16
|
-
* origin gets its own token from it, and the person is asked for credentials
|
|
17
|
-
* once.
|
|
18
|
-
*
|
|
19
|
-
* **`prompt=none` is what makes that invisible.** On load, an app with no token
|
|
20
|
-
* asks the issuer to authorise without interacting. Either a code comes back —
|
|
21
|
-
* a redirect out and in, a flash, and the app is signed in — or the issuer
|
|
22
|
-
* answers `login_required`, and the app shows its own sign-in affordance
|
|
23
|
-
* having lost nothing. `trySilentSignIn` is that attempt, `completeSignIn`
|
|
24
|
-
* tells the two apart, and `SilentRefused` is the outcome to draw a button for
|
|
25
|
-
* rather than an error.
|
|
26
|
-
*
|
|
27
|
-
* **A top-level redirect, not a hidden iframe.** The classic silent-auth trick
|
|
28
|
-
* loads the issuer in an iframe, which needs the issuer's cookie in a
|
|
29
|
-
* third-party context — blocked by default in Safari and increasingly
|
|
30
|
-
* elsewhere. It fails *silently* and intermittently, which is the worst
|
|
31
|
-
* failure mode available. A redirect always works and costs a flash.
|
|
32
|
-
*
|
|
33
|
-
* **Every silent attempt is made at most once per tab.** Without that, an
|
|
34
|
-
* issuer answering `login_required` sends the app back to a page whose load
|
|
35
|
-
* asks again, forever, and the loop is invisible: the address bar settles and
|
|
36
|
-
* the tab spins. `forget()` sets the same mark, so pressing sign out does not
|
|
37
|
-
* bounce somebody straight back in — a sign-out that undoes itself is not one.
|
|
38
|
-
*/
|
|
1
|
+
import { createNamespaceBrowserAuth } from './namespace-browser.js';
|
|
39
2
|
const TOKEN = 'wtfalch.auth.token';
|
|
40
3
|
const VERIFIER = 'wtfalch.auth.verifier';
|
|
41
4
|
const RETURN = 'wtfalch.auth.return';
|
|
@@ -85,6 +48,8 @@ function verifier() {
|
|
|
85
48
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
86
49
|
}
|
|
87
50
|
export function createBrowserAuth(options) {
|
|
51
|
+
if (options.namespace)
|
|
52
|
+
return createNamespaceBrowserAuth(options, options.namespace);
|
|
88
53
|
const origin = options.origin ?? window.location.origin;
|
|
89
54
|
const callbackPath = options.callbackPath ?? '/auth/callback';
|
|
90
55
|
const scope = options.scope ?? 'openid email profile offline_access';
|
|
@@ -118,6 +83,7 @@ export function createBrowserAuth(options) {
|
|
|
118
83
|
};
|
|
119
84
|
const silentSignInAvailable = () => !currentSession() && !store.get(TRIED);
|
|
120
85
|
return {
|
|
86
|
+
accountChanged: () => false,
|
|
121
87
|
currentSession,
|
|
122
88
|
silentSignInAvailable,
|
|
123
89
|
async trySilentSignIn(returnTo = window.location.pathname) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { type NamespaceSelection, resolveNamespaceContext } from './namespaces.js';
|
|
2
|
+
import type { SessionStore } from './sessions.js';
|
|
1
3
|
export interface AuthOptions {
|
|
4
|
+
/** Opt-in namespace mode. Resolve this from trusted workspace/host configuration. */
|
|
5
|
+
namespace?: NamespaceSelection;
|
|
6
|
+
/**
|
|
7
|
+
* Where namespace sessions are kept. Required with `namespace`, together with
|
|
8
|
+
* `appKey`; unused without it. Every replica of the app must share one.
|
|
9
|
+
*/
|
|
10
|
+
sessionStore?: SessionStore;
|
|
2
11
|
/** This app's origin, e.g. https://portal.valet.wtfalch.dev. Every URL the SDK builds starts here, never from the request's Host. */
|
|
3
12
|
appUrl: string | undefined;
|
|
4
13
|
/** From scripts/provisioned.json. */
|
|
@@ -30,13 +39,15 @@ export interface AuthOptions {
|
|
|
30
39
|
appKey?: string;
|
|
31
40
|
/** The sign-in service. Defaults to the issuer's /api. */
|
|
32
41
|
brokerUrl?: string;
|
|
33
|
-
/**
|
|
42
|
+
/** Session lifetime in seconds, sliding. Default 30 days, the issuer's idle expiry for a refresh token. */
|
|
34
43
|
sessionMaxAge?: number;
|
|
35
44
|
/** Refresh when the id token has fewer seconds left than this. Default 300. */
|
|
36
45
|
refreshWindow?: number;
|
|
37
46
|
fetch?: typeof fetch;
|
|
38
47
|
}
|
|
39
48
|
export interface ResolvedOptions {
|
|
49
|
+
namespace: ReturnType<typeof resolveNamespaceContext> | null;
|
|
50
|
+
sessionStore: SessionStore | null;
|
|
40
51
|
appUrl: URL;
|
|
41
52
|
clientId: string;
|
|
42
53
|
organizationId: string;
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveNamespaceContext } from './namespaces.js';
|
|
1
2
|
export const ISSUER = 'https://auth.wtfalch.dev';
|
|
2
3
|
export function resolveOptions(options) {
|
|
3
4
|
const appUrl = parseAppUrl(options.appUrl);
|
|
@@ -19,9 +20,24 @@ export function resolveOptions(options) {
|
|
|
19
20
|
*/
|
|
20
21
|
if (options.cookieSecret !== undefined)
|
|
21
22
|
decodeKey(options.cookieSecret);
|
|
23
|
+
const issuer = (options.issuer ?? ISSUER).replace(/\/$/, '');
|
|
24
|
+
const namespace = options.namespace
|
|
25
|
+
? resolveNamespaceContext(options.namespace, {
|
|
26
|
+
issuer,
|
|
27
|
+
clientId: options.clientId,
|
|
28
|
+
organizationId: options.organizationId,
|
|
29
|
+
appOrigin: appUrl.origin,
|
|
30
|
+
redirectUri: new URL(`${basePath}/callback`, appUrl).href,
|
|
31
|
+
})
|
|
32
|
+
: null;
|
|
33
|
+
if (namespace &&
|
|
34
|
+
!namespace.postLogoutRedirectUris.includes(new URL(options.afterLogout ?? '/', appUrl).href))
|
|
35
|
+
throw new Error('@wtfalch/auth: logout destination disagrees with binding');
|
|
22
36
|
// Held here so the key is decoded at most once however often it is read.
|
|
23
37
|
let cookieKey;
|
|
24
38
|
return {
|
|
39
|
+
namespace,
|
|
40
|
+
sessionStore: options.sessionStore ?? null,
|
|
25
41
|
appUrl,
|
|
26
42
|
clientId: options.clientId,
|
|
27
43
|
organizationId: options.organizationId,
|
package/dist/cookies.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export interface SessionPayload extends JWTPayload {
|
|
|
4
4
|
idt: string;
|
|
5
5
|
rt?: string;
|
|
6
6
|
}
|
|
7
|
+
/** The tokens inside a namespace session record, bound to that record's key. */
|
|
8
|
+
export interface RecordPayload extends JWTPayload {
|
|
9
|
+
k: string;
|
|
10
|
+
idt: string;
|
|
11
|
+
rt?: string;
|
|
12
|
+
}
|
|
7
13
|
export interface TransactionPayload extends JWTPayload {
|
|
8
14
|
st: string;
|
|
9
15
|
nc: string;
|
|
@@ -33,6 +39,16 @@ export declare function transactionCookieName(options: ResolvedOptions): string;
|
|
|
33
39
|
export declare function sealSession(options: ResolvedOptions, payload: Omit<SessionPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
34
40
|
export declare function openSession(options: ResolvedOptions, value: string | undefined): Promise<SessionPayload | null>;
|
|
35
41
|
export declare function clearSession(options: ResolvedOptions): SetCookie;
|
|
42
|
+
/** The namespace session cookie: a random handle to a server record, nothing else. */
|
|
43
|
+
export declare function sealHandle(options: ResolvedOptions, sid: string): Promise<SetCookie>;
|
|
44
|
+
export declare function openHandle(options: ResolvedOptions, value: string | undefined): Promise<string | null>;
|
|
45
|
+
export declare function sealRecord(options: ResolvedOptions, payload: Omit<RecordPayload, keyof JWTPayload>): Promise<string>;
|
|
46
|
+
/** The tokens, if this payload was sealed for this record under this binding. */
|
|
47
|
+
export declare function openRecord(options: ResolvedOptions, key: string, value: string): Promise<RecordPayload | null>;
|
|
48
|
+
/** The binding digest a namespace record is filed under. */
|
|
49
|
+
export declare function contextDigest(options: ResolvedOptions): Promise<string>;
|
|
50
|
+
/** SHA-256, base64url. */
|
|
51
|
+
export declare function digest(value: string): Promise<string>;
|
|
36
52
|
export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
37
53
|
export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
|
|
38
54
|
export declare function clearTransaction(options: ResolvedOptions): SetCookie;
|
package/dist/cookies.js
CHANGED
|
@@ -4,6 +4,13 @@ const SESSION = 'wtfalch_auth';
|
|
|
4
4
|
const TRANSACTION = 'wtfalch_auth_tx';
|
|
5
5
|
const LINK = 'wtfalch_auth_link';
|
|
6
6
|
const TRANSACTION_TTL = 10 * 60;
|
|
7
|
+
/**
|
|
8
|
+
* A namespace session handle lives as long as a browser will keep a cookie.
|
|
9
|
+
* It grants nothing by itself: the server record decides, and slides its own
|
|
10
|
+
* expiry. Refreshing never sets it again, which is what stops a late response
|
|
11
|
+
* from putting back a cookie the person has since replaced.
|
|
12
|
+
*/
|
|
13
|
+
const HANDLE_TTL = 400 * 24 * 60 * 60;
|
|
7
14
|
export function sessionCookieName(options) {
|
|
8
15
|
return options.secure ? `__Host-${SESSION}` : SESSION;
|
|
9
16
|
}
|
|
@@ -25,6 +32,37 @@ export async function openSession(options, value) {
|
|
|
25
32
|
export function clearSession(options) {
|
|
26
33
|
return setCookie(sessionCookieName(options), '', 0, options.secure);
|
|
27
34
|
}
|
|
35
|
+
/** The namespace session cookie: a random handle to a server record, nothing else. */
|
|
36
|
+
export async function sealHandle(options, sid) {
|
|
37
|
+
const value = await seal(options, 'handle', { sid }, HANDLE_TTL);
|
|
38
|
+
return setCookie(sessionCookieName(options), value, HANDLE_TTL, options.secure);
|
|
39
|
+
}
|
|
40
|
+
export async function openHandle(options, value) {
|
|
41
|
+
const payload = await open(options, 'handle', value);
|
|
42
|
+
return payload && typeof payload.sid === 'string' ? payload.sid : null;
|
|
43
|
+
}
|
|
44
|
+
export function sealRecord(options, payload) {
|
|
45
|
+
return seal(options, 'record', payload, HANDLE_TTL);
|
|
46
|
+
}
|
|
47
|
+
/** The tokens, if this payload was sealed for this record under this binding. */
|
|
48
|
+
export async function openRecord(options, key, value) {
|
|
49
|
+
const payload = await open(options, 'record', value);
|
|
50
|
+
return payload && payload.k === key && typeof payload.idt === 'string' ? payload : null;
|
|
51
|
+
}
|
|
52
|
+
/** The binding digest a namespace record is filed under. */
|
|
53
|
+
export async function contextDigest(options) {
|
|
54
|
+
if (!options.namespace)
|
|
55
|
+
throw new Error('@wtfalch/auth: no namespace binding');
|
|
56
|
+
return digest(options.namespace.context);
|
|
57
|
+
}
|
|
58
|
+
/** SHA-256, base64url. */
|
|
59
|
+
export async function digest(value) {
|
|
60
|
+
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
61
|
+
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
62
|
+
.replaceAll('+', '-')
|
|
63
|
+
.replaceAll('/', '_')
|
|
64
|
+
.replace(/=+$/, '');
|
|
65
|
+
}
|
|
28
66
|
export async function sealTransaction(options, payload) {
|
|
29
67
|
const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
|
|
30
68
|
return setCookie(transactionCookieName(options), value, TRANSACTION_TTL, options.secure);
|
|
@@ -61,11 +99,21 @@ export async function openLink(options, value) {
|
|
|
61
99
|
export function clearLink(options) {
|
|
62
100
|
return setCookie(linkCookieName(options), '', 0, options.secure);
|
|
63
101
|
}
|
|
102
|
+
// A different audience also prevents old static consumers from accepting a
|
|
103
|
+
// namespace cookie. Hashing keeps the encrypted session below browser limits.
|
|
104
|
+
async function audience(options, kind) {
|
|
105
|
+
if (!options.namespace)
|
|
106
|
+
return kind;
|
|
107
|
+
return `${kind}:namespace:1:${await digest(options.namespace.context)}`;
|
|
108
|
+
}
|
|
64
109
|
async function seal(options, kind, payload, ttl) {
|
|
65
|
-
return new EncryptJWT(
|
|
110
|
+
return new EncryptJWT({
|
|
111
|
+
...payload,
|
|
112
|
+
...(options.namespace ? { ns: options.namespace.namespaceId } : {}),
|
|
113
|
+
})
|
|
66
114
|
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
|
|
67
115
|
.setIssuer(options.appUrl.origin)
|
|
68
|
-
.setAudience(kind)
|
|
116
|
+
.setAudience(await audience(options, kind))
|
|
69
117
|
.setIssuedAt()
|
|
70
118
|
.setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
|
|
71
119
|
.encrypt(options.cookieKey);
|
|
@@ -76,7 +124,7 @@ async function open(options, kind, value) {
|
|
|
76
124
|
try {
|
|
77
125
|
const { payload } = await jwtDecrypt(value, options.cookieKey, {
|
|
78
126
|
issuer: options.appUrl.origin,
|
|
79
|
-
audience: kind,
|
|
127
|
+
audience: await audience(options, kind),
|
|
80
128
|
contentEncryptionAlgorithms: ['A256GCM'],
|
|
81
129
|
keyManagementAlgorithms: ['dir'],
|
|
82
130
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
2
2
|
export { type AuthOptions, ISSUER } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
|
+
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
4
5
|
export { BrokerError } from './broker.js';
|
|
5
6
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
6
7
|
export { safeNextPath } from './redirect.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { BrowserAuth, BrowserAuthOptions } from './browser.js';
|
|
2
|
+
import { type NamespaceSelection } from './namespaces.js';
|
|
3
|
+
/** Isolated implementation: legacy storage and callback behavior stay unchanged. */
|
|
4
|
+
export declare function createNamespaceBrowserAuth(options: BrowserAuthOptions, selection: NamespaceSelection): BrowserAuth;
|