@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 +1 -1
- package/dist/auth.js +11 -4
- package/dist/browser.d.ts +3 -1
- package/dist/browser.js +7 -3
- package/dist/config.d.ts +11 -0
- package/dist/config.js +20 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/names.d.ts +8 -0
- package/dist/names.js +11 -3
- package/dist/namespace-browser.js +5 -4
- package/dist/next.d.ts +1 -0
- package/dist/next.js +1 -1
- package/dist/oidc.d.ts +2 -1
- package/dist/oidc.js +2 -1
- package/dist/redirect.d.ts +7 -0
- package/dist/redirect.js +11 -0
- package/package.json +1 -1
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 =
|
|
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
|
|
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
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({
|
|
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
|
|
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
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,
|
package/dist/redirect.d.ts
CHANGED
|
@@ -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.
|
|
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",
|