@wtfalch/auth 0.23.0 → 0.25.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 +7 -5
- package/dist/auth.js +69 -101
- package/dist/broker.js +44 -13
- package/dist/browser.d.ts +6 -3
- package/dist/browser.js +14 -26
- package/dist/config.d.ts +15 -1
- package/dist/config.js +20 -0
- package/dist/cookies.d.ts +1 -2
- package/dist/cookies.js +3 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/namespace-browser.js +6 -10
- package/dist/namespace-session.d.ts +1 -5
- package/dist/namespace-session.js +11 -27
- package/dist/next.d.ts +2 -1
- package/dist/next.js +34 -15
- package/dist/oidc.d.ts +4 -1
- package/dist/oidc.js +16 -1
- package/dist/person-token.js +4 -1
- package/dist/qr.js +3 -0
- package/dist/redirect.d.ts +7 -0
- package/dist/redirect.js +11 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +57 -0
- package/dist/util.d.ts +13 -0
- package/dist/util.js +44 -0
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { JWTPayload } from 'jose';
|
|
2
2
|
import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
|
|
3
3
|
import { type AccessTokenResult, type AuthOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
@@ -28,7 +28,7 @@ export interface User {
|
|
|
28
28
|
claims: JWTPayload;
|
|
29
29
|
}
|
|
30
30
|
export interface ReadResult {
|
|
31
|
-
/** A cookie
|
|
31
|
+
/** @deprecated Never set. A cookie that cannot be opened (another binding's, an older revision's) reads as no session. */
|
|
32
32
|
accountChanged?: true;
|
|
33
33
|
/** Namespace mode: the binding is suspended or its status cannot be confirmed. Do not start a sign-in. */
|
|
34
34
|
unavailable?: true;
|
|
@@ -287,6 +287,8 @@ export interface Auth {
|
|
|
287
287
|
givenName: string;
|
|
288
288
|
familyName: string;
|
|
289
289
|
next?: string;
|
|
290
|
+
/** See `NewUser['personalEmail']`. */
|
|
291
|
+
personalEmail?: string;
|
|
290
292
|
}): Promise<{
|
|
291
293
|
userId: string;
|
|
292
294
|
invited: boolean;
|
|
@@ -323,7 +325,7 @@ export interface Auth {
|
|
|
323
325
|
* `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
|
|
324
326
|
* last authentication is older than that many seconds.
|
|
325
327
|
*/
|
|
326
|
-
startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
|
|
328
|
+
startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge, loginHint?: string): string;
|
|
327
329
|
/**
|
|
328
330
|
* Whether `user` authenticated within `maxAge` seconds. A user with no
|
|
329
331
|
* `authTime` is never fresh: the issuer only has to send the claim when the
|
|
@@ -419,8 +421,8 @@ export interface Auth {
|
|
|
419
421
|
/**
|
|
420
422
|
* Starts registering a passkey on this app's own domain, for whoever is
|
|
421
423
|
* signed in on `request` -- never for a stranger. Throws `AuthError` when
|
|
422
|
-
* nobody is: `
|
|
423
|
-
*
|
|
424
|
+
* nobody is: `unavailable` exactly as `read` reports it,
|
|
425
|
+
* `unauthenticated` otherwise.
|
|
424
426
|
*/
|
|
425
427
|
startPasskeyRegistration(request: Request): Promise<PasskeyRegistrationStart>;
|
|
426
428
|
/** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
|
package/dist/auth.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { errors } from 'jose';
|
|
2
1
|
import * as client from 'openid-client';
|
|
3
2
|
import { Broker, BrokerError } from './broker.js';
|
|
4
3
|
import { resolveOptions, } from './config.js';
|
|
5
4
|
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
|
|
6
5
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
|
-
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
|
|
6
|
+
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
|
|
8
7
|
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
9
|
-
import { safeNextPath } from './redirect.js';
|
|
8
|
+
import { safeLoginHint, safeNextPath } from './redirect.js';
|
|
9
|
+
import { expiryOf, issuedAtOf, timeoutSignal } from './util.js';
|
|
10
10
|
export function createAuth(input) {
|
|
11
11
|
// Resolved on first use so `next build`, which imports every route module, needs none of the values set.
|
|
12
12
|
let resolved = null;
|
|
@@ -77,9 +77,19 @@ export function createAuth(input) {
|
|
|
77
77
|
return brokerInstance;
|
|
78
78
|
};
|
|
79
79
|
const sessions = namespaceSessions({ options, oidc, broker });
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
// Every way in ends here, so this is the one place that reports a sign-in.
|
|
81
|
+
const sessionCookie = async (tokens, requestHeaders) => {
|
|
82
|
+
const cookie = options().namespace
|
|
83
|
+
? await sessions.create(tokens, requestHeaders)
|
|
84
|
+
: await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
|
|
85
|
+
const email = tokens.claims.email;
|
|
86
|
+
emit({
|
|
87
|
+
type: 'signIn',
|
|
88
|
+
email: typeof email === 'string' ? email : '',
|
|
89
|
+
userId: String(tokens.claims.sub),
|
|
90
|
+
});
|
|
91
|
+
return cookie;
|
|
92
|
+
};
|
|
83
93
|
/**
|
|
84
94
|
* Signing in again replaces this binding's previous session outright: in
|
|
85
95
|
* namespace mode, ends whatever session record `cookieHeader` already
|
|
@@ -94,7 +104,7 @@ export function createAuth(input) {
|
|
|
94
104
|
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
95
105
|
};
|
|
96
106
|
const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
|
|
97
|
-
const startUrl = (next, intent = 'login', prompt, maxAge) => {
|
|
107
|
+
const startUrl = (next, intent = 'login', prompt, maxAge, loginHint) => {
|
|
98
108
|
const url = new URL(`${options().basePath}/start`, options().appUrl);
|
|
99
109
|
const path = safeNextPath(next, '');
|
|
100
110
|
if (path)
|
|
@@ -107,6 +117,9 @@ export function createAuth(input) {
|
|
|
107
117
|
assertMaxAge(maxAge);
|
|
108
118
|
url.searchParams.set('max_age', String(maxAge));
|
|
109
119
|
}
|
|
120
|
+
const hint = safeLoginHint(loginHint);
|
|
121
|
+
if (hint)
|
|
122
|
+
url.searchParams.set('login_hint', hint);
|
|
110
123
|
return url.href;
|
|
111
124
|
};
|
|
112
125
|
const fresh = (user, maxAge) => {
|
|
@@ -128,7 +141,8 @@ export function createAuth(input) {
|
|
|
128
141
|
const silent = (await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())))) === 'try' &&
|
|
129
142
|
!params.has('intent') &&
|
|
130
143
|
!params.has('prompt') &&
|
|
131
|
-
!params.has('max_age')
|
|
144
|
+
!params.has('max_age') &&
|
|
145
|
+
!params.has('login_hint');
|
|
132
146
|
const state = client.randomState();
|
|
133
147
|
const nonce = client.randomNonce();
|
|
134
148
|
const codeVerifier = client.randomPKCECodeVerifier();
|
|
@@ -136,6 +150,7 @@ export function createAuth(input) {
|
|
|
136
150
|
create: params.get('intent') === 'register',
|
|
137
151
|
prompt: silent ? 'none' : promptFrom(params.get('prompt')),
|
|
138
152
|
maxAge: maxAgeFrom(params.get('max_age')),
|
|
153
|
+
loginHint: safeLoginHint(params.get('login_hint')),
|
|
139
154
|
});
|
|
140
155
|
const transaction = await sealTransaction(options(), {
|
|
141
156
|
st: state,
|
|
@@ -263,9 +278,7 @@ export function createAuth(input) {
|
|
|
263
278
|
// `sessions.end` reaches the store, which can be briefly unreachable --
|
|
264
279
|
// and a sign-out that 500s over that leaves the browser's own cookie
|
|
265
280
|
// standing, which is worse than the record surviving a moment longer.
|
|
266
|
-
|
|
267
|
-
if (status === 'changed')
|
|
268
|
-
return new Response('Account changed', { status: 409 });
|
|
281
|
+
await sessions.end(value).catch(() => { });
|
|
269
282
|
return endIssuerSession(idToken, [clearSession(options())]);
|
|
270
283
|
}
|
|
271
284
|
const session = await openSession(options(), value);
|
|
@@ -330,8 +343,6 @@ export function createAuth(input) {
|
|
|
330
343
|
const result = await sessions.read(value);
|
|
331
344
|
if (result.kind === 'user')
|
|
332
345
|
return { user: boundUser(result.claims), cookies: [] };
|
|
333
|
-
if (result.kind === 'changed')
|
|
334
|
-
return { user: null, cookies: [], accountChanged: true };
|
|
335
346
|
if (result.kind === 'unavailable')
|
|
336
347
|
return { user: null, cookies: [], unavailable: true };
|
|
337
348
|
return { user: null, cookies: [] };
|
|
@@ -384,11 +395,9 @@ export function createAuth(input) {
|
|
|
384
395
|
const isPublic = url.pathname === options().basePath ||
|
|
385
396
|
url.pathname.startsWith(`${options().basePath}/`) ||
|
|
386
397
|
matches(rules.public, url.pathname);
|
|
387
|
-
const { user, cookies,
|
|
398
|
+
const { user, cookies, unavailable } = await read(request);
|
|
388
399
|
if (user || isPublic)
|
|
389
400
|
return { kind: 'next', user, cookies };
|
|
390
|
-
if (accountChanged)
|
|
391
|
-
return { kind: 'deny', cookies, reason: 'account_changed' };
|
|
392
401
|
if (unavailable)
|
|
393
402
|
return { kind: 'deny', cookies, reason: 'unavailable' };
|
|
394
403
|
if (!isNavigation(request))
|
|
@@ -406,13 +415,11 @@ export function createAuth(input) {
|
|
|
406
415
|
/**
|
|
407
416
|
* The person signed in on `request`, for the passkey routes -- never a
|
|
408
417
|
* stranger. Adapts `requireUser`'s reasons for a caller that throws rather
|
|
409
|
-
* than redirects: `
|
|
418
|
+
* than redirects: `unavailable` is read's own, and
|
|
410
419
|
* plain absence becomes `unauthenticated`, which read has no reason for.
|
|
411
420
|
*/
|
|
412
421
|
const currentUser = async (request) => {
|
|
413
|
-
const { user,
|
|
414
|
-
if (accountChanged)
|
|
415
|
-
throw new AuthError('account_changed', 'Choose an account to continue');
|
|
422
|
+
const { user, unavailable } = await read(request, { refresh: false });
|
|
416
423
|
if (unavailable)
|
|
417
424
|
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
418
425
|
if (!user)
|
|
@@ -479,8 +486,13 @@ export function createAuth(input) {
|
|
|
479
486
|
const nonce = client.randomNonce();
|
|
480
487
|
const verifier = client.randomPKCECodeVerifier();
|
|
481
488
|
const target = await oidc().authorizationUrl(state, nonce, verifier);
|
|
482
|
-
const response = await options().fetch(target.href, {
|
|
489
|
+
const response = await options().fetch(target.href, {
|
|
490
|
+
redirect: 'manual',
|
|
491
|
+
signal: timeoutSignal(),
|
|
492
|
+
});
|
|
483
493
|
const location = response.headers.get('location');
|
|
494
|
+
// Only the Location header is wanted; an unread body would hold the socket.
|
|
495
|
+
response.body?.cancel().catch(() => undefined);
|
|
484
496
|
if (options().namespace &&
|
|
485
497
|
location &&
|
|
486
498
|
new URL(location, options().issuer).origin !== options().namespace?.loginOrigin)
|
|
@@ -503,7 +515,9 @@ export function createAuth(input) {
|
|
|
503
515
|
return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
|
|
504
516
|
};
|
|
505
517
|
const hostedUrl = (id) => {
|
|
506
|
-
const url =
|
|
518
|
+
const url = options().namespace
|
|
519
|
+
? new URL('/login', options().namespace?.loginOrigin)
|
|
520
|
+
: new URL('login', options().signInBase);
|
|
507
521
|
url.searchParams.set('authRequest', id);
|
|
508
522
|
return url.href;
|
|
509
523
|
};
|
|
@@ -535,7 +549,13 @@ export function createAuth(input) {
|
|
|
535
549
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
536
550
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
537
551
|
const invite = async (input) => {
|
|
538
|
-
const result = await broker().invite({
|
|
552
|
+
const result = await broker().invite({
|
|
553
|
+
email: input.email,
|
|
554
|
+
givenName: input.givenName,
|
|
555
|
+
familyName: input.familyName,
|
|
556
|
+
next: safeNextPath(input.next, ''),
|
|
557
|
+
personalEmail: input.personalEmail,
|
|
558
|
+
});
|
|
539
559
|
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
540
560
|
return result;
|
|
541
561
|
};
|
|
@@ -611,14 +631,11 @@ export function createAuth(input) {
|
|
|
611
631
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
612
632
|
try {
|
|
613
633
|
const redirectTo = await broker().signIn({ authRequestId, email, password });
|
|
614
|
-
emit({ type: 'signIn', email });
|
|
615
634
|
return { ok: true, redirectTo };
|
|
616
635
|
}
|
|
617
636
|
catch (error) {
|
|
618
|
-
if (needsHostedLogin(error))
|
|
619
|
-
emit({ type: 'signIn', email });
|
|
637
|
+
if (needsHostedLogin(error))
|
|
620
638
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
621
|
-
}
|
|
622
639
|
const change = changeRequired(error);
|
|
623
640
|
if (change)
|
|
624
641
|
return change;
|
|
@@ -657,7 +674,6 @@ export function createAuth(input) {
|
|
|
657
674
|
try {
|
|
658
675
|
const flow = await serverFlow();
|
|
659
676
|
const completed = await finishOrHandoff(() => broker().signIn({ authRequestId: flow.id, email, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
660
|
-
emit({ type: 'signIn', email });
|
|
661
677
|
return { ok: true, ...completed };
|
|
662
678
|
}
|
|
663
679
|
catch (error) {
|
|
@@ -683,26 +699,7 @@ export function createAuth(input) {
|
|
|
683
699
|
return { ok: true, ...completed };
|
|
684
700
|
}
|
|
685
701
|
catch (error) {
|
|
686
|
-
|
|
687
|
-
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
688
|
-
}
|
|
689
|
-
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
690
|
-
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
691
|
-
}
|
|
692
|
-
// Both carry a sentence about the name, for the form to show as it is.
|
|
693
|
-
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
694
|
-
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
695
|
-
}
|
|
696
|
-
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
697
|
-
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
698
|
-
}
|
|
699
|
-
if (error instanceof BrokerError && error.error === 'request') {
|
|
700
|
-
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
701
|
-
}
|
|
702
|
-
if (error instanceof BrokerError && error.status < 500) {
|
|
703
|
-
return { ok: false, error: 'invalid', message: error.detail ?? error.error };
|
|
704
|
-
}
|
|
705
|
-
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
702
|
+
return signUpFailure(error);
|
|
706
703
|
}
|
|
707
704
|
};
|
|
708
705
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
@@ -719,26 +716,7 @@ export function createAuth(input) {
|
|
|
719
716
|
const change = changeRequired(error);
|
|
720
717
|
if (change)
|
|
721
718
|
return { ...change, message: 'choose a new password to continue' };
|
|
722
|
-
|
|
723
|
-
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
724
|
-
}
|
|
725
|
-
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
726
|
-
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
727
|
-
}
|
|
728
|
-
// Both carry a sentence about the name, for the form to show as it is.
|
|
729
|
-
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
730
|
-
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
731
|
-
}
|
|
732
|
-
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
733
|
-
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
734
|
-
}
|
|
735
|
-
if (error instanceof BrokerError && error.error === 'request') {
|
|
736
|
-
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
737
|
-
}
|
|
738
|
-
if (error instanceof BrokerError && error.status < 500) {
|
|
739
|
-
return { ok: false, error: 'invalid', message: error.detail ?? error.error };
|
|
740
|
-
}
|
|
741
|
-
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
719
|
+
return signUpFailure(error);
|
|
742
720
|
}
|
|
743
721
|
};
|
|
744
722
|
const startPasskeyRegistration = async (request) => {
|
|
@@ -799,7 +777,11 @@ export function createAuth(input) {
|
|
|
799
777
|
const changePassword = async (request, input) => {
|
|
800
778
|
const user = await currentFreshUser(request);
|
|
801
779
|
try {
|
|
802
|
-
await broker().changePassword({
|
|
780
|
+
await broker().changePassword({
|
|
781
|
+
userId: user.id,
|
|
782
|
+
currentPassword: input.currentPassword,
|
|
783
|
+
newPassword: input.newPassword,
|
|
784
|
+
});
|
|
803
785
|
return { ok: true };
|
|
804
786
|
}
|
|
805
787
|
catch (error) {
|
|
@@ -855,7 +837,7 @@ export function createAuth(input) {
|
|
|
855
837
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
856
838
|
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
857
839
|
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
|
|
858
|
-
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode
|
|
840
|
+
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode }, device.expiresIn);
|
|
859
841
|
return {
|
|
860
842
|
url: qrVerificationUrl(options(), device.verificationUriComplete),
|
|
861
843
|
userCode: device.userCode,
|
|
@@ -1025,28 +1007,6 @@ function matches(rule, pathname) {
|
|
|
1025
1007
|
return pathname === entry;
|
|
1026
1008
|
});
|
|
1027
1009
|
}
|
|
1028
|
-
function expiryOf(idToken) {
|
|
1029
|
-
try {
|
|
1030
|
-
const [, payload] = idToken.split('.');
|
|
1031
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
1032
|
-
return typeof json.exp === 'number' ? json.exp : null;
|
|
1033
|
-
}
|
|
1034
|
-
catch {
|
|
1035
|
-
return null;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
// Age, not time left: a disabled or revoked account must not ride out a
|
|
1039
|
-
// long-lived token just because it still has plenty of time on the clock.
|
|
1040
|
-
function issuedAtOf(idToken) {
|
|
1041
|
-
try {
|
|
1042
|
-
const [, payload] = idToken.split('.');
|
|
1043
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
1044
|
-
return typeof json.iat === 'number' ? json.iat : null;
|
|
1045
|
-
}
|
|
1046
|
-
catch {
|
|
1047
|
-
return null;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
1010
|
const PROMPTS = ['select_account', 'login'];
|
|
1051
1011
|
function promptFrom(raw) {
|
|
1052
1012
|
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
@@ -1110,15 +1070,6 @@ function userFrom(claims) {
|
|
|
1110
1070
|
};
|
|
1111
1071
|
}
|
|
1112
1072
|
// The token itself is bad, as opposed to the keys being unreachable or rotating.
|
|
1113
|
-
function rejected(error) {
|
|
1114
|
-
return (error instanceof AuthError ||
|
|
1115
|
-
error instanceof errors.JWTExpired ||
|
|
1116
|
-
error instanceof errors.JWTClaimValidationFailed ||
|
|
1117
|
-
error instanceof errors.JWSSignatureVerificationFailed ||
|
|
1118
|
-
error instanceof errors.JWSInvalid ||
|
|
1119
|
-
error instanceof errors.JWTInvalid ||
|
|
1120
|
-
error instanceof errors.JOSEAlgNotAllowed);
|
|
1121
|
-
}
|
|
1122
1073
|
function isNavigation(request) {
|
|
1123
1074
|
const mode = request.headers.get('sec-fetch-mode');
|
|
1124
1075
|
if (mode && mode !== 'navigate')
|
|
@@ -1172,6 +1123,23 @@ function signInError(error) {
|
|
|
1172
1123
|
return 'invalid_credentials';
|
|
1173
1124
|
return 'unavailable';
|
|
1174
1125
|
}
|
|
1126
|
+
/** The sign-up refusals the broker can name, each with its own sentence for the form to show as it is. */
|
|
1127
|
+
function signUpFailure(error) {
|
|
1128
|
+
if (error instanceof BrokerError) {
|
|
1129
|
+
const message = error.detail ?? error.error;
|
|
1130
|
+
switch (error.error) {
|
|
1131
|
+
case 'email_taken':
|
|
1132
|
+
case 'registration_closed':
|
|
1133
|
+
case 'username_taken':
|
|
1134
|
+
case 'username_invalid':
|
|
1135
|
+
case 'request':
|
|
1136
|
+
return { ok: false, error: error.error, message };
|
|
1137
|
+
}
|
|
1138
|
+
if (error.status < 500)
|
|
1139
|
+
return { ok: false, error: 'invalid', message };
|
|
1140
|
+
}
|
|
1141
|
+
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
1142
|
+
}
|
|
1175
1143
|
function reasonOf(error) {
|
|
1176
1144
|
if (error instanceof AuthError)
|
|
1177
1145
|
return error.reason;
|
package/dist/broker.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timeoutSignal } from './util.js';
|
|
1
2
|
export class BrokerError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
error;
|
|
@@ -34,7 +35,7 @@ export class Broker {
|
|
|
34
35
|
url.searchParams.set(k, String(v));
|
|
35
36
|
const response = await this.options.fetch(url.href, {
|
|
36
37
|
method,
|
|
37
|
-
signal,
|
|
38
|
+
signal: timeoutSignal(signal),
|
|
38
39
|
headers: {
|
|
39
40
|
authorization: `Bearer ${this.options.appKey}`,
|
|
40
41
|
'content-type': 'application/json',
|
|
@@ -84,8 +85,16 @@ export class Broker {
|
|
|
84
85
|
return callbackUrl;
|
|
85
86
|
}
|
|
86
87
|
async signUp(input) {
|
|
88
|
+
// Named fields: `input` can come from a form a client controls.
|
|
87
89
|
const { callbackUrl } = await this.call('POST', '/sign-up', {
|
|
88
|
-
|
|
90
|
+
authRequestId: input.authRequestId,
|
|
91
|
+
email: input.email,
|
|
92
|
+
password: input.password,
|
|
93
|
+
givenName: input.givenName,
|
|
94
|
+
familyName: input.familyName,
|
|
95
|
+
username: input.username,
|
|
96
|
+
changeRequired: input.changeRequired,
|
|
97
|
+
personalEmail: input.personalEmail,
|
|
89
98
|
});
|
|
90
99
|
return callbackUrl;
|
|
91
100
|
}
|
|
@@ -113,7 +122,13 @@ export class Broker {
|
|
|
113
122
|
return callbackUrl;
|
|
114
123
|
}
|
|
115
124
|
invite(input) {
|
|
116
|
-
return this.call('POST', '/invite',
|
|
125
|
+
return this.call('POST', '/invite', {
|
|
126
|
+
email: input.email,
|
|
127
|
+
givenName: input.givenName,
|
|
128
|
+
familyName: input.familyName,
|
|
129
|
+
next: input.next,
|
|
130
|
+
personalEmail: input.personalEmail,
|
|
131
|
+
});
|
|
117
132
|
}
|
|
118
133
|
sendLink(email, next) {
|
|
119
134
|
return this.call('POST', '/link/send', { email, next });
|
|
@@ -148,7 +163,11 @@ export class Broker {
|
|
|
148
163
|
return this.call('POST', '/email/change/complete', { userId, code });
|
|
149
164
|
}
|
|
150
165
|
changePassword(input) {
|
|
151
|
-
return this.call('POST', '/password/change',
|
|
166
|
+
return this.call('POST', '/password/change', {
|
|
167
|
+
userId: input.userId,
|
|
168
|
+
currentPassword: input.currentPassword,
|
|
169
|
+
newPassword: input.newPassword,
|
|
170
|
+
});
|
|
152
171
|
}
|
|
153
172
|
passkeys(userId) {
|
|
154
173
|
return this.call('GET', '/passkeys', { userId });
|
|
@@ -158,14 +177,26 @@ export class Broker {
|
|
|
158
177
|
}
|
|
159
178
|
/** A public client revoking its own token: no key, no service. */
|
|
160
179
|
async revoke(refreshToken) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
let response;
|
|
181
|
+
try {
|
|
182
|
+
response = await this.options.fetch(`${this.options.issuer}/oauth/v2/revoke`, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
signal: timeoutSignal(),
|
|
185
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
186
|
+
body: new URLSearchParams({
|
|
187
|
+
token: refreshToken,
|
|
188
|
+
token_type_hint: 'refresh_token',
|
|
189
|
+
client_id: this.options.clientId,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
console.error('@wtfalch/auth: the revoke request failed', error instanceof Error ? error.name : 'unknown');
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
console.error('@wtfalch/auth: the issuer refused to revoke a refresh token', response.status);
|
|
199
|
+
throw new BrokerError(response.status, 'unavailable', 'the issuer did not revoke the token');
|
|
200
|
+
}
|
|
170
201
|
}
|
|
171
202
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -125,8 +125,9 @@ export type RefreshOutcome = {
|
|
|
125
125
|
kind: 'no-refresh-token';
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
|
-
* The issuer refused rotation, or this operation was cancelled.
|
|
129
|
-
*
|
|
128
|
+
* The issuer refused rotation, or this operation was cancelled. A 4xx other
|
|
129
|
+
* than 429 drops the held session because the old refresh token was spent;
|
|
130
|
+
* a 429 or 5xx keeps it.
|
|
130
131
|
* Cancellation discards the old response and preserves any newer session.
|
|
131
132
|
*/
|
|
132
133
|
| {
|
|
@@ -160,7 +161,9 @@ export interface BrowserAuth {
|
|
|
160
161
|
trySilentSignIn(returnTo?: string): Promise<boolean>;
|
|
161
162
|
/** Leave for the issuer, expecting to be asked for credentials. Does not
|
|
162
163
|
* return. */
|
|
163
|
-
signIn(returnTo?: string
|
|
164
|
+
signIn(returnTo?: string, opts?: {
|
|
165
|
+
loginHint?: string;
|
|
166
|
+
}): Promise<void>;
|
|
164
167
|
/** Finish the flow at the callback path. */
|
|
165
168
|
completeSignIn(search?: string): Promise<SignInOutcome>;
|
|
166
169
|
/**
|
package/dist/browser.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createNamespaceBrowserAuth } from './namespace-browser.js';
|
|
2
|
+
import { safeLoginHint } from './redirect.js';
|
|
3
|
+
import { base64url, decodeJwtPayload, timeoutSignal } from './util.js';
|
|
2
4
|
const TOKEN = 'wtfalch.auth.token';
|
|
3
5
|
const VERIFIER = 'wtfalch.auth.verifier';
|
|
4
6
|
/** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
|
|
@@ -36,12 +38,6 @@ const defaultStore = {
|
|
|
36
38
|
}
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
|
-
function base64url(bytes) {
|
|
40
|
-
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
41
|
-
.replace(/\+/g, '-')
|
|
42
|
-
.replace(/\//g, '_')
|
|
43
|
-
.replace(/=+$/, '');
|
|
44
|
-
}
|
|
45
41
|
async function challengeFor(verifier) {
|
|
46
42
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
47
43
|
return base64url(digest);
|
|
@@ -52,22 +48,6 @@ function verifier() {
|
|
|
52
48
|
// too -- a random, unguessable token is exactly what each of the three is.
|
|
53
49
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
54
50
|
}
|
|
55
|
-
/** The payload of a JWT, read without verifying its signature -- the ID token here comes straight
|
|
56
|
-
* from the token endpoint over TLS, which OIDC Core 1.0 3.1.3.7 allows in place of a signature
|
|
57
|
-
* check for the authorization code flow. Never used to trust a claim from anywhere else. */
|
|
58
|
-
function decodeJwtPayload(jwt) {
|
|
59
|
-
try {
|
|
60
|
-
const part = jwt.split('.')[1];
|
|
61
|
-
if (!part)
|
|
62
|
-
return null;
|
|
63
|
-
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
64
|
-
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
65
|
-
return JSON.parse(atob(padded));
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
51
|
function publicSession(session) {
|
|
72
52
|
const { refreshToken, ...rest } = session;
|
|
73
53
|
return rest;
|
|
@@ -137,7 +117,7 @@ export function createBrowserAuth(options) {
|
|
|
137
117
|
error: 'This refresh was cancelled',
|
|
138
118
|
});
|
|
139
119
|
const open = options.open ?? ((url) => window.location.assign(url));
|
|
140
|
-
const authorize = async (prompt, returnTo) => {
|
|
120
|
+
const authorize = async (prompt, returnTo, loginHint) => {
|
|
141
121
|
const generation = ++coordinator.generation;
|
|
142
122
|
const proof = verifier();
|
|
143
123
|
const state = verifier();
|
|
@@ -175,6 +155,9 @@ export function createBrowserAuth(options) {
|
|
|
175
155
|
url.searchParams.set('nonce', nonce);
|
|
176
156
|
if (prompt)
|
|
177
157
|
url.searchParams.set('prompt', prompt);
|
|
158
|
+
const hint = safeLoginHint(loginHint);
|
|
159
|
+
if (hint)
|
|
160
|
+
url.searchParams.set('login_hint', hint);
|
|
178
161
|
if (!current(generation))
|
|
179
162
|
return false;
|
|
180
163
|
await open(url.toString());
|
|
@@ -208,6 +191,7 @@ export function createBrowserAuth(options) {
|
|
|
208
191
|
return { kind: 'no-refresh-token' };
|
|
209
192
|
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
210
193
|
method: 'POST',
|
|
194
|
+
signal: timeoutSignal(),
|
|
211
195
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
212
196
|
body: new URLSearchParams({
|
|
213
197
|
grant_type: 'refresh_token',
|
|
@@ -219,10 +203,13 @@ export function createBrowserAuth(options) {
|
|
|
219
203
|
return cancelledRefresh();
|
|
220
204
|
if (!response.ok) {
|
|
221
205
|
const body = await response.text();
|
|
206
|
+
// A 5xx or 429 says the issuer is struggling, not that the grant is gone: keep the session for the next try.
|
|
207
|
+
const definitive = response.status >= 400 && response.status < 500 && response.status !== 429;
|
|
222
208
|
return mutate(async () => {
|
|
223
209
|
if (!current(generation))
|
|
224
210
|
return cancelledRefresh();
|
|
225
|
-
|
|
211
|
+
if (definitive)
|
|
212
|
+
await store.drop(tokenKey);
|
|
226
213
|
if (!current(generation))
|
|
227
214
|
return cancelledRefresh();
|
|
228
215
|
return {
|
|
@@ -261,8 +248,8 @@ export function createBrowserAuth(options) {
|
|
|
261
248
|
return false;
|
|
262
249
|
return authorize('none', returnTo);
|
|
263
250
|
},
|
|
264
|
-
async signIn(returnTo = window.location.pathname) {
|
|
265
|
-
await authorize(undefined, returnTo);
|
|
251
|
+
async signIn(returnTo = window.location.pathname, opts) {
|
|
252
|
+
await authorize(undefined, returnTo, opts?.loginHint);
|
|
266
253
|
},
|
|
267
254
|
async completeSignIn(search = window.location.search) {
|
|
268
255
|
const generation = coordinator.generation;
|
|
@@ -318,6 +305,7 @@ export function createBrowserAuth(options) {
|
|
|
318
305
|
};
|
|
319
306
|
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
320
307
|
method: 'POST',
|
|
308
|
+
signal: timeoutSignal(),
|
|
321
309
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
322
310
|
body: new URLSearchParams({
|
|
323
311
|
grant_type: 'authorization_code',
|
package/dist/config.d.ts
CHANGED
|
@@ -8,9 +8,12 @@ import type { SessionStore } from './sessions.js';
|
|
|
8
8
|
* to offer there; `invite` calls the broker's own endpoint, which returns
|
|
9
9
|
* one.
|
|
10
10
|
*/
|
|
11
|
-
export type AuthEvent =
|
|
11
|
+
export type AuthEvent =
|
|
12
|
+
/** A session was established, by any method: password, passkey, link, reset, QR or the hosted login. `email` is empty when the id token carries none. */
|
|
13
|
+
{
|
|
12
14
|
type: 'signIn';
|
|
13
15
|
email: string;
|
|
16
|
+
userId: string;
|
|
14
17
|
} | {
|
|
15
18
|
type: 'signUp';
|
|
16
19
|
email: string;
|
|
@@ -93,6 +96,15 @@ export interface AuthOptions {
|
|
|
93
96
|
cookieSecret?: string | undefined;
|
|
94
97
|
/** e.g. https://auth.example.com. Required -- there is no estate-wide default. */
|
|
95
98
|
issuer: string | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Where the hosted login lives, when it is not at the issuer's origin: an
|
|
101
|
+
* app that proxies the login under its own address passes e.g.
|
|
102
|
+
* `https://agora.example.com/sign-in`, and the hosted login URL becomes
|
|
103
|
+
* `https://agora.example.com/sign-in/login`. https, or http for localhost
|
|
104
|
+
* only; no query, fragment or credentials. Default: the issuer's origin,
|
|
105
|
+
* which is what the SDK has always used.
|
|
106
|
+
*/
|
|
107
|
+
signInBase?: string;
|
|
96
108
|
/** Where the routes are mounted. Default /auth. */
|
|
97
109
|
basePath?: string;
|
|
98
110
|
/** Default /. */
|
|
@@ -147,6 +159,8 @@ export interface ResolvedOptions {
|
|
|
147
159
|
*/
|
|
148
160
|
readonly cookieKey: Uint8Array;
|
|
149
161
|
issuer: string;
|
|
162
|
+
/** Absolute, always ends with "/": `new URL('login', signInBase)` is the hosted login. */
|
|
163
|
+
signInBase: string;
|
|
150
164
|
basePath: string;
|
|
151
165
|
afterLogin: string;
|
|
152
166
|
afterLogout: string;
|
package/dist/config.js
CHANGED
|
@@ -47,6 +47,7 @@ export function resolveOptions(options) {
|
|
|
47
47
|
return cookieKey;
|
|
48
48
|
},
|
|
49
49
|
issuer,
|
|
50
|
+
signInBase: parseSignInBase(options.signInBase, issuer),
|
|
50
51
|
basePath,
|
|
51
52
|
afterLogin: options.afterLogin ?? '/',
|
|
52
53
|
afterLogout: options.afterLogout ?? '/',
|
|
@@ -91,6 +92,25 @@ function parseAppUrl(raw) {
|
|
|
91
92
|
}
|
|
92
93
|
return url;
|
|
93
94
|
}
|
|
95
|
+
function parseSignInBase(raw, issuer) {
|
|
96
|
+
if (raw === undefined)
|
|
97
|
+
return `${new URL(issuer).origin}/`;
|
|
98
|
+
let url;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(raw);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
throw new Error(`@wtfalch/auth: signInBase must be an absolute URL, got "${raw}"`);
|
|
104
|
+
}
|
|
105
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
106
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
|
|
107
|
+
throw new Error(`@wtfalch/auth: signInBase must be https (http only for localhost), got "${raw}"`);
|
|
108
|
+
}
|
|
109
|
+
if (raw.includes('?') || raw.includes('#') || url.username || url.password) {
|
|
110
|
+
throw new Error(`@wtfalch/auth: signInBase takes no query, fragment or credentials, got "${raw}"`);
|
|
111
|
+
}
|
|
112
|
+
return url.href.endsWith('/') ? url.href : `${url.href}/`;
|
|
113
|
+
}
|
|
94
114
|
function decodeKey(secret) {
|
|
95
115
|
required(secret, 'cookieSecret');
|
|
96
116
|
let bytes = new Uint8Array(0);
|