@wtfalch/auth 0.24.0 → 0.26.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 +6 -4
- package/dist/auth.js +58 -97
- package/dist/broker.js +44 -13
- package/dist/browser.d.ts +3 -2
- package/dist/browser.js +7 -23
- package/dist/config.d.ts +4 -1
- package/dist/cookies.d.ts +1 -2
- package/dist/cookies.js +3 -7
- package/dist/index.d.ts +2 -2
- package/dist/mcp.d.ts +30 -0
- package/dist/mcp.js +55 -0
- package/dist/namespace-browser.js +1 -6
- package/dist/namespace-session.d.ts +1 -5
- package/dist/namespace-session.js +11 -27
- package/dist/next.d.ts +1 -1
- package/dist/next.js +33 -14
- package/dist/oidc.d.ts +2 -0
- package/dist/oidc.js +14 -0
- package/dist/person-token.d.ts +9 -0
- package/dist/person-token.js +13 -2
- package/dist/qr.js +3 -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/dist/worker-session.d.ts +44 -0
- package/dist/worker-session.js +106 -0
- package/package.json +11 -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;
|
|
@@ -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
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
|
|
@@ -268,9 +278,7 @@ export function createAuth(input) {
|
|
|
268
278
|
// `sessions.end` reaches the store, which can be briefly unreachable --
|
|
269
279
|
// and a sign-out that 500s over that leaves the browser's own cookie
|
|
270
280
|
// standing, which is worse than the record surviving a moment longer.
|
|
271
|
-
|
|
272
|
-
if (status === 'changed')
|
|
273
|
-
return new Response('Account changed', { status: 409 });
|
|
281
|
+
await sessions.end(value).catch(() => { });
|
|
274
282
|
return endIssuerSession(idToken, [clearSession(options())]);
|
|
275
283
|
}
|
|
276
284
|
const session = await openSession(options(), value);
|
|
@@ -335,8 +343,6 @@ export function createAuth(input) {
|
|
|
335
343
|
const result = await sessions.read(value);
|
|
336
344
|
if (result.kind === 'user')
|
|
337
345
|
return { user: boundUser(result.claims), cookies: [] };
|
|
338
|
-
if (result.kind === 'changed')
|
|
339
|
-
return { user: null, cookies: [], accountChanged: true };
|
|
340
346
|
if (result.kind === 'unavailable')
|
|
341
347
|
return { user: null, cookies: [], unavailable: true };
|
|
342
348
|
return { user: null, cookies: [] };
|
|
@@ -389,11 +395,9 @@ export function createAuth(input) {
|
|
|
389
395
|
const isPublic = url.pathname === options().basePath ||
|
|
390
396
|
url.pathname.startsWith(`${options().basePath}/`) ||
|
|
391
397
|
matches(rules.public, url.pathname);
|
|
392
|
-
const { user, cookies,
|
|
398
|
+
const { user, cookies, unavailable } = await read(request);
|
|
393
399
|
if (user || isPublic)
|
|
394
400
|
return { kind: 'next', user, cookies };
|
|
395
|
-
if (accountChanged)
|
|
396
|
-
return { kind: 'deny', cookies, reason: 'account_changed' };
|
|
397
401
|
if (unavailable)
|
|
398
402
|
return { kind: 'deny', cookies, reason: 'unavailable' };
|
|
399
403
|
if (!isNavigation(request))
|
|
@@ -411,13 +415,11 @@ export function createAuth(input) {
|
|
|
411
415
|
/**
|
|
412
416
|
* The person signed in on `request`, for the passkey routes -- never a
|
|
413
417
|
* stranger. Adapts `requireUser`'s reasons for a caller that throws rather
|
|
414
|
-
* than redirects: `
|
|
418
|
+
* than redirects: `unavailable` is read's own, and
|
|
415
419
|
* plain absence becomes `unauthenticated`, which read has no reason for.
|
|
416
420
|
*/
|
|
417
421
|
const currentUser = async (request) => {
|
|
418
|
-
const { user,
|
|
419
|
-
if (accountChanged)
|
|
420
|
-
throw new AuthError('account_changed', 'Choose an account to continue');
|
|
422
|
+
const { user, unavailable } = await read(request, { refresh: false });
|
|
421
423
|
if (unavailable)
|
|
422
424
|
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
423
425
|
if (!user)
|
|
@@ -484,8 +486,13 @@ export function createAuth(input) {
|
|
|
484
486
|
const nonce = client.randomNonce();
|
|
485
487
|
const verifier = client.randomPKCECodeVerifier();
|
|
486
488
|
const target = await oidc().authorizationUrl(state, nonce, verifier);
|
|
487
|
-
const response = await options().fetch(target.href, {
|
|
489
|
+
const response = await options().fetch(target.href, {
|
|
490
|
+
redirect: 'manual',
|
|
491
|
+
signal: timeoutSignal(),
|
|
492
|
+
});
|
|
488
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);
|
|
489
496
|
if (options().namespace &&
|
|
490
497
|
location &&
|
|
491
498
|
new URL(location, options().issuer).origin !== options().namespace?.loginOrigin)
|
|
@@ -542,7 +549,13 @@ export function createAuth(input) {
|
|
|
542
549
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
543
550
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
544
551
|
const invite = async (input) => {
|
|
545
|
-
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
|
+
});
|
|
546
559
|
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
547
560
|
return result;
|
|
548
561
|
};
|
|
@@ -618,14 +631,11 @@ export function createAuth(input) {
|
|
|
618
631
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
619
632
|
try {
|
|
620
633
|
const redirectTo = await broker().signIn({ authRequestId, email, password });
|
|
621
|
-
emit({ type: 'signIn', email });
|
|
622
634
|
return { ok: true, redirectTo };
|
|
623
635
|
}
|
|
624
636
|
catch (error) {
|
|
625
|
-
if (needsHostedLogin(error))
|
|
626
|
-
emit({ type: 'signIn', email });
|
|
637
|
+
if (needsHostedLogin(error))
|
|
627
638
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
628
|
-
}
|
|
629
639
|
const change = changeRequired(error);
|
|
630
640
|
if (change)
|
|
631
641
|
return change;
|
|
@@ -664,7 +674,6 @@ export function createAuth(input) {
|
|
|
664
674
|
try {
|
|
665
675
|
const flow = await serverFlow();
|
|
666
676
|
const completed = await finishOrHandoff(() => broker().signIn({ authRequestId: flow.id, email, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
667
|
-
emit({ type: 'signIn', email });
|
|
668
677
|
return { ok: true, ...completed };
|
|
669
678
|
}
|
|
670
679
|
catch (error) {
|
|
@@ -690,26 +699,7 @@ export function createAuth(input) {
|
|
|
690
699
|
return { ok: true, ...completed };
|
|
691
700
|
}
|
|
692
701
|
catch (error) {
|
|
693
|
-
|
|
694
|
-
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
695
|
-
}
|
|
696
|
-
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
697
|
-
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
698
|
-
}
|
|
699
|
-
// Both carry a sentence about the name, for the form to show as it is.
|
|
700
|
-
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
701
|
-
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
702
|
-
}
|
|
703
|
-
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
704
|
-
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
705
|
-
}
|
|
706
|
-
if (error instanceof BrokerError && error.error === 'request') {
|
|
707
|
-
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
708
|
-
}
|
|
709
|
-
if (error instanceof BrokerError && error.status < 500) {
|
|
710
|
-
return { ok: false, error: 'invalid', message: error.detail ?? error.error };
|
|
711
|
-
}
|
|
712
|
-
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
702
|
+
return signUpFailure(error);
|
|
713
703
|
}
|
|
714
704
|
};
|
|
715
705
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
@@ -726,26 +716,7 @@ export function createAuth(input) {
|
|
|
726
716
|
const change = changeRequired(error);
|
|
727
717
|
if (change)
|
|
728
718
|
return { ...change, message: 'choose a new password to continue' };
|
|
729
|
-
|
|
730
|
-
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
731
|
-
}
|
|
732
|
-
if (error instanceof BrokerError && error.error === 'registration_closed') {
|
|
733
|
-
return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
|
|
734
|
-
}
|
|
735
|
-
// Both carry a sentence about the name, for the form to show as it is.
|
|
736
|
-
if (error instanceof BrokerError && error.error === 'username_taken') {
|
|
737
|
-
return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
|
|
738
|
-
}
|
|
739
|
-
if (error instanceof BrokerError && error.error === 'username_invalid') {
|
|
740
|
-
return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
|
|
741
|
-
}
|
|
742
|
-
if (error instanceof BrokerError && error.error === 'request') {
|
|
743
|
-
return { ok: false, error: 'request', message: error.detail ?? error.error };
|
|
744
|
-
}
|
|
745
|
-
if (error instanceof BrokerError && error.status < 500) {
|
|
746
|
-
return { ok: false, error: 'invalid', message: error.detail ?? error.error };
|
|
747
|
-
}
|
|
748
|
-
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
719
|
+
return signUpFailure(error);
|
|
749
720
|
}
|
|
750
721
|
};
|
|
751
722
|
const startPasskeyRegistration = async (request) => {
|
|
@@ -806,7 +777,11 @@ export function createAuth(input) {
|
|
|
806
777
|
const changePassword = async (request, input) => {
|
|
807
778
|
const user = await currentFreshUser(request);
|
|
808
779
|
try {
|
|
809
|
-
await broker().changePassword({
|
|
780
|
+
await broker().changePassword({
|
|
781
|
+
userId: user.id,
|
|
782
|
+
currentPassword: input.currentPassword,
|
|
783
|
+
newPassword: input.newPassword,
|
|
784
|
+
});
|
|
810
785
|
return { ok: true };
|
|
811
786
|
}
|
|
812
787
|
catch (error) {
|
|
@@ -862,7 +837,7 @@ export function createAuth(input) {
|
|
|
862
837
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
863
838
|
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
864
839
|
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
|
|
865
|
-
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode
|
|
840
|
+
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode }, device.expiresIn);
|
|
866
841
|
return {
|
|
867
842
|
url: qrVerificationUrl(options(), device.verificationUriComplete),
|
|
868
843
|
userCode: device.userCode,
|
|
@@ -1032,28 +1007,6 @@ function matches(rule, pathname) {
|
|
|
1032
1007
|
return pathname === entry;
|
|
1033
1008
|
});
|
|
1034
1009
|
}
|
|
1035
|
-
function expiryOf(idToken) {
|
|
1036
|
-
try {
|
|
1037
|
-
const [, payload] = idToken.split('.');
|
|
1038
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
1039
|
-
return typeof json.exp === 'number' ? json.exp : null;
|
|
1040
|
-
}
|
|
1041
|
-
catch {
|
|
1042
|
-
return null;
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
// Age, not time left: a disabled or revoked account must not ride out a
|
|
1046
|
-
// long-lived token just because it still has plenty of time on the clock.
|
|
1047
|
-
function issuedAtOf(idToken) {
|
|
1048
|
-
try {
|
|
1049
|
-
const [, payload] = idToken.split('.');
|
|
1050
|
-
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
1051
|
-
return typeof json.iat === 'number' ? json.iat : null;
|
|
1052
|
-
}
|
|
1053
|
-
catch {
|
|
1054
|
-
return null;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
1010
|
const PROMPTS = ['select_account', 'login'];
|
|
1058
1011
|
function promptFrom(raw) {
|
|
1059
1012
|
return PROMPTS.includes(raw ?? '') ? raw : undefined;
|
|
@@ -1117,15 +1070,6 @@ function userFrom(claims) {
|
|
|
1117
1070
|
};
|
|
1118
1071
|
}
|
|
1119
1072
|
// The token itself is bad, as opposed to the keys being unreachable or rotating.
|
|
1120
|
-
function rejected(error) {
|
|
1121
|
-
return (error instanceof AuthError ||
|
|
1122
|
-
error instanceof errors.JWTExpired ||
|
|
1123
|
-
error instanceof errors.JWTClaimValidationFailed ||
|
|
1124
|
-
error instanceof errors.JWSSignatureVerificationFailed ||
|
|
1125
|
-
error instanceof errors.JWSInvalid ||
|
|
1126
|
-
error instanceof errors.JWTInvalid ||
|
|
1127
|
-
error instanceof errors.JOSEAlgNotAllowed);
|
|
1128
|
-
}
|
|
1129
1073
|
function isNavigation(request) {
|
|
1130
1074
|
const mode = request.headers.get('sec-fetch-mode');
|
|
1131
1075
|
if (mode && mode !== 'navigate')
|
|
@@ -1179,6 +1123,23 @@ function signInError(error) {
|
|
|
1179
1123
|
return 'invalid_credentials';
|
|
1180
1124
|
return 'unavailable';
|
|
1181
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
|
+
}
|
|
1182
1143
|
function reasonOf(error) {
|
|
1183
1144
|
if (error instanceof AuthError)
|
|
1184
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
|
| {
|
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createNamespaceBrowserAuth } from './namespace-browser.js';
|
|
2
2
|
import { safeLoginHint } from './redirect.js';
|
|
3
|
+
import { base64url, decodeJwtPayload, timeoutSignal } from './util.js';
|
|
3
4
|
const TOKEN = 'wtfalch.auth.token';
|
|
4
5
|
const VERIFIER = 'wtfalch.auth.verifier';
|
|
5
6
|
/** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
|
|
@@ -37,12 +38,6 @@ const defaultStore = {
|
|
|
37
38
|
}
|
|
38
39
|
},
|
|
39
40
|
};
|
|
40
|
-
function base64url(bytes) {
|
|
41
|
-
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
42
|
-
.replace(/\+/g, '-')
|
|
43
|
-
.replace(/\//g, '_')
|
|
44
|
-
.replace(/=+$/, '');
|
|
45
|
-
}
|
|
46
41
|
async function challengeFor(verifier) {
|
|
47
42
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
48
43
|
return base64url(digest);
|
|
@@ -53,22 +48,6 @@ function verifier() {
|
|
|
53
48
|
// too -- a random, unguessable token is exactly what each of the three is.
|
|
54
49
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
55
50
|
}
|
|
56
|
-
/** The payload of a JWT, read without verifying its signature -- the ID token here comes straight
|
|
57
|
-
* from the token endpoint over TLS, which OIDC Core 1.0 3.1.3.7 allows in place of a signature
|
|
58
|
-
* check for the authorization code flow. Never used to trust a claim from anywhere else. */
|
|
59
|
-
function decodeJwtPayload(jwt) {
|
|
60
|
-
try {
|
|
61
|
-
const part = jwt.split('.')[1];
|
|
62
|
-
if (!part)
|
|
63
|
-
return null;
|
|
64
|
-
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
65
|
-
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
66
|
-
return JSON.parse(atob(padded));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
51
|
function publicSession(session) {
|
|
73
52
|
const { refreshToken, ...rest } = session;
|
|
74
53
|
return rest;
|
|
@@ -212,6 +191,7 @@ export function createBrowserAuth(options) {
|
|
|
212
191
|
return { kind: 'no-refresh-token' };
|
|
213
192
|
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
214
193
|
method: 'POST',
|
|
194
|
+
signal: timeoutSignal(),
|
|
215
195
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
216
196
|
body: new URLSearchParams({
|
|
217
197
|
grant_type: 'refresh_token',
|
|
@@ -223,10 +203,13 @@ export function createBrowserAuth(options) {
|
|
|
223
203
|
return cancelledRefresh();
|
|
224
204
|
if (!response.ok) {
|
|
225
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;
|
|
226
208
|
return mutate(async () => {
|
|
227
209
|
if (!current(generation))
|
|
228
210
|
return cancelledRefresh();
|
|
229
|
-
|
|
211
|
+
if (definitive)
|
|
212
|
+
await store.drop(tokenKey);
|
|
230
213
|
if (!current(generation))
|
|
231
214
|
return cancelledRefresh();
|
|
232
215
|
return {
|
|
@@ -322,6 +305,7 @@ export function createBrowserAuth(options) {
|
|
|
322
305
|
};
|
|
323
306
|
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
324
307
|
method: 'POST',
|
|
308
|
+
signal: timeoutSignal(),
|
|
325
309
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
326
310
|
body: new URLSearchParams({
|
|
327
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;
|
package/dist/cookies.d.ts
CHANGED
|
@@ -32,10 +32,9 @@ export interface LinkPayload extends JWTPayload {
|
|
|
32
32
|
stk: string;
|
|
33
33
|
ar: string;
|
|
34
34
|
}
|
|
35
|
-
/** The QR device-grant transaction: the device_code, never handed to the caller, and
|
|
35
|
+
/** The QR device-grant transaction: the device_code, never handed to the caller, and nothing else: the issuer answers `slow_down` to a client that polls too fast, and `pollQrSignIn` passes that on. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
|
|
36
36
|
export interface QrTransactionPayload extends JWTPayload {
|
|
37
37
|
dc: string;
|
|
38
|
-
iv: number;
|
|
39
38
|
}
|
|
40
39
|
export interface SetCookie {
|
|
41
40
|
name: string;
|
package/dist/cookies.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EncryptJWT, jwtDecrypt } from 'jose';
|
|
2
2
|
import { deriveCookieKey } from './config.js';
|
|
3
3
|
import { AuthError } from './oidc.js';
|
|
4
|
+
import { base64url } from './util.js';
|
|
4
5
|
// `options.cookieKey` decodes once per `ResolvedOptions` (config.ts) and is
|
|
5
6
|
// always the same `Uint8Array` reference after that, so keying the derived
|
|
6
7
|
// key's own cache off it means every seal/open after the first reuses it
|
|
@@ -78,10 +79,7 @@ export async function contextDigest(options) {
|
|
|
78
79
|
/** SHA-256, base64url. */
|
|
79
80
|
export async function digest(value) {
|
|
80
81
|
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
81
|
-
return
|
|
82
|
-
.replaceAll('+', '-')
|
|
83
|
-
.replaceAll('/', '_')
|
|
84
|
-
.replace(/=+$/, '');
|
|
82
|
+
return base64url(bytes);
|
|
85
83
|
}
|
|
86
84
|
export async function sealTransaction(options, payload) {
|
|
87
85
|
const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
|
|
@@ -125,9 +123,7 @@ export async function sealQrTransaction(options, payload, ttl) {
|
|
|
125
123
|
}
|
|
126
124
|
export async function openQrTransaction(options, value) {
|
|
127
125
|
const payload = await open(options, 'qr', value);
|
|
128
|
-
return payload && typeof payload.dc === 'string'
|
|
129
|
-
? payload
|
|
130
|
-
: null;
|
|
126
|
+
return payload && typeof payload.dc === 'string' ? payload : null;
|
|
131
127
|
}
|
|
132
128
|
export function clearQrTransaction(options) {
|
|
133
129
|
return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type ChangePasswordError, type ChangePasswordResult, type Completed, type DeletePasskeyResult, type EmailChangeError, type EmailChangeResult, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
2
2
|
export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
5
5
|
export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
|
|
6
|
-
export { BrokerError } from './broker.js';
|
|
6
|
+
export { BrokerError, type PeoplePage, type Person } from './broker.js';
|
|
7
7
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
8
8
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource side of MCP authorization for an org app (ADR 0002, section 4).
|
|
3
|
+
* Only `jose` and `fetch`: this entry runs on workerd, so nothing here may
|
|
4
|
+
* reach a `node:` module.
|
|
5
|
+
*/
|
|
6
|
+
export interface McpOptions {
|
|
7
|
+
/** The bound login host, e.g. `https://login.candorfx.com`. */
|
|
8
|
+
issuer: string;
|
|
9
|
+
/** The ZITADEL project id of the MCP project (env `MCP_AUDIENCE`). */
|
|
10
|
+
audience: string;
|
|
11
|
+
/** The MCP URL clients send as `resource` (env `MCP_RESOURCE`). */
|
|
12
|
+
resource: string;
|
|
13
|
+
/** Test seam. */
|
|
14
|
+
fetch?: typeof fetch;
|
|
15
|
+
}
|
|
16
|
+
export interface McpTokenClaims {
|
|
17
|
+
userId: string;
|
|
18
|
+
}
|
|
19
|
+
/** Claims for a valid bearer token for this resource, otherwise null. Never
|
|
20
|
+
* throws on a bad token; says nothing about why. */
|
|
21
|
+
export declare function verifyMcpToken(req: Request, opts: McpOptions): Promise<McpTokenClaims | null>;
|
|
22
|
+
/** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
|
|
23
|
+
* the user may not do the thing (403). */
|
|
24
|
+
export declare function mcpChallenge(opts: McpOptions, status: 401 | 403): Response;
|
|
25
|
+
/** The document the app serves at `/.well-known/oauth-protected-resource` (and
|
|
26
|
+
* the path form). */
|
|
27
|
+
export declare function protectedResourceMetadata(opts: McpOptions): {
|
|
28
|
+
resource: string;
|
|
29
|
+
authorization_servers: string[];
|
|
30
|
+
};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createPersonTokenCore } from './person-token.js';
|
|
2
|
+
/** One person-token core per (fetch, issuer, audience), so discovery and the
|
|
3
|
+
* JWKS are fetched once and cached, not once per request. Weakly keyed by the
|
|
4
|
+
* fetch function so a test seam never leaks across tests. */
|
|
5
|
+
const cores = new WeakMap();
|
|
6
|
+
function coreFor(opts) {
|
|
7
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
8
|
+
let byKey = cores.get(fetchImpl);
|
|
9
|
+
if (!byKey) {
|
|
10
|
+
byKey = new Map();
|
|
11
|
+
cores.set(fetchImpl, byKey);
|
|
12
|
+
}
|
|
13
|
+
const key = JSON.stringify([opts.issuer, opts.audience]);
|
|
14
|
+
let core = byKey.get(key);
|
|
15
|
+
if (!core) {
|
|
16
|
+
core = createPersonTokenCore({
|
|
17
|
+
issuer: opts.issuer,
|
|
18
|
+
audience: opts.audience,
|
|
19
|
+
fetch: opts.fetch,
|
|
20
|
+
});
|
|
21
|
+
byKey.set(key, core);
|
|
22
|
+
}
|
|
23
|
+
return core;
|
|
24
|
+
}
|
|
25
|
+
const BEARER = /^Bearer ([^\s]+)$/i;
|
|
26
|
+
/** Claims for a valid bearer token for this resource, otherwise null. Never
|
|
27
|
+
* throws on a bad token; says nothing about why. */
|
|
28
|
+
export async function verifyMcpToken(req, opts) {
|
|
29
|
+
const match = BEARER.exec(req.headers.get('authorization') ?? '');
|
|
30
|
+
if (!match?.[1])
|
|
31
|
+
return null;
|
|
32
|
+
const payload = await coreFor(opts).verifyJwtClaims(match[1]);
|
|
33
|
+
if (!payload || typeof payload.sub !== 'string' || !payload.sub)
|
|
34
|
+
return null;
|
|
35
|
+
return { userId: payload.sub };
|
|
36
|
+
}
|
|
37
|
+
function metadataUrl(opts) {
|
|
38
|
+
const url = new URL(opts.resource);
|
|
39
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
40
|
+
return `${url.origin}/.well-known/oauth-protected-resource${path}`;
|
|
41
|
+
}
|
|
42
|
+
/** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
|
|
43
|
+
* the user may not do the thing (403). */
|
|
44
|
+
export function mcpChallenge(opts, status) {
|
|
45
|
+
const resourceMetadata = `resource_metadata="${metadataUrl(opts)}"`;
|
|
46
|
+
const challenge = status === 403
|
|
47
|
+
? `Bearer error="insufficient_scope", ${resourceMetadata}`
|
|
48
|
+
: `Bearer ${resourceMetadata}`;
|
|
49
|
+
return new Response(null, { status, headers: { 'WWW-Authenticate': challenge } });
|
|
50
|
+
}
|
|
51
|
+
/** The document the app serves at `/.well-known/oauth-protected-resource` (and
|
|
52
|
+
* the path form). */
|
|
53
|
+
export function protectedResourceMetadata(opts) {
|
|
54
|
+
return { resource: opts.resource, authorization_servers: [opts.issuer] };
|
|
55
|
+
}
|