@wtfalch/auth 0.15.0 → 0.19.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/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 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';
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
4
  import type { SessionRecord } from './sessions.js';
5
5
  type SearchParams = Record<string, string | string[] | undefined>;
@@ -57,12 +57,31 @@ export interface NextAuth {
57
57
  password: string;
58
58
  }) => Promise<{
59
59
  error: SignInError;
60
+ } | {
61
+ error: 'password_change_required';
62
+ changeToken: string;
63
+ }>;
64
+ /**
65
+ * For a server action, after `signIn` returned `password_change_required`:
66
+ * sets the person's own password with no old one, signs them in and
67
+ * redirects. Returns the error otherwise.
68
+ */
69
+ setNewPassword: (input: {
70
+ authRequestId: string;
71
+ changeToken: string;
72
+ newPassword: string;
73
+ }) => Promise<{
74
+ error: SetNewPasswordError;
60
75
  }>;
61
76
  signUp: (input: {
62
77
  authRequestId: string;
63
78
  } & NewUser) => Promise<{
64
79
  error: SignUpError;
65
80
  message: string;
81
+ } | {
82
+ error: 'password_change_required';
83
+ changeToken: string;
84
+ message: string;
66
85
  }>;
67
86
  /** Emails a fresh verification link. Never says whether the address exists. */
68
87
  resendVerification: (email: string, next?: string) => Promise<void>;
@@ -138,6 +157,9 @@ export interface NextAuth {
138
157
  credential: unknown;
139
158
  }) => Promise<{
140
159
  error: SignInError;
160
+ } | {
161
+ error: 'password_change_required';
162
+ changeToken: string;
141
163
  }>;
