@wtfalch/auth 0.22.0 → 0.24.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
@@ -323,7 +323,7 @@ export interface Auth {
323
323
  * `maxAge` becomes OIDC `max_age`: the issuer re-authenticates anybody whose
324
324
  * last authentication is older than that many seconds.
325
325
  */
326
- startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge): string;
326
+ startUrl(next?: string | null, intent?: Intent, prompt?: Prompt, maxAge?: MaxAge, loginHint?: string): string;
327
327
  /**
328
328
  * Whether `user` authenticated within `maxAge` seconds. A user with no
329
329
  * `authTime` is never fresh: the issuer only has to send the claim when the
package/dist/auth.js CHANGED
@@ -6,7 +6,7 @@ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrT
6
6
  import { namespaceSessions } from './namespace-session.js';
7
7
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
8
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
9
- import { safeNextPath } from './redirect.js';
9
+ import { safeLoginHint, safeNextPath } from './redirect.js';
10
10
  export function createAuth(input) {
11
11
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
12
12
  let resolved = null;
@@ -94,7 +94,7 @@ export function createAuth(input) {
94
94
  .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
95
95
  };
96
96
  const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
97
- const startUrl = (next, intent = 'login', prompt, maxAge) => {
97
+ const startUrl = (next, intent = 'login', prompt, maxAge, loginHint) => {
98
98
  const url = new URL(`${options().basePath}/start`, options().appUrl);
99
99
  const path = safeNextPath(next, '');
100
100
  if (path)
@@ -107,6 +107,9 @@ export function createAuth(input) {
107
107
  assertMaxAge(maxAge);
108
108
  url.searchParams.set('max_age', String(maxAge));
109
109
  }
110
+ const hint = safeLoginHint(loginHint);
111
+ if (hint)
112
+ url.searchParams.set('login_hint', hint);
110
113
  return url.href;
111
114
  };
112
115
  const fresh = (user, maxAge) => {
@@ -128,7 +131,8 @@ export function createAuth(input) {
128
131
  const silent = (await openSilent(options(), cookieFrom(request.headers.get('cookie'), silentCookieName(options())))) === 'try' &&
129
132
  !params.has('intent') &&
130
133
  !params.has('prompt') &&
131
- !params.has('max_age');
134
+ !params.has('max_age') &&
135
+ !params.has('login_hint');
132
136
  const state = client.randomState();
133
137
  const nonce = client.randomNonce();
134
138
  const codeVerifier = client.randomPKCECodeVerifier();
@@ -136,6 +140,7 @@ export function createAuth(input) {
136
140
  create: params.get('intent') === 'register',
137
141
  prompt: silent ? 'none' : promptFrom(params.get('prompt')),
138
142
  maxAge: maxAgeFrom(params.get('max_age')),
143
+ loginHint: safeLoginHint(params.get('login_hint')),
139
144
  });
140
145
  const transaction = await sealTransaction(options(), {
141
146
  st: state,
@@ -503,7 +508,9 @@ export function createAuth(input) {
503
508
  return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
504
509
  };
505
510
  const hostedUrl = (id) => {
506
- const url = new URL('/login', options().namespace?.loginOrigin ?? options().issuer);
511
+ const url = options().namespace
512
+ ? new URL('/login', options().namespace?.loginOrigin)
513
+ : new URL('login', options().signInBase);
507
514
  url.searchParams.set('authRequest', id);
508
515
  return url.href;
509
516
  };
package/dist/browser.d.ts CHANGED
@@ -160,7 +160,9 @@ export interface BrowserAuth {
160
160
  trySilentSignIn(returnTo?: string): Promise<boolean>;
161
161
  /** Leave for the issuer, expecting to be asked for credentials. Does not
162
162
  * return. */
163
- signIn(returnTo?: string): Promise<void>;
163
+ signIn(returnTo?: string, opts?: {
164
+ loginHint?: string;
165
+ }): Promise<void>;
164
166
  /** Finish the flow at the callback path. */
165
167
  completeSignIn(search?: string): Promise<SignInOutcome>;
166
168
  /**
package/dist/browser.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createNamespaceBrowserAuth } from './namespace-browser.js';
2
+ import { safeLoginHint } from './redirect.js';
2
3
  const TOKEN = 'wtfalch.auth.token';
3
4
  const VERIFIER = 'wtfalch.auth.verifier';
4
5
  /** CSRF protection on the callback: checked before the code exchange, never persisted past it. */
@@ -137,7 +138,7 @@ export function createBrowserAuth(options) {
137
138
  error: 'This refresh was cancelled',
138
139
  });
