@wtfalch/auth 0.10.0 → 0.11.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
@@ -15,6 +15,16 @@ export interface User {
15
15
  familyName: string | null;
16
16
  preferredUsername: string | null;
17
17
  organizationId: string;
18
+ /**
19
+ * When the person last proved who they were to the issuer, in seconds since
20
+ * the epoch, from the id token's `auth_time`. Null when the issuer did not
21
+ * send it, which OIDC allows unless the request carried `max_age` -- so a
22
+ * null is not evidence of freshness and `fresh()` treats it as stale.
23
+ *
24
+ * A refresh does not move it. It is when they authenticated, not when the
25
+ * token was minted.
26
+ */
27
+ authTime: number | null;
18
28
  claims: JWTPayload;
19
29
  }
20
30
  export interface ReadResult {
@@ -46,6 +56,8 @@ export type Gate = {
46
56
  reason?: 'account_changed' | 'unavailable';
47
57
  };
48
58
  export type Intent = 'login' | 'register';
59
+ /** Seconds. `0` makes the issuer re-authenticate whatever it did a moment ago. */
60
+ export type MaxAge = number;
49
61
  /** Allowlisted so a caller cannot smuggle an arbitrary OIDC prompt value through. */
50
62
  export type Prompt = 'select_account' | 'login';
51
63
  export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
@@ -230,8 +242,19 @@ export interface Auth {
230
242
  refresh?: boolean;
231
243
  }): Promise<ReadResult>;
232
244
  gate(request: Request, rules?: GateRules): Promise<Gate>;
233
- /** `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded. */
234
- startUrl(next?: string | null, intent?: Intent, prompt?: Prompt): string;
245
+ /**
246
+ * `prompt` is allowlisted server-side too -- an unrecognised value is dropped, not forwarded.
247
+ * `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
248
+ * last authentication is older than that many seconds.
249
+ */
250
+ startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
251
+ /**
252
+ * Whether `user` authenticated within `maxAge` seconds. A user with no
253
+ * `authTime` is never fresh: the issuer only has to send the claim when the
254
+ * request asked for it, so its absence says nothing, and the safe reading of
255
+ * nothing is "send them back".
256
+ */
257
+ fresh(user: User | null, maxAge: MaxAge): boolean;
235
258
  /**
236
259
  * Where to send somebody when a sign-in step failed for a reason they
237
260
  * cannot fix by retrying it automatically: `onError` with `?auth_error=`,
package/dist/auth.js CHANGED
@@ -74,7 +74,7 @@ export function createAuth(input) {
74
74
  .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
75
75
  };
76
76
  const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
77
- const startUrl = (next, intent = 'login', prompt) => {
77
+ const startUrl = (next, intent = 'login', prompt, maxAge) => {
78
78
  const url = new URL(`${options().basePath}/start`, options().appUrl);
79
79
  const path = safeNextPath(next, '');
80
80
  if (path)
@@ -83,8 +83,18 @@ export function createAuth(input) {
83
83
  url.searchParams.set('intent', 'register');
84
84
  if (prompt)
85
85
  url.searchParams.set('prompt', prompt);
86
+ if (maxAge !== undefined) {
87
+ assertMaxAge(maxAge);
88
+ url.searchParams.set('max_age', String(maxAge));
89
+ }
86
90
  return url.href;
87
91
  };
92
+ const fresh = (user, maxAge) => {
93
+ assertMaxAge(maxAge);
94
+ if (!user || user.authTime === null)
95
+ return false;
96
+ return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
97
+ };
88
98
  const start = async (request) => {
89
99
  if (!isNavigation(request))
90
100
  return new Response('Unauthorized', { status: 401 });
@@ -98,6 +108,7 @@ export function createAuth(input) {
98
108
  const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
99
109
  create: params.get('intent') === 'register',
100
110
  prompt: promptFrom(params.get('prompt')),
111
+ maxAge: maxAgeFrom(params.get('max_age')),
101
112
  });
102
113
  const transaction = await sealTransaction(options(), {
103
114
  st: state,
@@ -716,6 +727,7 @@ export function createAuth(input) {
716
727
  readCookie,
717
728
  gate,
718
729
  startUrl,
730
+ fresh,
719
731
  errorUrl,
720
732
  people,
721
733
  setPersonActive,
@@ -783,6 +795,39 @@ const PROMPTS = ['select_account', 'login'];
783
795
  function promptFrom(raw) {
784
796
  return PROMPTS.includes(raw ?? '') ? raw : undefined;
785
797
  }
798
+ // A year. Past that the bound is doing nothing, and an absurd value is a
799
+ // caller getting it wrong rather than a request to honour.
800
+ const MAX_AGE_CEILING = 31_536_000;
801
+ /**
802
+ * The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
803
+ * comes off the issuer's clock and is compared against this server's, so a
804
+ * bound checked to the exact second would flip on skew alone.
805
+ *
806
+ * It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
807
+ * the person again; they then spend a second or two coming back, and a check
808
+ * with no slack would find them already stale and send them round again,
809
+ * forever. The tolerance is why the round trip can ever satisfy the bound.
810
+ */
811
+ const CLOCK_TOLERANCE = 60;
812
+ function maxAgeFrom(raw) {
813
+ if (raw === null || raw.trim() === '')
814
+ return undefined;
815
+ const value = Number(raw);
816
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
817
+ return undefined;
818
+ return value;
819
+ }
820
+ /**
821
+ * A bad `maxAge` from app code is a mistake in the app, and the loud failure is
822
+ * the cheap one. Left alone it is silent and expensive: `startUrl` would drop
823
+ * the value, the issuer would be asked for nothing, the person would come back
824
+ * exactly as stale, and the gate would send them again.
825
+ */
826
+ function assertMaxAge(value) {
827
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
828
+ throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
829
+ }
830
+ }
786
831
  function userFrom(claims) {
787
832
  const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
788
833
  return {
@@ -794,6 +839,7 @@ function userFrom(claims) {
794
839
  familyName: str('family_name'),
795
840
  preferredUsername: str('preferred_username'),
796
841
  organizationId: String(claims[ORG_CLAIM]),
842
+ authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
797
843
  claims,
798
844
  };
799
845
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
1
+ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
2
  export { type AuthOptions, ISSUER } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
4
  export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
package/dist/next.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type NextRequest, NextResponse } from 'next/server';
2
- import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
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 SignInError, type SignUpError, type User } from './auth.js';
3
3
  import type { AuthOptions } from './config.js';