142
164
  /**
143
165
  * Starts a QR sign-in from a server action or route handler, setting the
@@ -157,4 +179,4 @@ export interface NextAuth {
157
179
  }>;
158
180
  }
159
181
  export declare function nextAuth(options: AuthOptions): NextAuth;
160
- export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
182
+ export type { 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
@@ -138,10 +138,7 @@ export function nextAuth(options) {
138
138
  const request = await requestFromCookies();
139
139
  const { location, cookies: set } = await auth.complete(callbackUrl, request.headers.get('cookie'), request.headers);
140
140
  for (const c of set) {
141
- if (c.value)
142
- store.set(c.name, c.value, c.attributes);
143
- else
144
- store.delete({ name: c.name, path: '/' });
141
+ store.set(c.name, c.value, c.attributes);
145
142
  }
146
143
  const url = new URL(location);
147
144
  redirect(`${url.pathname}${url.search}`);
@@ -184,6 +181,9 @@ export function nextAuth(options) {
184
181
  redirect(result.redirectTo);
185
182
  return finish(result.redirectTo);
186
183
  }
184
+ if (result.error === 'password_change_required') {
185
+ return { error: result.error, changeToken: result.changeToken };
186
+ }
187
187
  return { error: result.error };
188
188
  };
189
189
  const resetPassword = async (input) => {
@@ -192,10 +192,7 @@ export function nextAuth(options) {
192
192
  return { error: result.error, message: result.message };
193
193
  const store = await cookies();
194
194
  for (const c of result.cookies) {
195
- if (c.value)
196
- store.set(c.name, c.value, c.attributes);
197
- else
198
- store.delete({ name: c.name, path: '/' });
195
+ store.set(c.name, c.value, c.attributes);
199
196
  }
200
197
  if (result.hosted)
201
198
  redirect(result.location);
@@ -209,6 +206,23 @@ export function nextAuth(options) {
209
206
  redirect(result.redirectTo);
210
207
  return finish(result.redirectTo);
211
208
  }
209
+ if (result.error === 'password_change_required') {
210
+ return { error: result.error, changeToken: result.changeToken };
211
+ }
212
+ return { error: result.error };
213
+ };
214
+ const setNewPassword = async (input) => {
215
+ // Named fields: a Server Action's argument is whatever the client sent.
216
+ const result = await auth.setNewPassword({
217
+ authRequestId: String(input?.authRequestId),
218
+ changeToken: String(input?.changeToken),
219
+ newPassword: String(input?.newPassword),
220
+ });
221
+ if (result.ok) {
222
+ if (result.hosted)
223
+ redirect(result.redirectTo);
224
+ return finish(result.redirectTo);
225
+ }
212
226
  return { error: result.error };
213
227
  };
214
228
  const signUp = async (input) => {
@@ -218,16 +232,16 @@ export function nextAuth(options) {
218
232
  redirect(result.redirectTo);
219
233
  return finish(result.redirectTo);
220
234
  }
235
+ if (result.error === 'password_change_required') {
236
+ return { error: result.error, changeToken: result.changeToken, message: result.message };
237
+ }
221
238
  return { error: result.error, message: result.message };
222
239
  };
223
240
  const startQrSignIn = async () => {
224
241
  const { cookies: set, ...start } = await auth.startQrSignIn();
225
242
  const store = await cookies();
226
243
  for (const c of set) {
227
- if (c.value)
228
- store.set(c.name, c.value, c.attributes);
229
- else
230
- store.delete({ name: c.name, path: '/' });
244
+ store.set(c.name, c.value, c.attributes);
231
245
  }
232
246
  return start;
233
247
  };
@@ -235,10 +249,7 @@ export function nextAuth(options) {
235
249
  const { status, reason, cookies: set } = await auth.pollQrSignIn(await requestFromCookies());
236
250
  const store = await cookies();
237
251
  for (const c of set) {
238
- if (c.value)
239
- store.set(c.name, c.value, c.attributes);
240
- else
241
- store.delete({ name: c.name, path: '/' });
252
+ store.set(c.name, c.value, c.attributes);
242
253
  }
243
254
  return reason ? { status, reason } : { status };
244
255
  };
@@ -253,6 +264,7 @@ export function nextAuth(options) {
253
264
  requireFreshUser,
254
265
  authRequest,
255
266
  signIn,
267
+ setNewPassword,
256
268
  signUp,
257
269
  resendVerification: auth.resendVerification,
258
270
  requestPasswordReset: auth.requestPasswordReset,
package/dist/oidc.d.ts CHANGED
@@ -44,7 +44,7 @@ export declare class Oidc {
44
44
  get redirectUri(): URL;
45
45
  authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, }?: {
46
46
  create?: boolean;
47
- prompt?: 'select_account' | 'login';
47
+ prompt?: 'select_account' | 'login' | 'none';
48
48
  maxAge?: number;
49
49
  }): Promise<URL>;
50
50
  exchange(callbackParams: URLSearchParams, transaction: {
@@ -61,6 +61,7 @@ export declare class Oidc {
61
61
  endSessionUrl(idToken: string, postLogoutRedirectUri: string): Promise<URL | null>;
62
62
  private tokensFrom;
63
63
  verify(idToken: string): Promise<jose.JWTPayload>;
64
+ private assertIdentityClaims;
64
65
  private assertOrganization;
65
66
  }
66
67
  export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
package/dist/oidc.js CHANGED
@@ -147,6 +147,7 @@ export class Oidc {
147
147
  if (!response.id_token || !claims) {
148
148
  throw new AuthError('exchange', 'the issuer returned no id token');
149
149
  }
150
+ this.assertIdentityClaims(claims);
150
151
  this.assertOrganization(claims);
151
152
  const expiresIn = response.expiresIn();
152
153
  return {
@@ -163,6 +164,7 @@ export class Oidc {
163
164
  audience: this.options.clientId,
164
165
  algorithms: ['RS256'],
165
166
  clockTolerance: 60,
167
+ requiredClaims: ['exp', 'iat', 'sub'],
166
168
  });
167
169
  // ZITADEL puts the project in aud as well, so azp has to name us. Same rule openid-client applies on exchange.
168
170
  if (Array.isArray(payload.aud) &&
@@ -170,9 +172,28 @@ export class Oidc {
170
172
  payload.azp !== this.options.clientId) {
171
173
  throw new AuthError('exchange', 'the token was authorised for another party');
172
174
  }
175
+ this.assertIdentityClaims(payload);
173
176
  this.assertOrganization(payload);
174
177
  return payload;
175
178
  }
179
+ // Both grant responses and stored/device tokens become a User. Keep their
180
+ // identity and time shapes consistent, including optional freshness claims.
181
+ assertIdentityClaims(claims) {
182
+ if (typeof claims.sub !== 'string' || claims.sub.length === 0) {
183
+ throw new AuthError('exchange', 'the id token has no valid subject');
184
+ }
185
+ for (const name of ['exp', 'iat']) {
186
+ if (typeof claims[name] !== 'number' || !Number.isFinite(claims[name])) {
187
+ throw new AuthError('exchange', `the id token has no valid ${name}`);
188
+ }
189
+ }
190
+ if (Object.hasOwn(claims, 'auth_time') &&
191
+ (typeof claims.auth_time !== 'number' ||
192
+ !Number.isFinite(claims.auth_time) ||
193
+ claims.auth_time < 0)) {
194
+ throw new AuthError('exchange', 'the id token has no valid auth_time');
195
+ }
196
+ }
176
197
  assertOrganization(claims) {
177
198
  if (claims[ORG_CLAIM] !== this.options.organizationId) {
178
199
  throw new AuthError('organization', `the token's organisation ${String(claims[ORG_CLAIM])} is not this app's`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.15.0",
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.",
3
+ "version": "0.19.0",
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",
7
7
  "url": "git+https://github.com/wtfalch/auth.git",
@@ -30,6 +30,10 @@
30
30
  "types": "./dist/namespaces.d.ts",
31
31
  "default": "./dist/namespaces.js"
32
32
  },
33
+ "./names": {
34
+ "types": "./dist/names.d.ts",
35
+ "default": "./dist/names.js"
36
+ },
33
37
  "./namespace-server": {
34
38
  "types": "./dist/namespace-server.d.ts",
35
39
  "default": "./dist/namespace-server.js"
@@ -51,11 +55,14 @@
51
55
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
56
  "prepack": "pnpm build",
53
57
  "typecheck": "tsc --noEmit",
54
- "test": "vitest run"
58
+ "test": "vitest run",
59
+ "test:cookie-browser": "vitest run --config vitest.cookie-browser.config.ts"
55
60
  },
56
61
  "dependencies": {
57
62
  "jose": "^6.2.10",
58
- "openid-client": "6.8.7"
63
+ "obscenity": "^0.4.6",
64
+ "openid-client": "6.8.7",
65
+ "reserved-usernames": "^1.1.6"
59
66
  },
60
67
  "peerDependencies": {
61
68
  "next": ">=15.0.0"