@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 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: '/';
@@ -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.33.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"