@wtfalch/auth 0.33.0 → 0.34.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 +68 -0
- package/dist/auth.js +188 -2
- package/dist/broker.d.ts +24 -1
- package/dist/broker.js +23 -2
- package/dist/cookies.d.ts +4 -0
- package/dist/fresh.d.ts +14 -0
- package/dist/fresh.js +21 -0
- package/dist/next.d.ts +74 -1
- package/dist/next.js +91 -0
- package/package.json +5 -1
package/dist/auth.d.ts
CHANGED
|
@@ -151,6 +151,45 @@ export type PasskeySignInStart = {
|
|
|
151
151
|
ok: false;
|
|
152
152
|
error: SignInError;
|
|
153
153
|
};
|
|
154
|
+
/** What a fresh sign-in step needs the caller to do. `cookies` are always to be set, success or not. */
|
|
155
|
+
export type FreshOutcome = {
|
|
156
|
+
ok: true;
|
|
157
|
+
cookies: SetCookie[];
|
|
158
|
+
} | {
|
|
159
|
+
ok: false;
|
|
160
|
+
error: SignInError | 'other_person' | 'invalid_code';
|
|
161
|
+
cookies: SetCookie[];
|
|
162
|
+
} | {
|
|
163
|
+
ok: false;
|
|
164
|
+
secondFactor: {
|
|
165
|
+
token: string;
|
|
166
|
+
methods: 'totp'[];
|
|
167
|
+
};
|
|
168
|
+
cookies: SetCookie[];
|
|
169
|
+
} | {
|
|
170
|
+
ok: false;
|
|
171
|
+
hosted: true;
|
|
172
|
+
next?: string;
|
|
173
|
+
cookies: SetCookie[];
|
|
174
|
+
};
|
|
175
|
+
export type FreshStart = {
|
|
176
|
+
ok: true;
|
|
177
|
+
authRequestId: string;
|
|
178
|
+
loginHint: string;
|
|
179
|
+
passkey: boolean;
|
|
180
|
+
cookies: SetCookie[];
|
|
181
|
+
} | {
|
|
182
|
+
ok: false;
|
|
183
|
+
hosted: true;
|
|
184
|
+
cookies: SetCookie[];
|
|
185
|
+
};
|
|
186
|
+
export type FreshPasskeyStart = {
|
|
187
|
+
ok: true;
|
|
188
|
+
options: unknown;
|
|
189
|
+
cookies: SetCookie[];
|
|
190
|
+
} | Exclude<FreshOutcome, {
|
|
191
|
+
ok: true;
|
|
192
|
+
}>;
|
|
154
193
|
export interface Completed {
|
|
155
194
|
location: string;
|
|
156
195
|
cookies: SetCookie[];
|
|
@@ -425,6 +464,35 @@ export interface Auth {
|
|
|
425
464
|
endSession(request: Request, key: string): Promise<void>;
|
|
426
465
|
/** The auth request the issuer sent the browser here with. Refuses one for another application. */
|
|
427
466
|
authRequest(id: string): Promise<AuthRequest>;
|
|
467
|
+
/**
|
|
468
|
+
* Starts a `prompt=login` auth request for whoever is signed in on `request`
|
|
469
|
+
* and seals its PKCE transaction in a cookie. Nothing redirects. `hosted`
|
|
470
|
+
* means the modal cannot be used: nobody is signed in, or the issuer did not
|
|
471
|
+
* start a request. The other four calls take the same `request` and the
|
|
472
|
+
* `authRequestId` this returned.
|
|
473
|
+
*/
|
|
474
|
+
startFreshSignIn(request: Request, opts?: {
|
|
475
|
+
next?: string;
|
|
476
|
+
}): Promise<FreshStart>;
|
|
477
|
+
/** The password step. The email is the signed-in person's own, never an argument. */
|
|
478
|
+
finishFreshSignIn(request: Request, input: {
|
|
479
|
+
authRequestId: string;
|
|
480
|
+
password: string;
|
|
481
|
+
}): Promise<FreshOutcome>;
|
|
482
|
+
/** A passkey challenge for the signed-in person; `finishFreshPasskey` answers it. */
|
|
483
|
+
startFreshPasskey(request: Request, input: {
|
|
484
|
+
authRequestId: string;
|
|
485
|
+
}): Promise<FreshPasskeyStart>;
|
|
486
|
+
finishFreshPasskey(request: Request, input: {
|
|
487
|
+
authRequestId: string;
|
|
488
|
+
credential: unknown;
|
|
489
|
+
}): Promise<FreshOutcome>;
|
|
490
|
+
/** The code step after a `secondFactor` outcome. */
|
|
491
|
+
verifyFreshSecondFactor(request: Request, input: {
|
|
492
|
+
authRequestId: string;
|
|
493
|
+
token: string;
|
|
494
|
+
code: string;
|
|
495
|
+
}): Promise<FreshOutcome>;
|
|
428
496
|
signIn(input: {
|
|
429
497
|
authRequestId: string;
|
|
430
498
|
email: string;
|
package/dist/auth.js
CHANGED
|
@@ -547,13 +547,13 @@ export function createAuth(input) {
|
|
|
547
547
|
* An OIDC flow this app starts and finishes itself, for a person arriving
|
|
548
548
|
* from an email rather than from a browser the app sent to the issuer.
|
|
549
549
|
*/
|
|
550
|
-
const serverFlow = async () => {
|
|
550
|
+
const serverFlow = async (authorization = {}) => {
|
|
551
551
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
552
552
|
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
553
553
|
const state = client.randomState();
|
|
554
554
|
const nonce = client.randomNonce();
|
|
555
555
|
const verifier = client.randomPKCECodeVerifier();
|
|
556
|
-
const target = await oidc().authorizationUrl(state, nonce, verifier);
|
|
556
|
+
const target = await oidc().authorizationUrl(state, nonce, verifier, authorization);
|
|
557
557
|
const response = await options().fetch(target.href, {
|
|
558
558
|
redirect: 'manual',
|
|
559
559
|
signal: timeoutSignal(),
|
|
@@ -613,6 +613,187 @@ export function createAuth(input) {
|
|
|
613
613
|
};
|
|
614
614
|
}
|
|
615
615
|
};
|
|
616
|
+
/**
|
|
617
|
+
* The fresh sign-in is the modal's version of `/auth/start` + `/auth/callback`:
|
|
618
|
+
* the same PKCE transaction, started and finished on the server, so the page
|
|
619
|
+
* never navigates. The transaction cookie holds the auth request id (`ar`),
|
|
620
|
+
* and every step refuses an id that is not that one.
|
|
621
|
+
*/
|
|
622
|
+
const freshContext = async (request, authRequestId) => {
|
|
623
|
+
const { user } = await read(request, { refresh: false });
|
|
624
|
+
const transaction = await openTransaction(options(), cookieFrom(request.headers.get('cookie'), transactionCookieName(options())));
|
|
625
|
+
if (!user?.email)
|
|
626
|
+
return {
|
|
627
|
+
kind: 'refused',
|
|
628
|
+
outcome: { ok: false, hosted: true, next: transaction?.nx, cookies: [] },
|
|
629
|
+
};
|
|
630
|
+
if (!transaction || transaction.ar === undefined || transaction.ar !== authRequestId)
|
|
631
|
+
return { kind: 'refused', outcome: { ok: false, error: 'request', cookies: [] } };
|
|
632
|
+
return { kind: 'ready', user, email: user.email, transaction };
|
|
633
|
+
};
|
|
634
|
+
const startFreshSignIn = async (request, opts = {}) => {
|
|
635
|
+
const { user } = await read(request, { refresh: false });
|
|
636
|
+
const loginHint = user?.email ? safeLoginHint(user.email) : undefined;
|
|
637
|
+
if (!user || !loginHint)
|
|
638
|
+
return { ok: false, hosted: true, cookies: [] };
|
|
639
|
+
let flow;
|
|
640
|
+
try {
|
|
641
|
+
flow = await serverFlow({ prompt: 'login', loginHint });
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
console.error('@wtfalch/auth: could not start a fresh sign-in', error instanceof Error ? error.name : 'unknown');
|
|
645
|
+
return { ok: false, hosted: true, cookies: [] };
|
|
646
|
+
}
|
|
647
|
+
const cookie = await sealTransaction(options(), {
|
|
648
|
+
st: flow.state,
|
|
649
|
+
nc: flow.nonce,
|
|
650
|
+
cv: flow.verifier,
|
|
651
|
+
nx: safeNextPath(opts.next, options().afterLogin),
|
|
652
|
+
ar: flow.id,
|
|
653
|
+
});
|
|
654
|
+
let passkey = false;
|
|
655
|
+
try {
|
|
656
|
+
await broker().startPasskeySignIn({ authRequestId: flow.id, email: user.email });
|
|
657
|
+
passkey = true;
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (!(error instanceof BrokerError && error.status < 500)) {
|
|
661
|
+
console.error('@wtfalch/auth: could not check for a passkey', error instanceof Error ? error.name : 'unknown');
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return { ok: true, authRequestId: flow.id, loginHint, passkey, cookies: [cookie] };
|
|
665
|
+
};
|
|
666
|
+
/** Turns what the broker said into the outcome the modal acts on. */
|
|
667
|
+
const freshFailure = (error, cookies = [], next) => {
|
|
668
|
+
if (error instanceof BrokerError) {
|
|
669
|
+
if (needsHostedLogin(error) || error.error === 'password_change_required')
|
|
670
|
+
return { ok: false, hosted: true, next, cookies };
|
|
671
|
+
if (error.error === 'second_factor_required') {
|
|
672
|
+
if (error.secondFactor?.methods.includes('totp'))
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
secondFactor: { token: error.secondFactor.token, methods: ['totp'] },
|
|
676
|
+
cookies,
|
|
677
|
+
};
|
|
678
|
+
return { ok: false, hosted: true, next, cookies };
|
|
679
|
+
}
|
|
680
|
+
if (error.error === 'invalid_code')
|
|
681
|
+
return { ok: false, error: 'invalid_code', cookies };
|
|
682
|
+
if (error.error === 'invalid')
|
|
683
|
+
return { ok: false, error: 'request', cookies };
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
console.error('@wtfalch/auth: a fresh sign-in step failed', error instanceof Error ? error.name : 'unknown');
|
|
687
|
+
}
|
|
688
|
+
return { ok: false, error: signInError(error), cookies };
|
|
689
|
+
};
|
|
690
|
+
/**
|
|
691
|
+
* Exchanges the callback and, only when it is the same person, replaces the
|
|
692
|
+
* session. The check comes before the session is made, so another person's
|
|
693
|
+
* sign-in leaves no record behind in namespace mode either.
|
|
694
|
+
*/
|
|
695
|
+
const completeFresh = async (request, callbackUrl, user, transaction) => {
|
|
696
|
+
const cleared = clearTransaction(options());
|
|
697
|
+
if (!validCallback(callbackUrl))
|
|
698
|
+
return { ok: false, error: 'request', cookies: [cleared] };
|
|
699
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
700
|
+
return { ok: false, error: 'unavailable', cookies: [cleared] };
|
|
701
|
+
let tokens;
|
|
702
|
+
try {
|
|
703
|
+
tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
|
|
704
|
+
state: transaction.st,
|
|
705
|
+
nonce: transaction.nc,
|
|
706
|
+
codeVerifier: transaction.cv,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
console.error('@wtfalch/auth: the fresh sign-in could not be exchanged', error instanceof Error ? error.name : 'unknown');
|
|
711
|
+
return { ok: false, error: 'request', cookies: [cleared] };
|
|
712
|
+
}
|
|
713
|
+
if (String(tokens.claims.sub) !== user.id)
|
|
714
|
+
return { ok: false, error: 'other_person', cookies: [cleared] };
|
|
715
|
+
const session = await sessionCookie(tokens, request.headers);
|
|
716
|
+
await replacePreviousSession(request.headers.get('cookie'));
|
|
717
|
+
return { ok: true, cookies: [cleared, session] };
|
|
718
|
+
};
|
|
719
|
+
const finishFreshSignIn = async (request, input) => {
|
|
720
|
+
const context = await freshContext(request, input.authRequestId);
|
|
721
|
+
if (context.kind === 'refused')
|
|
722
|
+
return context.outcome;
|
|
723
|
+
let callbackUrl;
|
|
724
|
+
try {
|
|
725
|
+
callbackUrl = await broker().signIn({
|
|
726
|
+
authRequestId: input.authRequestId,
|
|
727
|
+
email: context.email,
|
|
728
|
+
password: input.password,
|
|
729
|
+
secondFactor: true,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
return freshFailure(error, [], context.transaction.nx);
|
|
734
|
+
}
|
|
735
|
+
return completeFresh(request, callbackUrl, context.user, context.transaction);
|
|
736
|
+
};
|
|
737
|
+
const startFreshPasskey = async (request, input) => {
|
|
738
|
+
const context = await freshContext(request, input.authRequestId);
|
|
739
|
+
if (context.kind === 'refused')
|
|
740
|
+
return context.outcome;
|
|
741
|
+
try {
|
|
742
|
+
const { sessionId, options: challenge } = await broker().startPasskeySignIn({
|
|
743
|
+
authRequestId: input.authRequestId,
|
|
744
|
+
email: context.email,
|
|
745
|
+
});
|
|
746
|
+
const cookie = await sealTransaction(options(), {
|
|
747
|
+
st: context.transaction.st,
|
|
748
|
+
nc: context.transaction.nc,
|
|
749
|
+
cv: context.transaction.cv,
|
|
750
|
+
nx: context.transaction.nx,
|
|
751
|
+
ar: input.authRequestId,
|
|
752
|
+
fp: sessionId,
|
|
753
|
+
});
|
|
754
|
+
return { ok: true, options: challenge, cookies: [cookie] };
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
return freshFailure(error, [], context.transaction.nx);
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
const finishFreshPasskey = async (request, input) => {
|
|
761
|
+
const context = await freshContext(request, input.authRequestId);
|
|
762
|
+
if (context.kind === 'refused')
|
|
763
|
+
return context.outcome;
|
|
764
|
+
if (!context.transaction.fp)
|
|
765
|
+
return { ok: false, error: 'request', cookies: [] };
|
|
766
|
+
let callbackUrl;
|
|
767
|
+
try {
|
|
768
|
+
callbackUrl = await broker().finishPasskeySignIn({
|
|
769
|
+
authRequestId: input.authRequestId,
|
|
770
|
+
sessionId: context.transaction.fp,
|
|
771
|
+
credential: input.credential,
|
|
772
|
+
secondFactor: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
return freshFailure(error, [], context.transaction.nx);
|
|
777
|
+
}
|
|
778
|
+
return completeFresh(request, callbackUrl, context.user, context.transaction);
|
|
779
|
+
};
|
|
780
|
+
const verifyFreshSecondFactor = async (request, input) => {
|
|
781
|
+
const context = await freshContext(request, input.authRequestId);
|
|
782
|
+
if (context.kind === 'refused')
|
|
783
|
+
return context.outcome;
|
|
784
|
+
let callbackUrl;
|
|
785
|
+
try {
|
|
786
|
+
callbackUrl = await broker().verifySecondFactor({
|
|
787
|
+
authRequestId: input.authRequestId,
|
|
788
|
+
token: input.token,
|
|
789
|
+
code: input.code,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
return freshFailure(error, [], context.transaction.nx);
|
|
794
|
+
}
|
|
795
|
+
return completeFresh(request, callbackUrl, context.user, context.transaction);
|
|
796
|
+
};
|
|
616
797
|
const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
|
|
617
798
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
618
799
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
@@ -1068,6 +1249,11 @@ export function createAuth(input) {
|
|
|
1068
1249
|
sessions: listSessions,
|
|
1069
1250
|
endSession,
|
|
1070
1251
|
authRequest,
|
|
1252
|
+
startFreshSignIn,
|
|
1253
|
+
finishFreshSignIn,
|
|
1254
|
+
startFreshPasskey,
|
|
1255
|
+
finishFreshPasskey,
|
|
1256
|
+
verifyFreshSecondFactor,
|
|
1071
1257
|
signIn,
|
|
1072
1258
|
setNewPassword,
|
|
1073
1259
|
setNewPasswordHeadless,
|
package/dist/broker.d.ts
CHANGED
|
@@ -49,9 +49,19 @@ export declare class BrokerError extends Error {
|
|
|
49
49
|
readonly detail?: string | undefined;
|
|
50
50
|
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
51
51
|
readonly changeToken?: string | undefined;
|
|
52
|
+
/** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
|
|
53
|
+
readonly secondFactor?: {
|
|
54
|
+
token: string;
|
|
55
|
+
methods: string[];
|
|
56
|
+
} | undefined;
|
|
52
57
|
constructor(status: number, error: string, detail?: string | undefined,
|
|
53
58
|
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
54
|
-
changeToken?: string | undefined
|
|
59
|
+
changeToken?: string | undefined,
|
|
60
|
+
/** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
|
|
61
|
+
secondFactor?: {
|
|
62
|
+
token: string;
|
|
63
|
+
methods: string[];
|
|
64
|
+
} | undefined);
|
|
55
65
|
}
|
|
56
66
|
/**
|
|
57
67
|
* The privileged half, over HTTP. The app holds a key that reaches its own
|
|
@@ -76,10 +86,16 @@ export declare class Broker {
|
|
|
76
86
|
password: string;
|
|
77
87
|
}>;
|
|
78
88
|
authRequest(id: string): Promise<AuthRequest>;
|
|
89
|
+
/**
|
|
90
|
+
* `secondFactor: true` asks the service to answer 403 `second_factor_required`
|
|
91
|
+
* (a token to send to `verifySecondFactor`) where it would otherwise answer
|
|
92
|
+
* `hosted_login_required` only because of an enrolled one-time-code factor.
|
|
93
|
+
*/
|
|
79
94
|
signIn(input: {
|
|
80
95
|
authRequestId: string;
|
|
81
96
|
email: string;
|
|
82
97
|
password: string;
|
|
98
|
+
secondFactor?: true;
|
|
83
99
|
}): Promise<string>;
|
|
84
100
|
/** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
|
|
85
101
|
setNewPassword(input: {
|
|
@@ -113,6 +129,13 @@ export declare class Broker {
|
|
|
113
129
|
authRequestId: string;
|
|
114
130
|
sessionId: string;
|
|
115
131
|
credential: unknown;
|
|
132
|
+
secondFactor?: true;
|
|
133
|
+
}): Promise<string>;
|
|
134
|
+
/** The code half of a `second_factor_required` answer; finishes the auth request like `signIn`. */
|
|
135
|
+
verifySecondFactor(input: {
|
|
136
|
+
authRequestId: string;
|
|
137
|
+
token: string;
|
|
138
|
+
code: string;
|
|
116
139
|
}): Promise<string>;
|
|
117
140
|
followLink(input: {
|
|
118
141
|
authRequestId: string;
|
package/dist/broker.js
CHANGED
|
@@ -4,14 +4,18 @@ export class BrokerError extends Error {
|
|
|
4
4
|
error;
|
|
5
5
|
detail;
|
|
6
6
|
changeToken;
|
|
7
|
+
secondFactor;
|
|
7
8
|
constructor(status, error, detail,
|
|
8
9
|
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
9
|
-
changeToken
|
|
10
|
+
changeToken,
|
|
11
|
+
/** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
|
|
12
|
+
secondFactor) {
|
|
10
13
|
super(detail ?? error);
|
|
11
14
|
this.status = status;
|
|
12
15
|
this.error = error;
|
|
13
16
|
this.detail = detail;
|
|
14
17
|
this.changeToken = changeToken;
|
|
18
|
+
this.secondFactor = secondFactor;
|
|
15
19
|
this.name = 'BrokerError';
|
|
16
20
|
}
|
|
17
21
|
}
|
|
@@ -53,7 +57,14 @@ export class Broker {
|
|
|
53
57
|
throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
|
|
54
58
|
}
|
|
55
59
|
if (!response.ok) {
|
|
56
|
-
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined
|
|
60
|
+
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined, data.error === 'second_factor_required' && typeof data.token === 'string'
|
|
61
|
+
? {
|
|
62
|
+
token: data.token,
|
|
63
|
+
methods: Array.isArray(data.methods)
|
|
64
|
+
? data.methods.filter((m) => typeof m === 'string')
|
|
65
|
+
: [],
|
|
66
|
+
}
|
|
67
|
+
: undefined);
|
|
57
68
|
}
|
|
58
69
|
return data;
|
|
59
70
|
}
|
|
@@ -75,6 +86,11 @@ export class Broker {
|
|
|
75
86
|
authRequest(id) {
|
|
76
87
|
return this.call('GET', '/auth-request', { id });
|
|
77
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* `secondFactor: true` asks the service to answer 403 `second_factor_required`
|
|
91
|
+
* (a token to send to `verifySecondFactor`) where it would otherwise answer
|
|
92
|
+
* `hosted_login_required` only because of an enrolled one-time-code factor.
|
|
93
|
+
*/
|
|
78
94
|
async signIn(input) {
|
|
79
95
|
const { callbackUrl } = await this.call('POST', '/sign-in', input);
|
|
80
96
|
return callbackUrl;
|
|
@@ -113,6 +129,11 @@ export class Broker {
|
|
|
113
129
|
const { callbackUrl } = await this.call('POST', '/passkey/finish', input);
|
|
114
130
|
return callbackUrl;
|
|
115
131
|
}
|
|
132
|
+
/** The code half of a `second_factor_required` answer; finishes the auth request like `signIn`. */
|
|
133
|
+
async verifySecondFactor(input) {
|
|
134
|
+
const { callbackUrl } = await this.call('POST', '/second-factor/verify', input);
|
|
135
|
+
return callbackUrl;
|
|
136
|
+
}
|
|
116
137
|
async followLink(input) {
|
|
117
138
|
const { callbackUrl } = await this.call('POST', '/link/follow', input);
|
|
118
139
|
return callbackUrl;
|
package/dist/cookies.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export interface TransactionPayload extends JWTPayload {
|
|
|
19
19
|
nx: string;
|
|
20
20
|
/** The leg was a silent pass (`prompt=none`): an error coming back falls back to interactive sign-in. */
|
|
21
21
|
sl?: true;
|
|
22
|
+
/** A fresh sign-in started by `startFreshSignIn`: the auth request this transaction belongs to. */
|
|
23
|
+
ar?: string;
|
|
24
|
+
/** The passkey challenge's session id, set by `startFreshPasskey`. */
|
|
25
|
+
fp?: string;
|
|
22
26
|
}
|
|
23
27
|
export interface CookieAttributes {
|
|
24
28
|
path: '/';
|
package/dist/fresh.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Client-safe: no imports, so a client component can use it without pulling in the server. */
|
|
2
|
+
/** What a server action returns instead of redirecting when the person must sign in again. */
|
|
3
|
+
export type FreshSignInAsk = {
|
|
4
|
+
kind: 'fresh-sign-in';
|
|
5
|
+
maxAge: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function isFreshSignInAsk(value: unknown): value is FreshSignInAsk;
|
|
8
|
+
/**
|
|
9
|
+
* Runs `run`; when it answers an ask, waits for `ask()` (the app's modal, `true`
|
|
10
|
+
* once the person signed in again) and runs it once more. A cancelled modal
|
|
11
|
+
* returns the ask. The second answer is returned as it is, so a second ask
|
|
12
|
+
* reaches the caller instead of looping.
|
|
13
|
+
*/
|
|
14
|
+
export declare function withFreshSignIn<T>(run: () => Promise<T>, ask: () => Promise<boolean>): Promise<T | FreshSignInAsk>;
|
package/dist/fresh.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Client-safe: no imports, so a client component can use it without pulling in the server. */
|
|
2
|
+
export function isFreshSignInAsk(value) {
|
|
3
|
+
if (typeof value !== 'object' || value === null)
|
|
4
|
+
return false;
|
|
5
|
+
const { kind, maxAge } = value;
|
|
6
|
+
return kind === 'fresh-sign-in' && typeof maxAge === 'number' && Number.isFinite(maxAge);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Runs `run`; when it answers an ask, waits for `ask()` (the app's modal, `true`
|
|
10
|
+
* once the person signed in again) and runs it once more. A cancelled modal
|
|
11
|
+
* returns the ask. The second answer is returned as it is, so a second ask
|
|
12
|
+
* reaches the caller instead of looping.
|
|
13
|
+
*/
|
|
14
|
+
export async function withFreshSignIn(run, ask) {
|
|
15
|
+
const first = await run();
|
|
16
|
+
if (!isFreshSignInAsk(first))
|
|
17
|
+
return first;
|
|
18
|
+
if (!(await ask()))
|
|
19
|
+
return first;
|
|
20
|
+
return run();
|
|
21
|
+
}
|
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollStatus, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignUpError, type User } from './auth.js';
|
|
3
3
|
import type { AuthEvent, AuthOptions } from './config.js';
|
|
4
|
+
import type { FreshSignInAsk } from './fresh.js';
|
|
4
5
|
import type { SessionRecord } from './sessions.js';
|
|
5
6
|
type SearchParams = Record<string, string | string[] | undefined>;
|
|
6
7
|
export interface QrSignIn {
|
|
@@ -9,6 +10,22 @@ export interface QrSignIn {
|
|
|
9
10
|
expiresIn: number;
|
|
10
11
|
interval: number;
|
|
11
12
|
}
|
|
13
|
+
/** What each step of the modal answers; see `NextAuth.startFreshSignIn`. Never a redirect. */
|
|
14
|
+
export type FreshResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
error: SignInError | 'other_person' | 'invalid_code';
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
secondFactor: {
|
|
22
|
+
token: string;
|
|
23
|
+
methods: 'totp'[];
|
|
24
|
+
};
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
fallbackUrl: string;
|
|
28
|
+
};
|
|
12
29
|
export interface NextAuth {
|
|
13
30
|
auth: Auth;
|
|
14
31
|
/** `export const { GET, POST } = handlers` from app/auth/[...auth]/route.ts. */
|
|
@@ -41,6 +58,62 @@ export interface NextAuth {
|
|
|
41
58
|
next?: string;
|
|
42
59
|
searchParams?: SearchParams | Promise<SearchParams>;
|
|
43
60
|
}) => Promise<User>;
|
|
61
|
+
/**
|
|
62
|
+
* `requireFreshUser` for a server action: the user when they authenticated
|
|
63
|
+
* within `maxAge` seconds, otherwise an `ask` for the action to return to
|
|
64
|
+
* its client, which opens the sign-in modal (`withFreshSignIn` in
|
|
65
|
+
* `@wtfalch/auth/fresh`). Never redirects. Throws `AuthError('unauthenticated')`
|
|
66
|
+
* for nobody, as `startPasskeyRegistration` does.
|
|
67
|
+
*/
|
|
68
|
+
freshUserOrAsk: (maxAge: MaxAge) => Promise<{
|
|
69
|
+
fresh: true;
|
|
70
|
+
user: User;
|
|
71
|
+
} | {
|
|
72
|
+
fresh: false;
|
|
73
|
+
ask: FreshSignInAsk;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Step 1 of the modal, for a server action. Starts a `prompt=login` auth
|
|
77
|
+
* request for the signed-in person and keeps its PKCE transaction in the
|
|
78
|
+
* sealed transaction cookie. `fallbackUrl` is where `requireFreshUser` would
|
|
79
|
+
* have sent them: the modal cannot be used, so the app does what it did
|
|
80
|
+
* before. `next` is where that fallback returns to.
|
|
81
|
+
*/
|
|
82
|
+
startFreshSignIn: (opts?: {
|
|
83
|
+
next?: string;
|
|
84
|
+
}) => Promise<{
|
|
85
|
+
ok: true;
|
|
86
|
+
authRequestId: string;
|
|
87
|
+
loginHint: string;
|
|
88
|
+
passkey: boolean;
|
|
89
|
+
} | {
|
|
90
|
+
ok: false;
|
|
91
|
+
fallbackUrl: string;
|
|
92
|
+
}>;
|
|
93
|
+
/** The password step. On `ok` the session cookie is already replaced; nothing redirects. */
|
|
94
|
+
finishFreshSignIn: (input: {
|
|
95
|
+
authRequestId: string;
|
|
96
|
+
password: string;
|
|
97
|
+
}) => Promise<FreshResult>;
|
|
98
|
+
/** A passkey challenge: answer it with `@wtfalch/auth/passkey`'s `getPasskey`, then `finishFreshPasskey`. */
|
|
99
|
+
startFreshPasskey: (input: {
|
|
100
|
+
authRequestId: string;
|
|
101
|
+
}) => Promise<{
|
|
102
|
+
ok: true;
|
|
103
|
+
options: unknown;
|
|
104
|
+
} | Exclude<FreshResult, {
|
|
105
|
+
ok: true;
|
|
106
|
+
}>>;
|
|
107
|
+
finishFreshPasskey: (input: {
|
|
108
|
+
authRequestId: string;
|
|
109
|
+
credential: unknown;
|
|
110
|
+
}) => Promise<FreshResult>;
|
|
111
|
+
/** The code step after a `secondFactor` result. */
|
|
112
|
+
verifyFreshSecondFactor: (input: {
|
|
113
|
+
authRequestId: string;
|
|
114
|
+
token: string;
|
|
115
|
+
code: string;
|
|
116
|
+
}) => Promise<FreshResult>;
|
|
44
117
|
/**
|
|
45
118
|
* For the app's login and register pages: the auth request the issuer sent
|
|
46
119
|
* the browser with, or a redirect that starts one. Pass the page's searchParams.
|
|
@@ -184,4 +257,4 @@ export interface NextAuth {
|
|
|
184
257
|
}>;
|
|
185
258
|
}
|
|
186
259
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
187
|
-
export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, SetNewPasswordError, SetNewPasswordResult, Prompt, QrPollStatus, SessionRecord, User, };
|
|
260
|
+
export type { FreshSignInAsk, Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, SetNewPasswordError, SetNewPasswordResult, Prompt, QrPollStatus, SessionRecord, User, };
|
package/dist/next.js
CHANGED
|
@@ -163,6 +163,91 @@ export function nextAuth(options) {
|
|
|
163
163
|
}
|
|
164
164
|
return new Request('http://localhost/', { headers: requestHeaders });
|
|
165
165
|
};
|
|
166
|
+
const freshUserOrAsk = async (maxAge) => {
|
|
167
|
+
const store = await cookies();
|
|
168
|
+
const { user, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, {
|
|
169
|
+
refresh: false,
|
|
170
|
+
});
|
|
171
|
+
if (unavailable)
|
|
172
|
+
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
173
|
+
if (!user)
|
|
174
|
+
throw new AuthError('unauthenticated', 'sign in to continue');
|
|
175
|
+
if (auth.fresh(user, maxAge))
|
|
176
|
+
return { fresh: true, user };
|
|
177
|
+
return { fresh: false, ask: { kind: 'fresh-sign-in', maxAge } };
|
|
178
|
+
};
|
|
179
|
+
// Where requireFreshUser would send them. `max_age=0` because the modal does not know the bound the action wanted.
|
|
180
|
+
const freshFallback = (next) => auth.startUrl(marked(next), 'login', undefined, 0);
|
|
181
|
+
const setAll = async (set) => {
|
|
182
|
+
const store = await cookies();
|
|
183
|
+
for (const c of set)
|
|
184
|
+
store.set(c.name, c.value, c.attributes);
|
|
185
|
+
};
|
|
186
|
+
const freshResult = async (outcome) => {
|
|
187
|
+
await setAll(outcome.cookies);
|
|
188
|
+
if (outcome.ok)
|
|
189
|
+
return { ok: true };
|
|
190
|
+
if ('hosted' in outcome)
|
|
191
|
+
return { ok: false, fallbackUrl: freshFallback(outcome.next) };
|
|
192
|
+
if ('secondFactor' in outcome)
|
|
193
|
+
return { ok: false, secondFactor: outcome.secondFactor };
|
|
194
|
+
return { ok: false, error: outcome.error };
|
|
195
|
+
};
|
|
196
|
+
// A server action's argument is whatever the client sent: every field is checked where it is read.
|
|
197
|
+
const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max ? value : null;
|
|
198
|
+
const refused = { ok: false, error: 'request' };
|
|
199
|
+
const startFreshSignIn = async (opts) => {
|
|
200
|
+
const next = typeof opts?.next === 'string' ? opts.next : undefined;
|
|
201
|
+
const result = await auth.startFreshSignIn(await requestFromCookies(), { next });
|
|
202
|
+
await setAll(result.cookies);
|
|
203
|
+
if (!result.ok)
|
|
204
|
+
return { ok: false, fallbackUrl: freshFallback(next) };
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
authRequestId: result.authRequestId,
|
|
208
|
+
loginHint: result.loginHint,
|
|
209
|
+
passkey: result.passkey,
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
const finishFreshSignIn = async (input) => {
|
|
213
|
+
const authRequestId = text(input?.authRequestId, 256);
|
|
214
|
+
const password = text(input?.password, 1024);
|
|
215
|
+
if (!authRequestId || !password)
|
|
216
|
+
return refused;
|
|
217
|
+
return freshResult(await auth.finishFreshSignIn(await requestFromCookies(), { authRequestId, password }));
|
|
218
|
+
};
|
|
219
|
+
const startFreshPasskey = async (input) => {
|
|
220
|
+
const authRequestId = text(input?.authRequestId, 256);
|
|
221
|
+
if (!authRequestId)
|
|
222
|
+
return refused;
|
|
223
|
+
const result = await auth.startFreshPasskey(await requestFromCookies(), { authRequestId });
|
|
224
|
+
if (result.ok) {
|
|
225
|
+
await setAll(result.cookies);
|
|
226
|
+
return { ok: true, options: result.options };
|
|
227
|
+
}
|
|
228
|
+
return freshResult(result);
|
|
229
|
+
};
|
|
230
|
+
const finishFreshPasskey = async (input) => {
|
|
231
|
+
const authRequestId = text(input?.authRequestId, 256);
|
|
232
|
+
if (!authRequestId || typeof input.credential !== 'object' || input.credential === null)
|
|
233
|
+
return refused;
|
|
234
|
+
return freshResult(await auth.finishFreshPasskey(await requestFromCookies(), {
|
|
235
|
+
authRequestId,
|
|
236
|
+
credential: input.credential,
|
|
237
|
+
}));
|
|
238
|
+
};
|
|
239
|
+
const verifyFreshSecondFactor = async (input) => {
|
|
240
|
+
const authRequestId = text(input?.authRequestId, 256);
|
|
241
|
+
const token = text(input?.token, 2048);
|
|
242
|
+
const code = text(input?.code, 32);
|
|
243
|
+
if (!authRequestId || !token || !code)
|
|
244
|
+
return refused;
|
|
245
|
+
return freshResult(await auth.verifyFreshSecondFactor(await requestFromCookies(), {
|
|
246
|
+
authRequestId,
|
|
247
|
+
token,
|
|
248
|
+
code,
|
|
249
|
+
}));
|
|
250
|
+
};
|
|
166
251
|
const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
|
|
167
252
|
const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
|
|
168
253
|
const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
|
|
@@ -281,6 +366,12 @@ export function nextAuth(options) {
|
|
|
281
366
|
getUser,
|
|
282
367
|
requireUser,
|
|
283
368
|
requireFreshUser,
|
|
369
|
+
freshUserOrAsk,
|
|
370
|
+
startFreshSignIn,
|
|
371
|
+
finishFreshSignIn,
|
|
372
|
+
startFreshPasskey,
|
|
373
|
+
finishFreshPasskey,
|
|
374
|
+
verifyFreshSecondFactor,
|
|
284
375
|
authRequest,
|
|
285
376
|
signIn,
|
|
286
377
|
setNewPassword,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Sign in against a self-hosted ZITADEL issuer: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"types": "./dist/browser.d.ts",
|
|
23
23
|
"default": "./dist/browser.js"
|
|
24
24
|
},
|
|
25
|
+
"./fresh": {
|
|
26
|
+
"types": "./dist/fresh.d.ts",
|
|
27
|
+
"default": "./dist/fresh.js"
|
|
28
|
+
},
|
|
25
29
|
"./passkey": {
|
|
26
30
|
"types": "./dist/passkey-browser.d.ts",
|
|
27
31
|
"default": "./dist/passkey-browser.js"
|