139
140
  const open = options.open ?? ((url) => window.location.assign(url));
140
- const authorize = async (prompt, returnTo) => {
141
+ const authorize = async (prompt, returnTo, loginHint) => {
141
142
  const generation = ++coordinator.generation;
142
143
  const proof = verifier();
143
144
  const state = verifier();
@@ -175,6 +176,9 @@ export function createBrowserAuth(options) {
175
176
  url.searchParams.set('nonce', nonce);
176
177
  if (prompt)
177
178
  url.searchParams.set('prompt', prompt);
179
+ const hint = safeLoginHint(loginHint);
180
+ if (hint)
181
+ url.searchParams.set('login_hint', hint);
178
182
  if (!current(generation))
179
183
  return false;
180
184
  await open(url.toString());
@@ -261,8 +265,8 @@ export function createBrowserAuth(options) {
261
265
  return false;
262
266
  return authorize('none', returnTo);
263
267
  },
264
- async signIn(returnTo = window.location.pathname) {
265
- await authorize(undefined, returnTo);
268
+ async signIn(returnTo = window.location.pathname, opts) {
269
+ await authorize(undefined, returnTo, opts?.loginHint);
266
270
  },
267
271
  async completeSignIn(search = window.location.search) {
268
272
  const generation = coordinator.generation;
package/dist/config.d.ts CHANGED
@@ -93,6 +93,15 @@ export interface AuthOptions {
93
93
  cookieSecret?: string | undefined;
94
94
  /** e.g. https://auth.example.com. Required -- there is no estate-wide default. */
95
95
  issuer: string | undefined;
96
+ /**
97
+ * Where the hosted login lives, when it is not at the issuer's origin: an
98
+ * app that proxies the login under its own address passes e.g.
99
+ * `https://agora.example.com/sign-in`, and the hosted login URL becomes
100
+ * `https://agora.example.com/sign-in/login`. https, or http for localhost
101
+ * only; no query, fragment or credentials. Default: the issuer's origin,
102
+ * which is what the SDK has always used.
103
+ */
104
+ signInBase?: string;
96
105
  /** Where the routes are mounted. Default /auth. */
97
106
  basePath?: string;
98
107
  /** Default /. */
@@ -147,6 +156,8 @@ export interface ResolvedOptions {
147
156
  */
148
157
  readonly cookieKey: Uint8Array;
149
158
  issuer: string;
159
+ /** Absolute, always ends with "/": `new URL('login', signInBase)` is the hosted login. */
160
+ signInBase: string;
150
161
  basePath: string;
151
162
  afterLogin: string;
152
163
  afterLogout: string;
package/dist/config.js CHANGED
@@ -47,6 +47,7 @@ export function resolveOptions(options) {
47
47
  return cookieKey;
48
48
  },
49
49
  issuer,
50
+ signInBase: parseSignInBase(options.signInBase, issuer),
50
51
  basePath,
51
52
  afterLogin: options.afterLogin ?? '/',
52
53
  afterLogout: options.afterLogout ?? '/',
@@ -91,6 +92,25 @@ function parseAppUrl(raw) {
91
92
  }
92
93
  return url;
93
94
  }
95
+ function parseSignInBase(raw, issuer) {
96
+ if (raw === undefined)
97
+ return `${new URL(issuer).origin}/`;
98
+ let url;
99
+ try {
100
+ url = new URL(raw);
101
+ }
102
+ catch {
103
+ throw new Error(`@wtfalch/auth: signInBase must be an absolute URL, got "${raw}"`);
104
+ }
105
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
106
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
107
+ throw new Error(`@wtfalch/auth: signInBase must be https (http only for localhost), got "${raw}"`);
108
+ }
109
+ if (raw.includes('?') || raw.includes('#') || url.username || url.password) {
110
+ throw new Error(`@wtfalch/auth: signInBase takes no query, fragment or credentials, got "${raw}"`);
111
+ }
112
+ return url.href.endsWith('/') ? url.href : `${url.href}/`;
113
+ }
94
114
  function decodeKey(secret) {
95
115
  required(secret, 'cookieSecret');
96
116
  let bytes = new Uint8Array(0);
package/dist/index.d.ts CHANGED
@@ -5,4 +5,4 @@ export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
5
5
  export type { PersonTokenConfig, PersonTokenVerifier } from './person-token.js';
6
6
  export { BrokerError } from './broker.js';
7
7
  export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
8
- export { safeNextPath } from './redirect.js';
8
+ export { safeLoginHint, safeNextPath } from './redirect.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { STEP_UP_MAX_AGE, createAuth, } from './auth.js';
2
2
  export { BrokerError } from './broker.js';
3
3
  export { AuthError, ORG_CLAIM } from './oidc.js';
4
- export { safeNextPath } from './redirect.js';
4
+ export { safeLoginHint, safeNextPath } from './redirect.js';
package/dist/names.d.ts CHANGED
@@ -38,6 +38,14 @@ export interface NameRules {
38
38
  pattern: RegExp;
39
39
  }
40
40
  export declare const DEFAULT_RULES: NameRules;
41
+ /**
42
+ * Lowercase letters with the digits read as letters and nothing else, runs
43
+ * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
44
+ * the one a list entry is stored under.
45
+ */
46
+ export declare function flattenings(name: string): string[];
47
+ /** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
48
+ export declare function reservedNames(): readonly string[];
41
49
  /** The refusal as a sentence the person can act on. */
42
50
  export declare function explain(reason: Refusal, rules?: NameRules): string;
43
51
  export declare function checkName(name: string, rules?: NameRules): Verdict;
package/dist/names.js CHANGED
@@ -35,7 +35,10 @@ strictWords.addPhrase((phrase) => {
35
35
  phrase.addWhitelistedTerm(term);
36
36
  return phrase;
37
37
  });
38
- const strict = new RegExpMatcher({ ...strictWords.build(), ...englishRecommendedTransformers });
38
+ const strict = new RegExpMatcher({
39
+ ...strictWords.build(),
40
+ ...englishRecommendedTransformers,
41
+ });
39
42
  /** Only the words that are slurs wherever they appear, read with everything between the letters skipped. */
40
43
  const looseWords = new DataSet();
41
44
  for (const entry of [
@@ -84,7 +87,7 @@ const LETTERS = {
84
87
  * collapsed. Every reading, since `1` may be an `i` or an `l`; the first is
85
88
  * the one a list entry is stored under.
86
89
  */
87
- function flattenings(name) {
90
+ export function flattenings(name) {
88
91
  let forms = [''];
89
92
  for (const char of name.toLowerCase()) {
90
93
  const readings = /[a-z]/.test(char) ? [char] : (LETTERS[char] ?? []);
@@ -96,7 +99,12 @@ function flattenings(name) {
96
99
  }
97
100
  return [...new Set(forms.map((form) => form.replace(/(.)\1+/g, '$1')))];
98
101
  }
99
- const reserved = new Set([...reservedUsernames, ...SYSTEM, ...PEOPLE].map((entry) => flattenings(entry)[0]));
102
+ const RESERVED = [...new Set([...reservedUsernames, ...SYSTEM, ...PEOPLE])];
103
+ const reserved = new Set(RESERVED.map((entry) => flattenings(entry)[0]));
104
+ /** Every entry checkName holds back: the system names, the people's and the reserved-usernames list, once each. */
105
+ export function reservedNames() {
106
+ return RESERVED;
107
+ }
100
108
  /** The name in the shapes a word might hide in. */
101
109
  function shapes(name) {
102
110
  const parts = name.split(/[^A-Za-z0-9æøåÆØÅ]+/).filter(Boolean);
@@ -1,6 +1,6 @@
1
1
  import { createRemoteJWKSet, jwtVerify } from 'jose';
2
2
  import { resolveNamespaceContext } from './namespaces.js';
3
- import { safeNextPath } from './redirect.js';
3
+ import { safeLoginHint, safeNextPath } from './redirect.js';
4
4
  const OWNER = 'urn:zitadel:iam:user:resourceowner:id';
5
5
  const ttl = 10 * 60_000;
6
6
  const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));
@@ -75,7 +75,7 @@ export function createNamespaceBrowserAuth(options, selection) {
75
75
  : null;
76
76
  };
77
77
  const silentSignInAvailable = () => !accountChanged() && !currentSession() && !sessionStorage.getItem(triedKey);
78
- const authorize = async (prompt, returnTo) => {
78
+ const authorize = async (prompt, returnTo, loginHint) => {
79
79
  const transaction = {
80
80
  context: binding.context,
81
81
  state: random(),
@@ -99,6 +99,7 @@ export function createNamespaceBrowserAuth(options, selection) {
99
99
  code_challenge: base64url(new Uint8Array(digest)),
100
100
  code_challenge_method: 'S256',
101
101
  ...(prompt ? { prompt } : {}),
102
+ ...(safeLoginHint(loginHint) ? { login_hint: safeLoginHint(loginHint) } : {}),
102
103
  }).toString();
103
104
  if (!ownsPending(transaction.state))
104
105
  throw new Error('Another sign-in replaced this attempt');
@@ -220,9 +221,9 @@ export function createNamespaceBrowserAuth(options, selection) {
220
221
  await authorize('none', returnTo);
221
222
  return true;
222
223
  },
223
- async signIn(returnTo = window.location.pathname) {
224
+ async signIn(returnTo = window.location.pathname, opts) {
224
225
  sessionStorage.removeItem(triedKey);
225
- await authorize(undefined, returnTo);
226
+ await authorize(undefined, returnTo, opts?.loginHint);
226
227
  },
227
228
  // Matches BrowserAuth.forget()'s Promise<void> signature; this path's
228
229
  // storage is always synchronous, so there is nothing to await.
package/dist/next.d.ts CHANGED
@@ -49,6 +49,7 @@ export interface NextAuth {
49
49
  next?: string;
50
50
  intent?: Intent;
51
51
  prompt?: Prompt;
52
+ loginHint?: string;
52
53
  }) => Promise<AuthRequest>;
53
54
  /** For a server action: redirects into the app on success, returns the error otherwise. */
54
55
  signIn: (input: {
package/dist/next.js CHANGED
@@ -123,7 +123,7 @@ export function nextAuth(options) {
123
123
  // Nobody has started a flow yet. This cannot loop: the issuer always
124
124
  // sends the browser back carrying an id.
125
125
  if (!id)
126
- redirect(auth.startUrl(opts.next, opts.intent, opts.prompt));
126
+ redirect(auth.startUrl(opts.next, opts.intent, opts.prompt, undefined, opts.loginHint));
127
127
  try {
128
128
  return await auth.authRequest(id);
129
129
  }
package/dist/oidc.d.ts CHANGED
@@ -42,10 +42,11 @@ export declare class Oidc {
42
42
  private config;
43
43
  private keys;
44
44
  get redirectUri(): URL;
45
- authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, }?: {
45
+ authorizationUrl(state: string, nonce: string, codeVerifier: string, { create, prompt, maxAge, loginHint, }?: {
46
46
  create?: boolean;
47
47
  prompt?: 'select_account' | 'login' | 'none';
48
48
  maxAge?: number;
49
+ loginHint?: string;
49
50
  }): Promise<URL>;
50
51
  exchange(callbackParams: URLSearchParams, transaction: {
51
52
  state: string;
package/dist/oidc.js CHANGED
@@ -81,10 +81,11 @@ export class Oidc {
81
81
  get redirectUri() {
82
82
  return new URL(`${this.options.basePath}/callback`, this.options.appUrl);
83
83
  }
84
- async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, } = {}) {
84
+ async authorizationUrl(state, nonce, codeVerifier, { create = false, prompt, maxAge, loginHint, } = {}) {
85
85
  return client.buildAuthorizationUrl(await this.config(), {
86
86
  ...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
87
87
  ...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
88
+ ...(loginHint ? { login_hint: loginHint } : {}),
88
89
  redirect_uri: this.redirectUri.href,
89
90
  scope: scopeFor(this.options.organizationId, this.options.extraScopes),
90
91
  state,
@@ -1,2 +1,9 @@
1
1
  /** A path on this app, or the fallback. Refuses absolute, scheme-relative and backslash forms. */
2
2
  export declare function safeNextPath(next: string | null | undefined, fallback: string): string;
3
+ /**
4
+ * An email address fit to send as the OIDC `login_hint`: at most 254
5
+ * characters, one `@`, a dotted domain, no whitespace or control characters.
6
+ * Anything else is `undefined` -- a bad hint is dropped, never a failure,
7
+ * because the person can still type their address at the issuer.
8
+ */
9
+ export declare function safeLoginHint(raw: string | null | undefined): string | undefined;
package/dist/redirect.js CHANGED
@@ -21,3 +21,14 @@ export function safeNextPath(next, fallback) {
21
21
  }
22
22
  return path;
23
23
  }
24
+ /**
25
+ * An email address fit to send as the OIDC `login_hint`: at most 254
26
+ * characters, one `@`, a dotted domain, no whitespace or control characters.
27
+ * Anything else is `undefined` -- a bad hint is dropped, never a failure,
28
+ * because the person can still type their address at the issuer.
29
+ */
30
+ export function safeLoginHint(raw) {
31
+ if (typeof raw !== 'string' || raw.length > 254)
32
+ return undefined;
33
+ return /^[^\s@\p{Cc}]+@[^\s@\p{Cc}]+\.[^\s@\p{Cc}]+$/u.test(raw) ? raw : undefined;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
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",