4
4
  import type { SessionRecord } from './sessions.js';
5
5
  type SearchParams = Record<string, string | string[] | undefined>;
@@ -22,6 +22,25 @@ export interface NextAuth {
22
22
  getUser: () => Promise<User | null>;
23
23
  /** The user, or a redirect to sign in with `next` to come back to. */
24
24
  requireUser: (next?: string) => Promise<User>;
25
+ /**
26
+ * The user, but only if they authenticated within `maxAge` seconds;
27
+ * otherwise a redirect that makes the issuer ask them again. Put it in front
28
+ * of an action somebody holding a borrowed laptop must not be able to take:
29
+ * changing the password, changing the email, removing a passkey.
30
+ *
31
+ * Somebody whose id token carries no `auth_time` is sent back too. The
32
+ * issuer only has to send that claim when the request asked for it, so its
33
+ * absence proves nothing, and the first round trip fixes it for good.
34
+ *
35
+ * **Pass the page's `searchParams`.** They carry the mark that says this
36
+ * person has already been sent once; without them a person the issuer will
37
+ * not re-authenticate is sent round the same loop for ever. See the
38
+ * implementation for why that is not hypothetical here.
39
+ */
40
+ requireFreshUser: (maxAge: MaxAge, opts?: {
41
+ next?: string;
42
+ searchParams?: SearchParams | Promise<SearchParams>;
43
+ }) => Promise<User>;
25
44
  /**
26
45
  * For the app's login and register pages: the auth request the issuer sent
27
46
  * the browser with, or a redirect that starts one. Pass the page's searchParams.
@@ -29,6 +48,7 @@ export interface NextAuth {
29
48
  authRequest: (searchParams: SearchParams | Promise<SearchParams>, opts?: {
30
49
  next?: string;
31
50
  intent?: Intent;
51
+ prompt?: Prompt;
32
52
  }) => Promise<AuthRequest>;
33
53
  /** For a server action: redirects into the app on success, returns the error otherwise. */
34
54
  signIn: (input: {
@@ -137,4 +157,4 @@ export interface NextAuth {
137
157
  }>;
138
158
  }
139
159
  export declare function nextAuth(options: AuthOptions): NextAuth;
140
- export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, SessionRecord, User, };
160
+ export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
package/dist/next.js CHANGED
@@ -5,6 +5,12 @@ import { createAuth, } from './auth.js';
5
5
  import { BrokerError } from './broker.js';
6
6
  import { withCookies } from './cookies.js';
7
7
  import { AuthError } from './oidc.js';
8
+ /** On the page a step-up returns to: this person has been sent once already. */
9
+ const STEP_UP_PARAM = 'auth_stepup';
10
+ function marked(next) {
11
+ const path = next ?? '/';
12
+ return `${path}${path.includes('?') ? '&' : '?'}${STEP_UP_PARAM}=1`;
13
+ }
8
14
  /**
9
15
  * Which of the two failures it was, in the vocabulary `auth_error` already
10
16
  * uses. A refusal under 500 is about this request — the broker says unknown,
@@ -63,6 +69,30 @@ export function nextAuth(options) {
63
69
  redirect(auth.startUrl(next));
64
70
  return user;
65
71
  };
72
+ /**
73
+ * One redirect, never two. The second one is the loop.
74
+ *
75
+ * The gate has exactly the shape `authRequest` warns about below, and the
76
+ * same price: send somebody to the issuer because they are stale, and if
77
+ * they come back no fresher, the next render sends them again. Nothing
78
+ * errors and nothing is logged. An issuer that declines to honour `max_age`,
79
+ * or one that omits `auth_time` from the token it mints afterwards, turns
80
+ * the gate into that loop for every person who reaches it.
81
+ *
82
+ * So the redirect marks the return address, and a person who arrives already
83
+ * marked gets an `AuthError` instead of another redirect. The app shows them
84
+ * something and they click. A human click cannot loop.
85
+ */
86
+ const requireFreshUser = async (maxAge, opts = {}) => {
87
+ const user = await requireUser(opts.next);
88
+ if (auth.fresh(user, maxAge))
89
+ return user;
90
+ const params = opts.searchParams ? await opts.searchParams : undefined;
91
+ if (params?.[STEP_UP_PARAM] !== undefined) {
92
+ throw new AuthError('stale', 'Could not confirm a recent sign-in');
93
+ }
94
+ redirect(auth.startUrl(marked(opts.next), 'login', undefined, maxAge));
95
+ };
66
96
  /**
67
97
  * The auth request the issuer sent the browser here with.
68
98
  *
@@ -93,7 +123,7 @@ export function nextAuth(options) {
93
123
  // Nobody has started a flow yet. This cannot loop: the issuer always
94
124
  // sends the browser back carrying an id.
95
125
  if (!id)
96
- redirect(auth.startUrl(opts.next, opts.intent));
126
+ redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
97
127
  try {
98
128
  return await auth.authRequest(id);
99
129
  }
@@ -220,6 +250,7 @@ export function nextAuth(options) {
220
250
  proxy,
221
251
  getUser,
222
252
  requireUser,
253
+ requireFreshUser,
223
254
  authRequest,
224
255
  signIn,
225
256
  signUp,
package/dist/oidc.d.ts CHANGED
@@ -26,9 +26,10 @@ export declare class Oidc {
26
26
  private config;
27
27
  private keys;
28
28
  get redirectUri(): URL;
29
- authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt }?: {
29
+ authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, }?: {
30
30
  create?: boolean;
31
31
  prompt?: 'select_account' | 'login';
32
+ maxAge?: number;
32
33
  }): Promise<URL>;
33
34
  exchange(callbackParams: URLSearchParams, transaction: {
34
35
  state: string;
@@ -48,7 +49,9 @@ export declare class Oidc {
48
49
  }
49
50
  export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
50
51
  /** No session at all -- distinct from `account_changed`/`unavailable`, which `requireUser` already covers by redirecting instead. */
51
- | 'unauthenticated';
52
+ | 'unauthenticated'
53
+ /** Came back from a step-up no fresher than it went. Never redirect on this: that is the loop. */
54
+ | 'stale';
52
55
  export declare class AuthError extends Error {
53
56
  readonly reason: AuthErrorReason;
54
57
  constructor(reason: AuthErrorReason, message: string);
package/dist/oidc.js CHANGED
@@ -62,9 +62,10 @@ export class Oidc {
62
62
  get redirectUri() {
63
63
  return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
64
64
  }
65
- async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt } = {}) {
65
+ async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, } = {}) {
66
66
  return client.buildAuthorizationUrl(await this.config(), {
67
67
  ...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
68
+ ...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
68
69
  redirect_uri: this.redirectUri.href,
69
70
  scope: scopeFor(this.options.organizationId),
70
71
  state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Sign in against auth.wtfalch.dev: 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",