@wtfalch/auth 0.2.1 → 0.3.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/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  Sign in against [auth.wtfalch.dev](https://auth.wtfalch.dev) from a Next.js app.
4
4
 
5
5
  A thin OIDC client for one self-hosted ZITADEL instance, plus a client for the
6
- sign-in service that sits in front of it. Apps host their own sign-in pages: a
7
- browser never renders a page on the identity service.
6
+ sign-in service that sits in front of it. Apps host their own password sign-in
7
+ pages. Flows requiring MFA or another unsupported login policy continue at
8
+ the identity service's hosted login.
8
9
 
9
10
  Built for the wtfalch estate, published because valet consumes it from a
10
11
  container and a `file:` path does not survive a Docker build.
@@ -39,6 +40,26 @@ middleware for the paths that need a session, and read the person with
39
40
  `createAuth` from `@wtfalch/auth` is the same thing without the framework, for
40
41
  a CLI or a worker that has no router to import.
41
42
 
43
+ ## MFA and hosted login
44
+
45
+ The broker checks the organisation's login policy and enrolled authentication
46
+ methods before completing password sign-in, signup, or password reset. MFA,
47
+ disabled password authentication, and unknown authentication methods require
48
+ the hosted login. Email links always continue there: ZITADEL email OTP is a
49
+ second factor and cannot authenticate a session by itself.
50
+
51
+ The Next.js adapter handles these redirects and preserves the OIDC
52
+ transaction. With the framework-neutral API, a successful result carrying
53
+ `hosted: true` means authentication is still pending: redirect to
54
+ `redirectTo` (sign-in/signup) or `location` (reset), writing any returned
55
+ cookies first. Do not pass a hosted URL to `complete`. The eventual OIDC
56
+ callback completes authentication with the original PKCE, state, nonce, and
57
+ destination.
58
+
59
+ Auth-request lookup failures lead to the configured error page with
60
+ `auth_error=request` or `auth_error=unavailable`. That page should show a
61
+ retry link; it must not automatically restart authorization.
62
+
42
63
  ## Configuration
43
64
 
44
65
  | | |
package/dist/auth.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type JWTPayload } from 'jose';
2
- import { type AuthRequest, type NewUser } from './broker.js';
2
+ import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
3
  import { type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
5
  export interface User {
@@ -43,6 +43,7 @@ export type SignUpError = 'registration_closed' | 'email_taken' | 'username_take
43
43
  export type SignInResult = {
44
44
  ok: true;
45
45
  redirectTo: string;
46
+ hosted?: true;
46
47
  } | {
47
48
  ok: false;
48
49
  error: SignInError;
@@ -50,6 +51,7 @@ export type SignInResult = {
50
51
  export type SignUpResult = {
51
52
  ok: true;
52
53
  redirectTo: string;
54
+ hosted?: true;
53
55
  } | {
54
56
  ok: false;
55
57
  error: SignUpError;
@@ -64,6 +66,7 @@ export type SignedIn = {
64
66
  ok: true;
65
67
  location: string;
66
68
  cookies: SetCookie[];
69
+ hosted?: true;
67
70
  } | {
68
71
  ok: false;
69
72
  error: ResetError;
@@ -121,6 +124,33 @@ export interface Auth {
121
124
  }): Promise<ReadResult>;
122
125
  gate(request: Request, rules?: GateRules): Promise<Gate>;
123
126
  startUrl(next?: string | null, intent?: Intent): string;
127
+ /**
128
+ * Where to send somebody when a sign-in step failed for a reason they
129
+ * cannot fix by retrying it automatically: `onError` with `?auth_error=`,
130
+ * which is the same shape the callback's own failures use. Exposed because
131
+ * the Next adapter has to reach it from a server component, where a
132
+ * `Response` is no use.
133
+ */
134
+ errorUrl(reason: string): string;
135
+ /**
136
+ * The organisation's people, for an operator surface. The scope is this
137
+ * app's own organisation, decided by the service from the key rather than
138
+ * by an argument here, so there is no call that reads another one's.
139
+ */
140
+ people(options?: {
141
+ limit?: number;
142
+ offset?: number;
143
+ }): Promise<PeoplePage>;
144
+ /**
145
+ * Deactivate or reactivate somebody. Refused for a user outside this app's
146
+ * organisation. Deactivating stops them signing in and keeps the row, the
147
+ * memberships and the trail; there is no delete here, deliberately —
148
+ * erasure is an authorisation concern with its own audited path.
149
+ */
150
+ setPersonActive(userId: string, active: boolean): Promise<{
151
+ userId: string;
152
+ state: string;
153
+ }>;
124
154
  /** The auth request the issuer sent the browser here with. Refuses one for another application. */
125
155
  authRequest(id: string): Promise<AuthRequest>;
126
156
  signIn(input: {
package/dist/auth.js CHANGED
@@ -180,6 +180,8 @@ export function createAuth(input) {
180
180
  return { kind: 'deny', cookies };
181
181
  return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
182
182
  };
183
+ const people = (options) => broker().people(options);
184
+ const setPersonActive = (userId, active) => broker().setPersonActive(userId, active);
183
185
  const authRequest = (id) => broker().authRequest(id);
184
186
  /**
185
187
  * An OIDC flow this app starts and finishes itself, for a person arriving
@@ -207,6 +209,35 @@ export function createAuth(input) {
207
209
  const cookie = await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
208
210
  return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
209
211
  };
212
+ const hostedUrl = (id) => {
213
+ const url = new URL('/login', options().issuer);
214
+ url.searchParams.set('authRequest', id);
215
+ return url.href;
216
+ };
217
+ const needsHostedLogin = (error) => error instanceof BrokerError && error.error === 'hosted_login_required';
218
+ // Email/reset flows started on the server must transfer their PKCE transaction
219
+ // to the browser before handing off to the issuer's full authentication UI.
220
+ const finishOrHandoff = async (action, flow, next) => {
221
+ try {
222
+ return await finish(await action(), flow, next);
223
+ }
224
+ catch (error) {
225
+ if (!needsHostedLogin(error))
226
+ throw error;
227
+ return {
228
+ location: hostedUrl(flow.id),
229
+ hosted: true,
230
+ cookies: [
231
+ await sealTransaction(options(), {
232
+ st: flow.state,
233
+ nc: flow.nonce,
234
+ cv: flow.verifier,
235
+ nx: next,
236
+ }),
237
+ ],
238
+ };
239
+ }
240
+ };
210
241
  const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
211
242
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
212
243
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
@@ -214,14 +245,8 @@ export function createAuth(input) {
214
245
  const resetPassword = async ({ userId, code, password, next, }) => {
215
246
  try {
216
247
  const flow = await serverFlow();
217
- const callbackUrl = await broker().completeReset({
218
- authRequestId: flow.id,
219
- userId,
220
- code,
221
- password,
222
- });
223
- const { location, cookies } = await finish(callbackUrl, flow, safeNextPath(next, options().afterLogin));
224
- return { ok: true, location, cookies };
248
+ const completed = await finishOrHandoff(() => broker().completeReset({ authRequestId: flow.id, userId, code, password }), flow, safeNextPath(next, options().afterLogin));
249
+ return { ok: true, ...completed };
225
250
  }
226
251
  catch (error) {
227
252
  if (error instanceof BrokerError && error.error === 'invalid_password') {
@@ -241,8 +266,7 @@ export function createAuth(input) {
241
266
  return failed('link', []);
242
267
  try {
243
268
  const flow = await serverFlow();
244
- const callbackUrl = await broker().followLink({ authRequestId: flow.id, sessionId, code });
245
- const { location, cookies } = await finish(callbackUrl, flow, safeNextPath(params.get('next'), options().afterLogin));
269
+ const { location, cookies } = await finishOrHandoff(() => broker().followLink({ authRequestId: flow.id, sessionId, code }), flow, safeNextPath(params.get('next'), options().afterLogin));
246
270
  return redirect(location, cookies);
247
271
  }
248
272
  catch {
@@ -254,6 +278,8 @@ export function createAuth(input) {
254
278
  return { ok: true, redirectTo: await broker().signIn({ authRequestId, email, password }) };
255
279
  }
256
280
  catch (error) {
281
+ if (needsHostedLogin(error))
282
+ return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
257
283
  return { ok: false, error: signInError(error) };
258
284
  }
259
285
  };
@@ -262,6 +288,8 @@ export function createAuth(input) {
262
288
  return { ok: true, redirectTo: await broker().signUp({ authRequestId, ...user }) };
263
289
  }
264
290
  catch (error) {
291
+ if (needsHostedLogin(error))
292
+ return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
265
293
  if (error instanceof BrokerError && error.error === 'email_taken') {
266
294
  return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
267
295
  }
@@ -289,6 +317,8 @@ export function createAuth(input) {
289
317
  url.searchParams.set('auth_error', reason);
290
318
  return { location: url.href, cookies };
291
319
  };
320
+ /** `failure`'s location on its own, for a caller that redirects rather than answering. */
321
+ const errorUrl = (reason) => failure(reason, []).location;
292
322
  const failed = (reason, cookies) => {
293
323
  const { location } = failure(reason, []);
294
324
  return redirect(location, cookies);
@@ -313,6 +343,9 @@ export function createAuth(input) {
313
343
  readCookie,
314
344
  gate,
315
345
  startUrl,
346
+ errorUrl,
347
+ people,
348
+ setPersonActive,
316
349
  authRequest,
317
350
  signIn,
318
351
  signUp,
package/dist/broker.d.ts CHANGED
@@ -16,6 +16,21 @@ export interface NewUser {
16
16
  */
17
17
  username?: string;
18
18
  }
19
+ /** One of the organisation's people, as the sign-in service reports them. */
20
+ export interface Person {
21
+ readonly userId: string;
22
+ readonly username: string;
23
+ readonly displayName: string;
24
+ readonly email: string | null;
25
+ readonly emailVerified: boolean;
26
+ /** `active`, `inactive`, or `unknown` if the issuer said something newer. */
27
+ readonly state: string;
28
+ }
29
+ export interface PeoplePage {
30
+ /** Everyone in the organisation, not only this page. */
31
+ readonly total: number;
32
+ readonly people: readonly Person[];
33
+ }
19
34
  export declare class BrokerError extends Error {
20
35
  readonly status: number;
21
36
  readonly error: string;
@@ -31,6 +46,14 @@ export declare class Broker {
31
46
  private readonly options;
32
47
  constructor(options: ResolvedOptions);
33
48
  private call;
49
+ people(options?: {
50
+ limit?: number;
51
+ offset?: number;
52
+ }): Promise<PeoplePage>;
53
+ setPersonActive(userId: string, active: boolean): Promise<{
54
+ userId: string;
55
+ state: string;
56
+ }>;
34
57
  authRequest(id: string): Promise<AuthRequest>;
35
58
  signIn(input: {
36
59
  authRequestId: string;
package/dist/broker.js CHANGED
@@ -51,6 +51,17 @@ export class Broker {
51
51
  }
52
52
  return data;
53
53
  }
54
+ people(options = {}) {
55
+ const query = {};
56
+ if (options.limit !== undefined)
57
+ query.limit = options.limit;
58
+ if (options.offset !== undefined)
59
+ query.offset = options.offset;
60
+ return this.call('GET', '/people', query);
61
+ }
62
+ setPersonActive(userId, active) {
63
+ return this.call('POST', active ? '/people/reactivate' : '/people/deactivate', { userId });
64
+ }
54
65
  authRequest(id) {
55
66
  return this.call('GET', '/auth-request', { id });
56
67
  }
package/dist/next.d.ts CHANGED
@@ -53,6 +53,10 @@ export interface NextAuth {
53
53
  userId: string;
54
54
  invited: boolean;
55
55
  }>;
56
+ /** The organisation's people, for an operator surface. Scoped to this app's organisation by the service. */
57
+ people: Auth['people'];
58
+ /** Deactivate or reactivate somebody in this app's organisation. */
59
+ setPersonActive: Auth['setPersonActive'];
56
60
  /** Sets the password from a reset link, signs the person in, and redirects. */
57
61
  resetPassword: (input: {
58
62
  userId: string;
package/dist/next.js CHANGED
@@ -2,7 +2,19 @@ import { cookies } from 'next/headers';
2
2
  import { redirect } from 'next/navigation';
3
3
  import { NextResponse } from 'next/server';
4
4
  import { createAuth, } from './auth.js';
5
+ import { BrokerError } from './broker.js';
5
6
  import { withCookies } from './cookies.js';
7
+ /**
8
+ * Which of the two failures it was, in the vocabulary `auth_error` already
9
+ * uses. A refusal under 500 is about this request — the broker says unknown,
10
+ * expired, or not this application's — and retrying it will fail again. A
11
+ * 500, or no answer at all, is about the service, and retrying later may
12
+ * well work. The page says which, because "try again" is good advice for one
13
+ * and useless for the other.
14
+ */
15
+ function authRequestReason(error) {
16
+ return error instanceof BrokerError && error.status < 500 ? 'request' : 'unavailable';
17
+ }
6
18
  export function nextAuth(options) {
7
19
  const auth = createAuth(options);
8
20
  const proxy = async (request, rules) => {
@@ -39,16 +51,44 @@ export function nextAuth(options) {
39
51
  redirect(auth.startUrl(next));
40
52
  return user;
41
53
  };
54
+ /**
55
+ * The auth request the issuer sent the browser here with.
56
+ *
57
+ * **No id starts a flow; an id that will not resolve does not.** The
58
+ * distinction is the whole of this function, and getting it wrong is not a
59
+ * degraded page but an infinite one. Starting a flow because resolution
60
+ * failed sends the browser to the issuer, which sends it back here with a
61
+ * fresh id, which fails the same way — two requests to the issuer per lap,
62
+ * no error page, nothing in a log, forever. That is not hypothetical: a
63
+ * client id missing from the broker's registry produced exactly this on
64
+ * manage.wtfalch.dev, and the visible symptom was Cloudflare rate-limiting
65
+ * the operator, twenty minutes before anybody could see a 403 underneath
66
+ * it. The refusal was diagnosable from the first request and the loop hid
67
+ * it behind thousands.
68
+ *
69
+ * So a failure lands on `onError` with a reason, exactly as the callback's
70
+ * own failures do, and the person clicks to try again. A human click cannot
71
+ * loop. The reason distinguishes the two cases worth telling apart: the
72
+ * broker refused this request (unknown, expired, or belonging to another
73
+ * application) or the broker could not be reached at all.
74
+ *
75
+ * The cause goes to the server log either way. A library swallowing the one
76
+ * error that explains the page is what made this expensive.
77
+ */
42
78
  const authRequest = async (searchParams, opts = {}) => {
43
79
  const params = await searchParams;
44
80
  const id = typeof params.authRequest === 'string' ? params.authRequest : null;
45
- if (id) {
46
- try {
47
- return await auth.authRequest(id);
48
- }
49
- catch { }
81
+ // Nobody has started a flow yet. This cannot loop: the issuer always
82
+ // sends the browser back carrying an id.
83
+ if (!id)
84
+ redirect(auth.startUrl(opts.next, opts.intent));
85
+ try {
86
+ return await auth.authRequest(id);
87
+ }
88
+ catch (error) {
89
+ console.error('@wtfalch/auth: could not resolve the auth request', error);
90
+ redirect(auth.errorUrl(authRequestReason(error)));
50
91
  }
51
- redirect(auth.startUrl(opts.next, opts.intent));
52
92
  };
53
93
  // A server action cannot redirect into a route handler, so the callback runs here and the cookies are set on the action's response.
54
94
  const finish = async (callbackUrl) => {
@@ -78,19 +118,27 @@ export function nextAuth(options) {
78
118
  else
79
119
  store.delete({ name: c.name, path: '/' });
80
120
  }
121
+ if (result.hosted)
122
+ redirect(result.location);
81
123
  const url = new URL(result.location);
82
124
  redirect(`${url.pathname}${url.search}`);
83
125
  };
84
126
  const signIn = async (input) => {
85
127
  const result = await auth.signIn(input);
86
- if (result.ok)
128
+ if (result.ok) {
129
+ if (result.hosted)
130
+ redirect(result.redirectTo);
87
131
  return finish(result.redirectTo);
132
+ }
88
133
  return { error: result.error };
89
134
  };
90
135
  const signUp = async (input) => {
91
136
  const result = await auth.signUp(input);
92
- if (result.ok)
137
+ if (result.ok) {
138
+ if (result.hosted)
139
+ redirect(result.redirectTo);
93
140
  return finish(result.redirectTo);
141
+ }
94
142
  return { error: result.error, message: result.message };
95
143
  };
96
144
  return {
@@ -106,6 +154,8 @@ export function nextAuth(options) {
106
154
  requestPasswordReset: auth.requestPasswordReset,
107
155
  sendLink: auth.sendLink,
108
156
  invite: auth.invite,
157
+ people: auth.people,
158
+ setPersonActive: auth.setPersonActive,
109
159
  resetPassword,
110
160
  };
111
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Sign in against auth.wtfalch.dev from a Next.js app.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,9 +8,7 @@
8
8
  "directory": "packages/auth"
9
9
  },
10
10
  "type": "module",
11
- "files": [
12
- "dist"
13
- ],
11
+ "files": ["dist"],
14
12
  "exports": {
15
13
  ".": {
16
14
  "types": "./dist/index.d.ts",
@@ -25,6 +23,12 @@
25
23
  "engines": {
26
24
  "node": ">=22.0.0"
27
25
  },
26
+ "scripts": {
27
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
28
+ "prepack": "pnpm build",
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "vitest run"
31
+ },
28
32
  "dependencies": {
29
33
  "jose": "^6.2.10",
30
34
  "openid-client": "6.8.7"
@@ -40,16 +44,11 @@
40
44
  "devDependencies": {
41
45
  "@types/node": "^22",
42
46
  "@types/react": "^19",
47
+ "@wtfalch/auth-broker": "workspace:*",
43
48
  "next": "^16",
44
49
  "playwright-core": "^1.62.1",
45
50
  "react": "^19",
46
51
  "typescript": "^5.9.0",
47
- "vitest": "^4.1.6",
48
- "@wtfalch/auth-broker": "0.1.0"
49
- },
50
- "scripts": {
51
- "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
- "typecheck": "tsc --noEmit",
53
- "test": "vitest run"
52
+ "vitest": "^4.1.6"
54
53
  }
55
- }
54
+ }