@wtfalch/auth 0.16.0 → 0.20.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 +102 -7
- package/dist/auth.d.ts +70 -3
- package/dist/auth.js +165 -18
- package/dist/broker.d.ts +30 -1
- package/dist/broker.js +15 -2
- package/dist/browser.d.ts +11 -9
- package/dist/browser.js +163 -71
- package/dist/config.d.ts +7 -3
- package/dist/config.js +4 -4
- package/dist/cookies.d.ts +7 -0
- package/dist/cookies.js +14 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -2
- package/dist/names.d.ts +3 -2
- package/dist/names.reserved.js +2 -0
- package/dist/next.d.ts +28 -2
- package/dist/next.js +29 -16
- package/dist/oidc.d.ts +2 -1
- package/dist/oidc.js +21 -0
- package/package.json +4 -3
package/dist/broker.d.ts
CHANGED
|
@@ -15,6 +15,18 @@ export interface NewUser {
|
|
|
15
15
|
* and words it will not have. Without it the email is the login name.
|
|
16
16
|
*/
|
|
17
17
|
username?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Creates the account with `password` as a one-time password: the person
|
|
20
|
+
* is signed in on nothing until they choose their own (`signUp` answers
|
|
21
|
+
* `password_change_required`, then `setNewPassword`).
|
|
22
|
+
*/
|
|
23
|
+
changeRequired?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Where this person's reset links go instead of their own login address --
|
|
26
|
+
* for somebody who cannot yet open the mailbox that address names. See
|
|
27
|
+
* `Auth['adminResetPassword']` for the person this does not cover.
|
|
28
|
+
*/
|
|
29
|
+
personalEmail?: string;
|
|
18
30
|
}
|
|
19
31
|
/** One of the organisation's people, as the sign-in service reports them. */
|
|
20
32
|
export interface Person {
|
|
@@ -35,7 +47,11 @@ export declare class BrokerError extends Error {
|
|
|
35
47
|
readonly status: number;
|
|
36
48
|
readonly error: string;
|
|
37
49
|
readonly detail?: string | undefined;
|
|
38
|
-
|
|
50
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
51
|
+
readonly changeToken?: string | undefined;
|
|
52
|
+
constructor(status: number, error: string, detail?: string | undefined,
|
|
53
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
54
|
+
changeToken?: string | undefined);
|
|
39
55
|
}
|
|
40
56
|
/**
|
|
41
57
|
* The privileged half, over HTTP. The app holds a key that reaches its own
|
|
@@ -54,12 +70,23 @@ export declare class Broker {
|
|
|
54
70
|
userId: string;
|
|
55
71
|
state: string;
|
|
56
72
|
}>;
|
|
73
|
+
/** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
|
|
74
|
+
adminResetPassword(userId: string): Promise<{
|
|
75
|
+
userId: string;
|
|
76
|
+
password: string;
|
|
77
|
+
}>;
|
|
57
78
|
authRequest(id: string): Promise<AuthRequest>;
|
|
58
79
|
signIn(input: {
|
|
59
80
|
authRequestId: string;
|
|
60
81
|
email: string;
|
|
61
82
|
password: string;
|
|
62
83
|
}): Promise<string>;
|
|
84
|
+
/** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
|
|
85
|
+
setNewPassword(input: {
|
|
86
|
+
authRequestId: string;
|
|
87
|
+
changeToken: string;
|
|
88
|
+
newPassword: string;
|
|
89
|
+
}): Promise<string>;
|
|
63
90
|
signUp(input: {
|
|
64
91
|
authRequestId: string;
|
|
65
92
|
} & NewUser): Promise<string>;
|
|
@@ -103,9 +130,11 @@ export declare class Broker {
|
|
|
103
130
|
givenName: string;
|
|
104
131
|
familyName: string;
|
|
105
132
|
next: string;
|
|
133
|
+
personalEmail?: string;
|
|
106
134
|
}): Promise<{
|
|
107
135
|
userId: string;
|
|
108
136
|
invited: boolean;
|
|
137
|
+
personalEmailStored: boolean;
|
|
109
138
|
}>;
|
|
110
139
|
sendLink(email: string, next: string): Promise<unknown>;
|
|
111
140
|
sendReset(email: string, next: string): Promise<unknown>;
|
package/dist/broker.js
CHANGED
|
@@ -2,11 +2,15 @@ export class BrokerError extends Error {
|
|
|
2
2
|
status;
|
|
3
3
|
error;
|
|
4
4
|
detail;
|
|
5
|
-
|
|
5
|
+
changeToken;
|
|
6
|
+
constructor(status, error, detail,
|
|
7
|
+
/** Only `password_change_required` carries one: see `setNewPassword`. */
|
|
8
|
+
changeToken) {
|
|
6
9
|
super(detail ?? error);
|
|
7
10
|
this.status = status;
|
|
8
11
|
this.error = error;
|
|
9
12
|
this.detail = detail;
|
|
13
|
+
this.changeToken = changeToken;
|
|
10
14
|
this.name = 'BrokerError';
|
|
11
15
|
}
|
|
12
16
|
}
|
|
@@ -48,7 +52,7 @@ export class Broker {
|
|
|
48
52
|
throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
|
|
49
53
|
}
|
|
50
54
|
if (!response.ok) {
|
|
51
|
-
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message);
|
|
55
|
+
throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined);
|
|
52
56
|
}
|
|
53
57
|
return data;
|
|
54
58
|
}
|
|
@@ -63,6 +67,10 @@ export class Broker {
|
|
|
63
67
|
setPersonActive(userId, active) {
|
|
64
68
|
return this.call('POST', active ? '/people/reactivate' : '/people/deactivate', { userId });
|
|
65
69
|
}
|
|
70
|
+
/** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
|
|
71
|
+
adminResetPassword(userId) {
|
|
72
|
+
return this.call('POST', '/people/reset-password', { userId });
|
|
73
|
+
}
|
|
66
74
|
authRequest(id) {
|
|
67
75
|
return this.call('GET', '/auth-request', { id });
|
|
68
76
|
}
|
|
@@ -70,6 +78,11 @@ export class Broker {
|
|
|
70
78
|
const { callbackUrl } = await this.call('POST', '/sign-in', input);
|
|
71
79
|
return callbackUrl;
|
|
72
80
|
}
|
|
81
|
+
/** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
|
|
82
|
+
async setNewPassword(input) {
|
|
83
|
+
const { callbackUrl } = await this.call('POST', '/password/set', input);
|
|
84
|
+
return callbackUrl;
|
|
85
|
+
}
|
|
73
86
|
async signUp(input) {
|
|
74
87
|
const { callbackUrl } = await this.call('POST', '/sign-up', {
|
|
75
88
|
...input,
|
package/dist/browser.d.ts
CHANGED
|
@@ -57,7 +57,7 @@ export interface BrowserAuthStore {
|
|
|
57
57
|
export interface BrowserAuthOptions {
|
|
58
58
|
/** Public namespace registry projection; never include broker or cookie secrets. */
|
|
59
59
|
namespace?: NamespaceSelection;
|
|
60
|
-
/** The issuer, e.g. `https://auth.
|
|
60
|
+
/** The issuer, e.g. `https://auth.example.com`. */
|
|
61
61
|
issuer: string;
|
|
62
62
|
/** This app's client id at the issuer. */
|
|
63
63
|
clientId: string;
|
|
@@ -84,8 +84,10 @@ export interface BrowserAuthOptions {
|
|
|
84
84
|
open?: BrowserOpener;
|
|
85
85
|
/**
|
|
86
86
|
* Holds the token, verifier, return path and silent-tried mark. Default
|
|
87
|
-
* wraps `sessionStorage`
|
|
88
|
-
*
|
|
87
|
+
* wraps `sessionStorage` with keys bound to issuer, client, origin, callback
|
|
88
|
+
* and scopes, and never throws. Custom stores retain fixed keys: supply
|
|
89
|
+
* separate backing storage per context, and reuse the same store object
|
|
90
|
+
* for instances that should coordinate writes and refreshes.
|
|
89
91
|
*/
|
|
90
92
|
store?: BrowserAuthStore;
|
|
91
93
|
}
|
|
@@ -123,10 +125,9 @@ export type RefreshOutcome = {
|
|
|
123
125
|
kind: 'no-refresh-token';
|
|
124
126
|
}
|
|
125
127
|
/**
|
|
126
|
-
* The issuer
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* returns.
|
|
128
|
+
* The issuer refused rotation, or this operation was cancelled. An issuer
|
|
129
|
+
* refusal drops the held session because the old refresh token was spent.
|
|
130
|
+
* Cancellation discards the old response and preserves any newer session.
|
|
130
131
|
*/
|
|
131
132
|
| {
|
|
132
133
|
kind: 'refused';
|
|
@@ -167,13 +168,14 @@ export interface BrowserAuth {
|
|
|
167
168
|
* `currentSession()` never carries the refresh token itself — a value
|
|
168
169
|
* passed around the app should not be able to mint new access tokens — so
|
|
169
170
|
* this is the only way to use it. A refusal drops the held session.
|
|
170
|
-
* Concurrent calls share one in-flight exchange, so
|
|
171
|
+
* Concurrent calls in the same store/context share one in-flight exchange, so callers racing
|
|
171
172
|
* near expiry both get the freshly rotated session rather than one losing
|
|
172
173
|
* to the other's now-spent refresh token.
|
|
173
174
|
*/
|
|
174
175
|
refresh(): Promise<RefreshOutcome>;
|
|
175
176
|
/** Drop the session, and do not silently take another in this tab. Awaits
|
|
176
|
-
* the store's writes
|
|
177
|
+
* the store's writes and invalidates pending sign-in/refresh completions.
|
|
178
|
+
* After it resolves, an older write cannot restore the forgotten session. */
|
|
177
179
|
forget(): Promise<void>;
|
|
178
180
|
}
|
|
179
181
|
export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
|
package/dist/browser.js
CHANGED
|
@@ -51,6 +51,9 @@ function publicSession(session) {
|
|
|
51
51
|
const { refreshToken, ...rest } = session;
|
|
52
52
|
return rest;
|
|
53
53
|
}
|
|
54
|
+
// Instances using the same store and context share cancellation and refreshes.
|
|
55
|
+
// The network is outside this queue: forget must not wait for a token response.
|
|
56
|
+
const coordinators = new WeakMap();
|
|
54
57
|
export function createBrowserAuth(options) {
|
|
55
58
|
if (options.namespace) {
|
|
56
59
|
// The namespace path coordinates a shared session across tabs and
|
|
@@ -69,11 +72,67 @@ export function createBrowserAuth(options) {
|
|
|
69
72
|
const scope = Array.from(new Set([...baseScope.split(/\s+/).filter(Boolean), ...extraScopes])).join(' ');
|
|
70
73
|
const redirectUri = `${origin}${callbackPath}`;
|
|
71
74
|
const store = options.store ?? defaultStore;
|
|
75
|
+
// Custom stores retain their established keys. Their owner supplies a separate
|
|
76
|
+
// store per context; default storage is bound to the complete client context.
|
|
77
|
+
const context = options.store
|
|
78
|
+
? ''
|
|
79
|
+
: JSON.stringify([
|
|
80
|
+
options.issuer.replace(/\/$/, ''),
|
|
81
|
+
options.clientId,
|
|
82
|
+
new URL(origin).origin,
|
|
83
|
+
redirectUri,
|
|
84
|
+
scope.split(' ').sort(),
|
|
85
|
+
]);
|
|
86
|
+
const key = (name) => (context ? `wtfalch.auth.browser.v1:${context}:${name}` : name);
|
|
87
|
+
const tokenKey = key(TOKEN);
|
|
88
|
+
const verifierKey = key(VERIFIER);
|
|
89
|
+
const returnKey = key(RETURN);
|
|
90
|
+
const triedKey = key(TRIED);
|
|
91
|
+
let contexts = coordinators.get(store);
|
|
92
|
+
if (!contexts) {
|
|
93
|
+
contexts = new Map();
|
|
94
|
+
coordinators.set(store, contexts);
|
|
95
|
+
}
|
|
96
|
+
let shared = contexts.get(context);
|
|
97
|
+
if (!shared) {
|
|
98
|
+
shared = { generation: 0, writes: Promise.resolve() };
|
|
99
|
+
contexts.set(context, shared);
|
|
100
|
+
}
|
|
101
|
+
const coordinator = shared;
|
|
102
|
+
const mutate = (operation) => {
|
|
103
|
+
const result = coordinator.writes.then(operation);
|
|
104
|
+
coordinator.writes = result.then(() => { }, () => { });
|
|
105
|
+
return result;
|
|
106
|
+
};
|
|
107
|
+
const current = (generation) => coordinator.generation === generation;
|
|
108
|
+
const cancelled = () => ({
|
|
109
|
+
kind: 'error',
|
|
110
|
+
error: 'This sign-in was cancelled; start again',
|
|
111
|
+
});
|
|
112
|
+
const cancelledRefresh = () => ({
|
|
113
|
+
kind: 'refused',
|
|
114
|
+
error: 'This refresh was cancelled',
|
|
115
|
+
});
|
|
72
116
|
const open = options.open ?? ((url) => window.location.assign(url));
|
|
73
117
|
const authorize = async (prompt, returnTo) => {
|
|
118
|
+
const generation = ++coordinator.generation;
|
|
74
119
|
const proof = verifier();
|
|
75
|
-
await
|
|
76
|
-
|
|
120
|
+
await mutate(async () => {
|
|
121
|
+
if (!current(generation))
|
|
122
|
+
return;
|
|
123
|
+
if (prompt)
|
|
124
|
+
await store.set(triedKey, '1');
|
|
125
|
+
else
|
|
126
|
+
await store.drop(triedKey);
|
|
127
|
+
if (!current(generation))
|
|
128
|
+
return;
|
|
129
|
+
await store.set(verifierKey, proof);
|
|
130
|
+
if (!current(generation))
|
|
131
|
+
return;
|
|
132
|
+
await store.set(returnKey, returnTo);
|
|
133
|
+
});
|
|
134
|
+
if (!current(generation))
|
|
135
|
+
return false;
|
|
77
136
|
const url = new URL(`${options.issuer}/oauth/v2/authorize`);
|
|
78
137
|
url.searchParams.set('client_id', options.clientId);
|
|
79
138
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
@@ -83,10 +142,13 @@ export function createBrowserAuth(options) {
|
|
|
83
142
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
84
143
|
if (prompt)
|
|
85
144
|
url.searchParams.set('prompt', prompt);
|
|
145
|
+
if (!current(generation))
|
|
146
|
+
return false;
|
|
86
147
|
await open(url.toString());
|
|
148
|
+
return true;
|
|
87
149
|
};
|
|
88
150
|
const readStored = () => {
|
|
89
|
-
const raw = store.get(
|
|
151
|
+
const raw = store.get(tokenKey);
|
|
90
152
|
if (!raw)
|
|
91
153
|
return null;
|
|
92
154
|
try {
|
|
@@ -102,13 +164,12 @@ export function createBrowserAuth(options) {
|
|
|
102
164
|
return null;
|
|
103
165
|
return session.expiresAt - 60_000 > Date.now() ? publicSession(session) : null;
|
|
104
166
|
};
|
|
105
|
-
const silentSignInAvailable = () => !currentSession() && !store.get(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const performRefresh = async () => {
|
|
167
|
+
const silentSignInAvailable = () => !currentSession() && !store.get(triedKey);
|
|
168
|
+
const performRefresh = async (generation) => {
|
|
169
|
+
// Wait for earlier store operations, without occupying the write queue while fetching.
|
|
170
|
+
await coordinator.writes;
|
|
171
|
+
if (!current(generation))
|
|
172
|
+
return cancelledRefresh();
|
|
112
173
|
const session = readStored();
|
|
113
174
|
if (!session?.refreshToken)
|
|
114
175
|
return { kind: 'no-refresh-token' };
|
|
@@ -121,29 +182,42 @@ export function createBrowserAuth(options) {
|
|
|
121
182
|
refresh_token: session.refreshToken,
|
|
122
183
|
}),
|
|
123
184
|
});
|
|
185
|
+
if (!current(generation))
|
|
186
|
+
return cancelledRefresh();
|
|
124
187
|
if (!response.ok) {
|
|
125
|
-
// ZITADEL spends the refresh token on every use, whether it accepts
|
|
126
|
-
// or refuses it: either way the old one cannot be tried again, so
|
|
127
|
-
// the held session goes with it.
|
|
128
|
-
await store.drop(TOKEN);
|
|
129
188
|
const body = await response.text();
|
|
130
|
-
return {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
189
|
+
return mutate(async () => {
|
|
190
|
+
if (!current(generation))
|
|
191
|
+
return cancelledRefresh();
|
|
192
|
+
await store.drop(tokenKey);
|
|
193
|
+
if (!current(generation))
|
|
194
|
+
return cancelledRefresh();
|
|
195
|
+
return {
|
|
196
|
+
kind: 'refused',
|
|
197
|
+
error: `the issuer refused the refresh (${response.status}): ${body.slice(0, 200)}`,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
134
200
|
}
|
|
135
201
|
const token = (await response.json());
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
202
|
+
return mutate(async () => {
|
|
203
|
+
if (!current(generation))
|
|
204
|
+
return cancelledRefresh();
|
|
205
|
+
if (!token.access_token || !token.refresh_token) {
|
|
206
|
+
await store.drop(tokenKey);
|
|
207
|
+
if (!current(generation))
|
|
208
|
+
return cancelledRefresh();
|
|
209
|
+
return { kind: 'refused', error: 'the issuer returned an incomplete refresh response' };
|
|
210
|
+
}
|
|
211
|
+
const next = {
|
|
212
|
+
accessToken: token.access_token,
|
|
213
|
+
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
214
|
+
refreshToken: token.refresh_token,
|
|
215
|
+
};
|
|
216
|
+
await store.set(tokenKey, JSON.stringify(next));
|
|
217
|
+
if (!current(generation))
|
|
218
|
+
return cancelledRefresh();
|
|
219
|
+
return { kind: 'refreshed', session: publicSession(next) };
|
|
220
|
+
});
|
|
147
221
|
};
|
|
148
222
|
return {
|
|
149
223
|
accountChanged: () => false,
|
|
@@ -152,30 +226,32 @@ export function createBrowserAuth(options) {
|
|
|
152
226
|
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
153
227
|
if (!silentSignInAvailable())
|
|
154
228
|
return false;
|
|
155
|
-
|
|
156
|
-
await authorize('none', returnTo);
|
|
157
|
-
return true;
|
|
229
|
+
return authorize('none', returnTo);
|
|
158
230
|
},
|
|
159
231
|
async signIn(returnTo = window.location.pathname) {
|
|
160
|
-
// A deliberate sign-in clears the mark: whatever the issuer said before,
|
|
161
|
-
// the person is asking now.
|
|
162
|
-
await store.drop(TRIED);
|
|
163
232
|
await authorize(undefined, returnTo);
|
|
164
233
|
},
|
|
165
234
|
async completeSignIn(search = window.location.search) {
|
|
235
|
+
const generation = coordinator.generation;
|
|
166
236
|
const params = new URLSearchParams(search);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
237
|
+
// Reading and consuming the proof is one queued operation, so recreated
|
|
238
|
+
// instances cannot consume the same transaction while its drop is pending.
|
|
239
|
+
const transaction = await mutate(async () => {
|
|
240
|
+
if (!current(generation))
|
|
241
|
+
return null;
|
|
242
|
+
const proof = store.get(verifierKey);
|
|
243
|
+
const returnTo = store.get(returnKey) ?? '/';
|
|
244
|
+
await store.drop(verifierKey);
|
|
245
|
+
if (!current(generation))
|
|
246
|
+
return null;
|
|
247
|
+
await store.drop(returnKey);
|
|
248
|
+
return { proof, returnTo };
|
|
249
|
+
});
|
|
250
|
+
if (!transaction || !current(generation))
|
|
251
|
+
return cancelled();
|
|
252
|
+
const { proof, returnTo } = transaction;
|
|
174
253
|
const issuerError = params.get('error');
|
|
175
254
|
if (issuerError) {
|
|
176
|
-
// The three the spec gives for "I would have had to ask them
|
|
177
|
-
// something". Any of them answers a silent attempt rather than
|
|
178
|
-
// failing it.
|
|
179
255
|
if (['login_required', 'interaction_required', 'consent_required'].includes(issuerError)) {
|
|
180
256
|
return { kind: 'silent-refused' };
|
|
181
257
|
}
|
|
@@ -184,12 +260,11 @@ export function createBrowserAuth(options) {
|
|
|
184
260
|
const code = params.get('code');
|
|
185
261
|
if (!code)
|
|
186
262
|
return { kind: 'error', error: 'the issuer sent no code' };
|
|
187
|
-
if (!proof)
|
|
263
|
+
if (!proof)
|
|
188
264
|
return {
|
|
189
265
|
kind: 'error',
|
|
190
266
|
error: 'this tab has no record of starting a sign-in — start again from the app',
|
|
191
267
|
};
|
|
192
|
-
}
|
|
193
268
|
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
194
269
|
method: 'POST',
|
|
195
270
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
@@ -201,42 +276,59 @@ export function createBrowserAuth(options) {
|
|
|
201
276
|
code_verifier: proof,
|
|
202
277
|
}),
|
|
203
278
|
});
|
|
279
|
+
if (!current(generation))
|
|
280
|
+
return cancelled();
|
|
204
281
|
if (!response.ok) {
|
|
205
282
|
const body = await response.text();
|
|
283
|
+
if (!current(generation))
|
|
284
|
+
return cancelled();
|
|
206
285
|
return {
|
|
207
286
|
kind: 'error',
|
|
208
287
|
error: `the issuer refused the code (${response.status}): ${body.slice(0, 200)}`,
|
|
209
288
|
};
|
|
210
289
|
}
|
|
211
290
|
const token = (await response.json());
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
291
|
+
return mutate(async () => {
|
|
292
|
+
if (!current(generation))
|
|
293
|
+
return cancelled();
|
|
294
|
+
if (!token.access_token)
|
|
295
|
+
return { kind: 'error', error: 'the issuer returned no access token' };
|
|
296
|
+
const session = {
|
|
297
|
+
accessToken: token.access_token,
|
|
298
|
+
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
299
|
+
refreshToken: token.refresh_token,
|
|
300
|
+
};
|
|
301
|
+
await store.set(tokenKey, JSON.stringify(session));
|
|
302
|
+
if (!current(generation))
|
|
303
|
+
return cancelled();
|
|
304
|
+
await store.drop(triedKey);
|
|
305
|
+
if (!current(generation))
|
|
306
|
+
return cancelled();
|
|
307
|
+
return { kind: 'signed-in', session: publicSession(session), returnTo };
|
|
308
|
+
});
|
|
224
309
|
},
|
|
225
310
|
async refresh() {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
311
|
+
const generation = coordinator.generation;
|
|
312
|
+
if (coordinator.refreshing?.generation !== generation) {
|
|
313
|
+
const promise = performRefresh(generation).finally(() => {
|
|
314
|
+
if (coordinator.refreshing?.promise === promise)
|
|
315
|
+
coordinator.refreshing = undefined;
|
|
316
|
+
});
|
|
317
|
+
coordinator.refreshing = { generation, promise };
|
|
318
|
+
}
|
|
319
|
+
return coordinator.refreshing.promise;
|
|
234
320
|
},
|
|
235
321
|
async forget() {
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
322
|
+
// Invalidate synchronously, before the first async store call. A write
|
|
323
|
+
// already underway finishes before this queued cleanup, so after forget
|
|
324
|
+
// resolves it cannot resurrect the old session or clear the tried mark.
|
|
325
|
+
++coordinator.generation;
|
|
326
|
+
await mutate(async () => {
|
|
327
|
+
await store.drop(tokenKey);
|
|
328
|
+
await store.drop(verifierKey);
|
|
329
|
+
await store.drop(returnKey);
|
|
330
|
+
await store.set(triedKey, '1');
|
|
331
|
+
});
|
|
240
332
|
},
|
|
241
333
|
};
|
|
242
334
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export type AuthEvent = {
|
|
|
19
19
|
email: string;
|
|
20
20
|
userId: string;
|
|
21
21
|
invited: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Never the password: an audit record of who was reset, not a copy of the credential. */
|
|
24
|
+
| {
|
|
25
|
+
type: 'adminResetPassword';
|
|
26
|
+
userId: string;
|
|
22
27
|
};
|
|
23
28
|
/**
|
|
24
29
|
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
@@ -82,8 +87,8 @@ export interface AuthOptions {
|
|
|
82
87
|
* the key.
|
|
83
88
|
*/
|
|
84
89
|
cookieSecret?: string | undefined;
|
|
85
|
-
/**
|
|
86
|
-
issuer
|
|
90
|
+
/** e.g. https://auth.example.com. Required -- there is no estate-wide default. */
|
|
91
|
+
issuer: string | undefined;
|
|
87
92
|
/** Where the routes are mounted. Default /auth. */
|
|
88
93
|
basePath?: string;
|
|
89
94
|
/** Default /. */
|
|
@@ -152,5 +157,4 @@ export interface ResolvedOptions {
|
|
|
152
157
|
fetch: typeof fetch;
|
|
153
158
|
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
154
159
|
}
|
|
155
|
-
export declare const ISSUER = "https://auth.wtfalch.dev";
|
|
156
160
|
export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { resolveNamespaceContext } from './namespaces.js';
|
|
2
|
-
export const ISSUER = 'https://auth.wtfalch.dev';
|
|
3
2
|
export function resolveOptions(options) {
|
|
4
3
|
const appUrl = parseAppUrl(options.appUrl);
|
|
5
4
|
required(options.clientId, 'clientId');
|
|
6
5
|
required(options.organizationId, 'organizationId');
|
|
6
|
+
required(options.issuer, 'issuer');
|
|
7
7
|
const basePath = options.basePath ?? '/auth';
|
|
8
8
|
if (!basePath.startsWith('/') || basePath.endsWith('/')) {
|
|
9
9
|
throw new Error(`@wtfalch/auth: basePath must start with "/" and not end with one, got "${basePath}"`);
|
|
@@ -20,7 +20,7 @@ export function resolveOptions(options) {
|
|
|
20
20
|
*/
|
|
21
21
|
if (options.cookieSecret !== undefined)
|
|
22
22
|
decodeKey(options.cookieSecret);
|
|
23
|
-
const issuer =
|
|
23
|
+
const issuer = options.issuer.replace(/\/$/, '');
|
|
24
24
|
const namespace = options.namespace
|
|
25
25
|
? resolveNamespaceContext(options.namespace, {
|
|
26
26
|
issuer,
|
|
@@ -46,14 +46,14 @@ export function resolveOptions(options) {
|
|
|
46
46
|
cookieKey ??= decodeKey(options.cookieSecret);
|
|
47
47
|
return cookieKey;
|
|
48
48
|
},
|
|
49
|
-
issuer
|
|
49
|
+
issuer,
|
|
50
50
|
basePath,
|
|
51
51
|
afterLogin: options.afterLogin ?? '/',
|
|
52
52
|
afterLogout: options.afterLogout ?? '/',
|
|
53
53
|
onError: options.onError ?? '/',
|
|
54
54
|
afterVerify: options.afterVerify ?? '/',
|
|
55
55
|
appKey: options.appKey || null,
|
|
56
|
-
brokerUrl: (options.brokerUrl ?? `${
|
|
56
|
+
brokerUrl: (options.brokerUrl ?? `${issuer}/api`).replace(/\/$/, ''),
|
|
57
57
|
sessionMaxAge: seconds(options.sessionMaxAge, 'sessionMaxAge', 30 * 24 * 60 * 60),
|
|
58
58
|
refreshWindow: seconds(options.refreshWindow, 'refreshWindow', 5 * 60),
|
|
59
59
|
freshness: seconds(options.freshness, 'freshness', 5 * 60),
|
package/dist/cookies.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface TransactionPayload extends JWTPayload {
|
|
|
15
15
|
nc: string;
|
|
16
16
|
cv: string;
|
|
17
17
|
nx: string;
|
|
18
|
+
/** The leg was a silent pass (`prompt=none`): an error coming back falls back to interactive sign-in. */
|
|
19
|
+
sl?: true;
|
|
18
20
|
}
|
|
19
21
|
export interface CookieAttributes {
|
|
20
22
|
path: '/';
|
|
@@ -57,6 +59,11 @@ export declare function digest(value: string): Promise<string>;
|
|
|
57
59
|
export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
58
60
|
export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
|
|
59
61
|
export declare function clearTransaction(options: ResolvedOptions): SetCookie;
|
|
62
|
+
export declare function silentCookieName(options: ResolvedOptions): string;
|
|
63
|
+
/** `try`: the gate asked for one silent pass. `done`: it ran, so every later sign-in is interactive. */
|
|
64
|
+
export type SilentState = 'try' | 'done';
|
|
65
|
+
export declare function sealSilent(options: ResolvedOptions, state: SilentState): Promise<SetCookie>;
|
|
66
|
+
export declare function openSilent(options: ResolvedOptions, value: string | undefined): Promise<SilentState | null>;
|
|
60
67
|
export declare function qrTransactionCookieName(options: ResolvedOptions): string;
|
|
61
68
|
/**
|
|
62
69
|
* `ttl` is the issuer's own `expires_in` for the device code, not a fixed
|
package/dist/cookies.js
CHANGED
|
@@ -4,6 +4,9 @@ const SESSION = 'wtfalch_auth';
|
|
|
4
4
|
const TRANSACTION = 'wtfalch_auth_tx';
|
|
5
5
|
const LINK = 'wtfalch_auth_link';
|
|
6
6
|
const QR = 'wtfalch_auth_qr';
|
|
7
|
+
const SILENT = 'wtfalch_auth_silent';
|
|
8
|
+
/** How long the gate remembers it has tried (or finished) a silent pass, so a failed one is never retried in a loop. */
|
|
9
|
+
const SILENT_TTL = 5 * 60;
|
|
7
10
|
const TRANSACTION_TTL = 10 * 60;
|
|
8
11
|
/**
|
|
9
12
|
* A namespace session handle lives as long as a browser will keep a cookie.
|
|
@@ -81,6 +84,17 @@ export async function openTransaction(options, value) {
|
|
|
81
84
|
export function clearTransaction(options) {
|
|
82
85
|
return setCookie(transactionCookieName(options), '', 0, options.secure);
|
|
83
86
|
}
|
|
87
|
+
export function silentCookieName(options) {
|
|
88
|
+
return options.secure ? `__Host-${SILENT}` : SILENT;
|
|
89
|
+
}
|
|
90
|
+
export async function sealSilent(options, state) {
|
|
91
|
+
const value = await seal(options, 'silent', { s: state }, SILENT_TTL);
|
|
92
|
+
return setCookie(silentCookieName(options), value, SILENT_TTL, options.secure);
|
|
93
|
+
}
|
|
94
|
+
export async function openSilent(options, value) {
|
|
95
|
+
const payload = await open(options, 'silent', value);
|
|
96
|
+
return payload?.s === 'try' || payload?.s === 'done' ? payload.s : null;
|
|
97
|
+
}
|
|
84
98
|
export function qrTransactionCookieName(options) {
|
|
85
99
|
return options.secure ? `__Host-${QR}` : QR;
|
|
86
100
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
2
|
-
export {
|
|
1
|
+
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
2
|
+
export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
5
5
|
export { BrokerError } from './broker.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
export { createAuth, } from './auth.js';
|
|
2
|
-
export { ISSUER, } from './config.js';
|
|
1
|
+
export { STEP_UP_MAX_AGE, createAuth, } from './auth.js';
|
|
3
2
|
export { BrokerError } from './broker.js';
|
|
4
3
|
export { AuthError, ORG_CLAIM } from './oidc.js';
|
|
5
4
|
export { safeNextPath } from './redirect.js';
|
package/dist/names.d.ts
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* read as an `i` and as an `l`, so `wa1id` is `walid` too.
|
|
21
21
|
*
|
|
22
22
|
* The letter rules -- what characters, how long -- are the caller's: this is a
|
|
23
|
-
* login name in ZITADEL and one part of
|
|
24
|
-
* default is ASCII letters, digits,
|
|
23
|
+
* login name in ZITADEL and one part of an email-shaped login handle at the
|
|
24
|
+
* issuer (`name@tf.<issuer-host>`), so the default is ASCII letters, digits,
|
|
25
|
+
* `_`, `.` and `-`, three to thirty of them.
|
|
25
26
|
*/
|
|
26
27
|
export type Refusal = 'too_short' | 'too_long' | 'characters' | 'reserved' | 'disallowed';
|
|
27
28
|
export type Verdict = {
|