@wtfalch/auth 0.10.0 → 0.11.1

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,
@@ -164,11 +175,21 @@ export function createAuth(input) {
164
175
  /**
165
176
  * Where a sign-out lands: the issuer's own logout, so its session ends and
166
177
  * the next sign-in asks who again, rather than straight back to
167
- * `afterLogout`. What this function can see fail -- no id token to hint
168
- * with, no `end_session_endpoint` published, the issuer not answering --
169
- * falls back to the plain redirect a sign-out has always ended with.
170
- * Losing the issuer's co-operation is never a reason to fail somebody's
171
- * sign-out.
178
+ * `afterLogout`. What this function can see fail -- no `end_session_endpoint`
179
+ * published, the issuer not answering -- falls back to the plain redirect a
180
+ * sign-out has always ended with. Losing the issuer's co-operation is never
181
+ * a reason to fail somebody's sign-out.
182
+ *
183
+ * Having no id token is the one failure that does not fall back, because the
184
+ * plain redirect leaves the issuer's session standing and the next sign-in is
185
+ * silent. `end_session` cannot rescue it either: without a hint the issuer
186
+ * takes its v1 branch and ends v1 user-agent sessions, of which a v2 sign-in
187
+ * has none. Routing a hint-less `end_session` to the hosted login instead
188
+ * needs the instance's `loginV2.required`, which overrides every app's own
189
+ * `loginVersion.loginV2.baseUri` with the instance's -- and each app here
190
+ * hosts its own sign-in at `/auth`, so that flag is not available to us.
191
+ * The hosted login's `/logout` sits at the issuer's origin, where
192
+ * `scripts/provision.mjs` points the instance's `loginV2.baseUri`.
172
193
  *
173
194
  * `afterLogout` not being a registered `post_logout_redirect_uri` is a
174
195
  * failure this function cannot see: that request reaches the issuer, which
@@ -180,11 +201,12 @@ export function createAuth(input) {
180
201
  */
181
202
  const endIssuerSession = async (idToken, cookies) => {
182
203
  const afterLogout = new URL(options().afterLogout, options().appUrl).href;
183
- const target = idToken
184
- ? await oidc()
185
- .endSessionUrl(idToken, afterLogout)
186
- .catch(() => null)
187
- : null;
204
+ if (!idToken) {
205
+ return redirect(new URL('/logout', options().issuer).href, cookies, 303);
206
+ }
207
+ const target = await oidc()
208
+ .endSessionUrl(idToken, afterLogout)
209
+ .catch(() => null);
188
210
  return redirect(target?.href ?? afterLogout, cookies, 303);
189
211
  };
190
212
  const logout = async (request) => {
@@ -716,6 +738,7 @@ export function createAuth(input) {
716
738
  readCookie,
717
739
  gate,
718
740
  startUrl,
741
+ fresh,
719
742
  errorUrl,
720
743
  people,
721
744
  setPersonActive,
@@ -783,6 +806,39 @@ const PROMPTS = ['select_account', 'login'];
783
806
  function promptFrom(raw) {
784
807
  return PROMPTS.includes(raw ?? '') ? raw : undefined;
785
808
  }
809
+ // A year. Past that the bound is doing nothing, and an absurd value is a
810
+ // caller getting it wrong rather than a request to honour.
811
+ const MAX_AGE_CEILING = 31_536_000;
812
+ /**
813
+ * The same sixty seconds `Oidc.verify` allows a token's own claims. `auth_time`
814
+ * comes off the issuer's clock and is compared against this server's, so a
815
+ * bound checked to the exact second would flip on skew alone.
816
+ *
817
+ * It also makes `maxAge: 0` terminate. Zero asks the issuer to authenticate
818
+ * the person again; they then spend a second or two coming back, and a check
819
+ * with no slack would find them already stale and send them round again,
820
+ * forever. The tolerance is why the round trip can ever satisfy the bound.
821
+ */
822
+ const CLOCK_TOLERANCE = 60;
823
+ function maxAgeFrom(raw) {
824
+ if (raw === null || raw.trim() === '')
825
+ return undefined;
826
+ const value = Number(raw);
827
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING)
828
+ return undefined;
829
+ return value;
830
+ }
831
+ /**
832
+ * A bad `maxAge` from app code is a mistake in the app, and the loud failure is
833
+ * the cheap one. Left alone it is silent and expensive: `startUrl` would drop
834
+ * the value, the issuer would be asked for nothing, the person would come back
835
+ * exactly as stale, and the gate would send them again.
836
+ */
837
+ function assertMaxAge(value) {
838
+ if (!Number.isInteger(value) || value < 0 || value > MAX_AGE_CEILING) {
839
+ throw new AuthError('request', `maxAge must be whole seconds from 0 to ${MAX_AGE_CEILING}`);
840
+ }
841
+ }
786
842
  function userFrom(claims) {
787
843
  const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
788
844
  return {
@@ -794,6 +850,7 @@ function userFrom(claims) {
794
850
  familyName: str('family_name'),
795
851
  preferredUsername: str('preferred_username'),
796
852
  organizationId: String(claims[ORG_CLAIM]),
853
+ authTime: typeof claims.auth_time === 'number' ? claims.auth_time : null,
797
854
  claims,
798
855
  };
799
856
  }
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.1",
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",