@wtfalch/auth 0.1.1 → 0.3.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/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 {
@@ -37,12 +37,13 @@ export type Gate = {
37
37
  cookies: SetCookie[];
38
38
  };
39
39
  export type Intent = 'login' | 'register';
40
- export type SignInError = 'invalid_credentials' | 'request' | 'unavailable';
40
+ export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
41
41
  export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
42
- export type SignUpError = 'email_taken' | 'invalid' | 'request' | 'unavailable';
42
+ export type SignUpError = 'registration_closed' | 'email_taken' | 'username_taken' | 'username_invalid' | 'invalid' | 'request' | 'unavailable';
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,9 +288,21 @@ 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
  }
296
+ if (error instanceof BrokerError && error.error === 'registration_closed') {
297
+ return { ok: false, error: 'registration_closed', message: error.detail ?? error.error };
298
+ }
299
+ // Both carry a sentence about the name, for the form to show as it is.
300
+ if (error instanceof BrokerError && error.error === 'username_taken') {
301
+ return { ok: false, error: 'username_taken', message: error.detail ?? error.error };
302
+ }
303
+ if (error instanceof BrokerError && error.error === 'username_invalid') {
304
+ return { ok: false, error: 'username_invalid', message: error.detail ?? error.error };
305
+ }
268
306
  if (error instanceof BrokerError && error.error === 'request') {
269
307
  return { ok: false, error: 'request', message: error.detail ?? error.error };
270
308
  }
@@ -279,6 +317,8 @@ export function createAuth(input) {
279
317
  url.searchParams.set('auth_error', reason);
280
318
  return { location: url.href, cookies };
281
319
  };
320
+ /** `failure`'s location on its own, for a caller that redirects rather than answering. */
321
+ const errorUrl = (reason) => failure(reason, []).location;
282
322
  const failed = (reason, cookies) => {
283
323
  const { location } = failure(reason, []);
284
324
  return redirect(location, cookies);
@@ -303,6 +343,9 @@ export function createAuth(input) {
303
343
  readCookie,
304
344
  gate,
305
345
  startUrl,
346
+ errorUrl,
347
+ people,
348
+ setPersonActive,
306
349
  authRequest,
307
350
  signIn,
308
351
  signUp,
@@ -390,6 +433,9 @@ function signInError(error) {
390
433
  // A key the service will not take is the app's problem, not the visitor's.
391
434
  if (error instanceof BrokerError && error.error === 'unauthorized')
392
435
  return 'unavailable';
436
+ // The sign-in service's brute-force backstop tripped; ask them to wait.
437
+ if (error instanceof BrokerError && error.error === 'too_many')
438
+ return 'too_many';
393
439
  if (error instanceof BrokerError && error.status < 500)
394
440
  return 'invalid_credentials';
395
441
  return 'unavailable';
package/dist/broker.d.ts CHANGED
@@ -9,6 +9,27 @@ export interface NewUser {
9
9
  password: string;
10
10
  givenName: string;
11
11
  familyName: string;
12
+ /**
13
+ * A login name they chose, where the app asks for one. Three to thirty of
14
+ * letters, digits, `.`, `_` and `-`; the service refuses reserved names
15
+ * and words it will not have. Without it the email is the login name.
16
+ */
17
+ username?: string;
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[];
12
33
  }
13
34
  export declare class BrokerError extends Error {
14
35
  readonly status: number;
@@ -25,6 +46,14 @@ export declare class Broker {
25
46
  private readonly options;
26
47
  constructor(options: ResolvedOptions);
27
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
+ }>;
28
57
  authRequest(id: string): Promise<AuthRequest>;
29
58
  signIn(input: {
30
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/dist/redirect.js CHANGED
@@ -12,5 +12,12 @@ export function safeNextPath(next, fallback) {
12
12
  }
13
13
  if (url.origin !== 'https://app.invalid')
14
14
  return fallback;
15
- return `${url.pathname}${url.search}`;
15
+ const path = `${url.pathname}${url.search}`;
16
+ // The URL parser can normalise an input like "/..//evil" into a
17
+ // protocol-relative "//evil", which a caller resolving it against an origin
18
+ // would send to another host. Guard the OUTPUT, not just the input.
19
+ if (!path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\')) {
20
+ return fallback;
21
+ }
22
+ return path;
16
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Sign in against auth.wtfalch.dev from a Next.js app.",
5
5
  "repository": {
6
6
  "type": "git",