@wtfalch/auth 0.11.1 → 0.13.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 +89 -0
- package/dist/auth.d.ts +31 -1
- package/dist/auth.js +75 -7
- package/dist/browser.d.ts +63 -2
- package/dist/browser.js +93 -19
- package/dist/config.d.ts +68 -0
- package/dist/config.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/namespace-browser.js +13 -3
- package/dist/namespace-session.d.ts +2 -1
- package/dist/namespace-session.js +121 -1
- package/dist/next.d.ts +2 -2
- package/dist/oidc.d.ts +18 -2
- package/dist/oidc.js +27 -5
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +81 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -99,6 +99,83 @@ again. `forget()` sets the same mark, so a sign-out does not undo itself.
|
|
|
99
99
|
`completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
|
|
100
100
|
answer to a silent attempt, not a failure — or `error`.
|
|
101
101
|
|
|
102
|
+
### Multi-session seams: a non-default opener, storage, refresh and scopes
|
|
103
|
+
|
|
104
|
+
Four options on `BrowserAuthOptions` exist for a client that is not a
|
|
105
|
+
plain same-origin web page — a phone driving a system browser, a desktop app,
|
|
106
|
+
or an app juggling more than one issuer session. Every default matches the
|
|
107
|
+
behaviour above exactly, so a caller that sets none of them sees no change.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const auth = createBrowserAuth({
|
|
111
|
+
issuer: 'https://auth.wtfalch.dev',
|
|
112
|
+
clientId: '…',
|
|
113
|
+
open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
|
|
114
|
+
store: capacitorSecureStore, // see "Storage stays synchronous" below
|
|
115
|
+
extraScopes: ['urn:zitadel:iam:org:project:id:<mail project>:aud'],
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **`open(url)`** replaces `window.location.assign` — what leaves the tab for
|
|
120
|
+
the issuer. It may return a promise, which is awaited before `authorize()`
|
|
121
|
+
returns; the default stays synchronous.
|
|
122
|
+
- **`store`** replaces the `sessionStorage` wrapper that holds the token,
|
|
123
|
+
verifier, return path and silent-tried mark, with a plain
|
|
124
|
+
`{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
|
|
125
|
+
return a promise (see "Storage: `get` stays synchronous" below). Two
|
|
126
|
+
`createBrowserAuth` instances given different stores never see each other's
|
|
127
|
+
values — one app's session cannot leak into another's held in the same tab.
|
|
128
|
+
- **`refresh()`**, a new method on the returned client, posts the
|
|
129
|
+
`refresh_token` grant and stores the session it gets back — awaiting the
|
|
130
|
+
store's write before it resolves, so an app that awaits `refresh()` knows
|
|
131
|
+
the rotated token is durable, not just in memory. ZITADEL rotates the
|
|
132
|
+
refresh token on every use — the one just spent cannot be reused — so a
|
|
133
|
+
refusal (`{ kind: 'refused' }`) drops the held session rather than leaving a
|
|
134
|
+
dead one behind; `{ kind: 'no-refresh-token' }` means there was nothing to
|
|
135
|
+
refresh. Concurrent calls share one in-flight exchange: two callers racing
|
|
136
|
+
near expiry both get the freshly rotated session, rather than the second
|
|
137
|
+
spending the first's now-dead token and refusing a session the first just
|
|
138
|
+
set. The refresh token itself never appears on the value `currentSession()`
|
|
139
|
+
or `refresh()` returns to app code — only `refresh()`'s own closure over
|
|
140
|
+
the store can reach it — because a value passed around the app should not
|
|
141
|
+
double as something that can mint new access tokens.
|
|
142
|
+
- **`extraScopes`** appends to `scope` (or its default) at `/authorize`. Each
|
|
143
|
+
entry is itself split on whitespace and de-duplicated against the rest —
|
|
144
|
+
`['read write']` and `['read', 'write']` do the same thing — so an audience
|
|
145
|
+
scope for one project, say, never lands twice or leaves a stray space when
|
|
146
|
+
an entry is empty. The refresh grant freezes the audience from first
|
|
147
|
+
sign-in, so this only matters there.
|
|
148
|
+
- **`forget()`** is now `Promise<void>` rather than `void`, the one signature
|
|
149
|
+
change here: it awaits the store's writes, the same as `completeSignIn()`
|
|
150
|
+
and `refresh()`.
|
|
151
|
+
|
|
152
|
+
**Storage: `get` stays synchronous; `set` and `drop` may be async.**
|
|
153
|
+
`currentSession()` and `silentSignInAvailable()` are synchronous methods on
|
|
154
|
+
every `createBrowserAuth` client, defaults included, and both read only
|
|
155
|
+
through the store's `get` — so `get` cannot become a promise without changing
|
|
156
|
+
that contract for every existing caller, not just a Capacitor one. `set` and
|
|
157
|
+
`drop` can, because the methods that write through them —
|
|
158
|
+
`completeSignIn()`, `refresh()` and `forget()` — are already async: each now
|
|
159
|
+
awaits the store's write before it returns, so a write is durable by the time
|
|
160
|
+
an app that awaits one of those methods acts on it.
|
|
161
|
+
|
|
162
|
+
A Capacitor secure-storage store bridges the two: keep a synchronous
|
|
163
|
+
in-memory mirror for `get` to read, and have `set`/`drop` write the mirror
|
|
164
|
+
*and* await the secure store, so the promise those methods return resolves
|
|
165
|
+
only once the write actually lands. Hydrate the mirror from the secure store
|
|
166
|
+
once at startup, before the app's first call to `currentSession()` — a `get`
|
|
167
|
+
before that has nothing to read and reports no session even when one is
|
|
168
|
+
held.
|
|
169
|
+
|
|
170
|
+
**Namespace clients (`namespace: {...}`) do not take a custom `store`.** That
|
|
171
|
+
path already coordinates a session across tabs and clients through its own
|
|
172
|
+
fixed `sessionStorage`/`localStorage` keys (see `namespace-browser.ts`), and
|
|
173
|
+
swapping in an injected store would mean redesigning that coordination, not
|
|
174
|
+
wiring one in — `createBrowserAuth` throws rather than silently ignoring it.
|
|
175
|
+
`open` and `extraScopes` do apply there, unchanged. `refresh()` is present but
|
|
176
|
+
always reports `no-refresh-token`: the namespace exchange never retains a
|
|
177
|
+
refresh token to begin with.
|
|
178
|
+
|
|
102
179
|
## Configuration
|
|
103
180
|
|
|
104
181
|
| | |
|
|
@@ -139,6 +216,18 @@ app URL, and refreshes itself when the id token is close to expiring.
|
|
|
139
216
|
`ORG_CLAIM` is enforced on every token: a token from another organisation is
|
|
140
217
|
refused even when the issuer and the signature are good.
|
|
141
218
|
|
|
219
|
+
The cookie never carries an access token -- every exchange and refresh
|
|
220
|
+
discards it. `accessToken(cookieValue)` reads one back for a server that
|
|
221
|
+
needs to call a resource server on a signed-in person's behalf: it runs the
|
|
222
|
+
refresh grant itself and returns `{ ok: true, accessToken, expiresAt,
|
|
223
|
+
cookies }` or `{ ok: false, reason }`. The refresh token is spent on every
|
|
224
|
+
call, so `cookies` -- always the re-sealed session, carrying the grant's
|
|
225
|
+
fresh id token even when the refresh token itself did not rotate -- must be
|
|
226
|
+
persisted before the session is read or `accessToken` called again.
|
|
227
|
+
Not available in namespace mode. `extraScopes` on `createAuth`'s options
|
|
228
|
+
adds to the scope every sign-in and QR device grant asks for, for a
|
|
229
|
+
resource server's own audience scope. See `docs/adopting.md` for both.
|
|
230
|
+
|
|
142
231
|
## Passkeys and QR sign-in
|
|
143
232
|
|
|
144
233
|
Passkeys live on each app's own domain: the relying-party ID is the app's
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type JWTPayload } from 'jose';
|
|
2
2
|
import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
|
|
3
|
-
import { type AuthOptions } from './config.js';
|
|
3
|
+
import { type AccessTokenResult, type AuthOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
import type { SessionRecord } from './sessions.js';
|
|
6
6
|
export interface User {
|
|
@@ -387,6 +387,36 @@ export interface Auth {
|
|
|
387
387
|
* failed callback rather than throwing.
|
|
388
388
|
*/
|
|
389
389
|
pollQrSignIn(request: Request): Promise<QrPollResult>;
|
|
390
|
+
/**
|
|
391
|
+
* Trades a sealed session's `value` (`readCookie`'s own `value` parameter,
|
|
392
|
+
* not a `Cookie` header -- in namespace mode, the handle) for a fresh
|
|
393
|
+
* issuer access token. Nothing here ever holds one -- `Tokens` keeps only
|
|
394
|
+
* an id token and a refresh token -- so this runs the refresh grant itself
|
|
395
|
+
* and reads `access_token` straight off that response, applying the same
|
|
396
|
+
* id-token and organisation checks `readCookie`'s own refresh path
|
|
397
|
+
* applies, through the same `Oidc.refresh`.
|
|
398
|
+
*
|
|
399
|
+
* The refresh token is SPENT on every call: the issuer rotates it on each
|
|
400
|
+
* grant. Cookie mode: a caller must serialise calls for one session -- two
|
|
401
|
+
* in flight would race the same token -- and persist `cookies`, the
|
|
402
|
+
* re-sealed session this always returns on `ok: true` (a fresh id token
|
|
403
|
+
* comes back on every grant, even on the rare one that does not rotate the
|
|
404
|
+
* refresh token), before the session is read or `accessToken` is called
|
|
405
|
+
* again. Namespace mode: `cookies` is always empty, and the call itself
|
|
406
|
+
* serialises under the record's own lease -- the same one a due `read`
|
|
407
|
+
* refreshes under -- so two calls, or a call racing a due read, spend the
|
|
408
|
+
* token in turn rather than together.
|
|
409
|
+
*
|
|
410
|
+
* `ok: false` is a definitive refusal -- no session, no refresh token in
|
|
411
|
+
* it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
|
|
412
|
+
* organisation/id-token check `readCookie` applies). A transient issuer
|
|
413
|
+
* outage (network error, 5xx, 429) throws instead of answering
|
|
414
|
+
* `ok: false`: unlike `readCookie`, there is no still-valid access token
|
|
415
|
+
* to fall back to, because one was never kept, in either mode.
|
|
416
|
+
* Namespace mode also throws while the binding is suspended, and when
|
|
417
|
+
* another holder keeps the record's lease past the wait.
|
|
418
|
+
*/
|
|
419
|
+
accessToken(cookieValue: string): Promise<AccessTokenResult>;
|
|
390
420
|
}
|
|
391
421
|
export declare function createAuth(input: AuthOptions): Auth;
|
|
392
422
|
export type { AuthRequest, NewUser, SetCookie };
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { errors } from 'jose';
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
import { Broker, BrokerError } from './broker.js';
|
|
4
|
-
import { resolveOptions } from './config.js';
|
|
4
|
+
import { resolveOptions, } from './config.js';
|
|
5
5
|
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
6
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
7
|
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
|
|
@@ -19,6 +19,26 @@ export function createAuth(input) {
|
|
|
19
19
|
}
|
|
20
20
|
return resolved;
|
|
21
21
|
};
|
|
22
|
+
const emit = (event) => {
|
|
23
|
+
try {
|
|
24
|
+
// The type says void, but nothing stops a caller from handing over an
|
|
25
|
+
// async function -- audit logging is usually a database write. An
|
|
26
|
+
// async throw never reaches this catch (calling an async function
|
|
27
|
+
// cannot throw synchronously, whatever is on its first line), so a
|
|
28
|
+
// thenable return is caught too, or a careless callback would produce
|
|
29
|
+
// an unhandled rejection instead of the logged-and-ignored failure
|
|
30
|
+
// promised above.
|
|
31
|
+
const result = options().onAuthEvent?.(event);
|
|
32
|
+
if (result && typeof result.then === 'function') {
|
|
33
|
+
Promise.resolve(result).catch((error) => {
|
|
34
|
+
console.error('@wtfalch/auth: onAuthEvent threw', error);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('@wtfalch/auth: onAuthEvent threw', error);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
22
42
|
const boundUser = (claims) => {
|
|
23
43
|
const namespace = options().namespace;
|
|
24
44
|
return {
|
|
@@ -460,7 +480,11 @@ export function createAuth(input) {
|
|
|
460
480
|
const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
|
|
461
481
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
462
482
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
463
|
-
const invite = (input) =>
|
|
483
|
+
const invite = async (input) => {
|
|
484
|
+
const result = await broker().invite({ ...input, next: safeNextPath(input.next, '') });
|
|
485
|
+
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
486
|
+
return result;
|
|
487
|
+
};
|
|
464
488
|
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
465
489
|
try {
|
|
466
490
|
const flow = await serverFlow();
|
|
@@ -505,21 +529,29 @@ export function createAuth(input) {
|
|
|
505
529
|
};
|
|
506
530
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
507
531
|
try {
|
|
508
|
-
|
|
532
|
+
const redirectTo = await broker().signIn({ authRequestId, email, password });
|
|
533
|
+
emit({ type: 'signIn', email });
|
|
534
|
+
return { ok: true, redirectTo };
|
|
509
535
|
}
|
|
510
536
|
catch (error) {
|
|
511
|
-
if (needsHostedLogin(error))
|
|
537
|
+
if (needsHostedLogin(error)) {
|
|
538
|
+
emit({ type: 'signIn', email });
|
|
512
539
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
540
|
+
}
|
|
513
541
|
return { ok: false, error: signInError(error) };
|
|
514
542
|
}
|
|
515
543
|
};
|
|
516
544
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
517
545
|
try {
|
|
518
|
-
|
|
546
|
+
const redirectTo = await broker().signUp({ authRequestId, ...user });
|
|
547
|
+
emit({ type: 'signUp', email: user.email });
|
|
548
|
+
return { ok: true, redirectTo };
|
|
519
549
|
}
|
|
520
550
|
catch (error) {
|
|
521
|
-
if (needsHostedLogin(error))
|
|
551
|
+
if (needsHostedLogin(error)) {
|
|
552
|
+
emit({ type: 'signUp', email: user.email });
|
|
522
553
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
554
|
+
}
|
|
523
555
|
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
524
556
|
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
525
557
|
}
|
|
@@ -655,7 +687,7 @@ export function createAuth(input) {
|
|
|
655
687
|
const startQrSignIn = async () => {
|
|
656
688
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
657
689
|
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
658
|
-
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
|
|
690
|
+
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
|
|
659
691
|
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
|
|
660
692
|
return {
|
|
661
693
|
url: qrVerificationUrl(options(), device.verificationUriComplete),
|
|
@@ -706,6 +738,41 @@ export function createAuth(input) {
|
|
|
706
738
|
await replacePreviousSession(request.headers.get('cookie'));
|
|
707
739
|
return { status: 'signed_in', cookies: [cleared, session] };
|
|
708
740
|
};
|
|
741
|
+
const accessToken = async (cookieValue) => {
|
|
742
|
+
if (options().namespace)
|
|
743
|
+
return sessions.accessToken(cookieValue);
|
|
744
|
+
const session = await openSession(options(), cookieValue);
|
|
745
|
+
if (!session)
|
|
746
|
+
return { ok: false, reason: 'no_session' };
|
|
747
|
+
if (!session.rt)
|
|
748
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
749
|
+
let tokens;
|
|
750
|
+
try {
|
|
751
|
+
tokens = await oidc().refresh(session.rt);
|
|
752
|
+
}
|
|
753
|
+
catch (error) {
|
|
754
|
+
if (error instanceof AuthError || isOAuthRefusal(error))
|
|
755
|
+
return { ok: false, reason: 'refused' };
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
if (!tokens.accessToken || tokens.accessTokenExpiresAt === undefined) {
|
|
759
|
+
// The issuer answered the grant but sent no access token -- treat it
|
|
760
|
+
// the same as a refusal rather than handing back an unusable result.
|
|
761
|
+
return { ok: false, reason: 'refused' };
|
|
762
|
+
}
|
|
763
|
+
// Always re-sealed, even when the refresh token itself did not rotate:
|
|
764
|
+
// the grant always returns a fresh id token, and readCookie's own
|
|
765
|
+
// refresh path re-seals on every successful refresh for the same reason.
|
|
766
|
+
const cookies = [
|
|
767
|
+
await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken }),
|
|
768
|
+
];
|
|
769
|
+
return {
|
|
770
|
+
ok: true,
|
|
771
|
+
accessToken: tokens.accessToken,
|
|
772
|
+
expiresAt: tokens.accessTokenExpiresAt,
|
|
773
|
+
cookies,
|
|
774
|
+
};
|
|
775
|
+
};
|
|
709
776
|
const failure = (reason, cookies) => {
|
|
710
777
|
const url = new URL(options().onError, options().appUrl);
|
|
711
778
|
url.searchParams.set('auth_error', reason);
|
|
@@ -759,6 +826,7 @@ export function createAuth(input) {
|
|
|
759
826
|
finishPasskeySignIn,
|
|
760
827
|
startQrSignIn,
|
|
761
828
|
pollQrSignIn,
|
|
829
|
+
accessToken,
|
|
762
830
|
};
|
|
763
831
|
}
|
|
764
832
|
function redirect(location, cookies, status = 302) {
|
package/dist/browser.d.ts
CHANGED
|
@@ -37,6 +37,23 @@ import type { NamespaceSelection } from './namespaces.js';
|
|
|
37
37
|
* the tab spins. `forget()` sets the same mark, so pressing sign out does not
|
|
38
38
|
* bounce somebody straight back in — a sign-out that undoes itself is not one.
|
|
39
39
|
*/
|
|
40
|
+
/** Leaves the tab for the issuer instead of `window.location.assign`. A
|
|
41
|
+
* phone driving a system browser (Capacitor, say) supplies its own; may
|
|
42
|
+
* return a promise, which is awaited before `authorize()` returns. */
|
|
43
|
+
export type BrowserOpener = (url: string) => void | Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Where the token, verifier, return path and silent-tried mark live.
|
|
46
|
+
* `get` is synchronous: `currentSession()` and `silentSignInAvailable()` are
|
|
47
|
+
* synchronous on `BrowserAuth`, defaults included, and read only through it.
|
|
48
|
+
* `set` and `drop` may return a promise — for a store backed by something
|
|
49
|
+
* like Capacitor secure storage — which `completeSignIn`, `refresh` and
|
|
50
|
+
* `forget` await before they return. See the package README.
|
|
51
|
+
*/
|
|
52
|
+
export interface BrowserAuthStore {
|
|
53
|
+
get(key: string): string | null;
|
|
54
|
+
set(key: string, value: string): void | Promise<void>;
|
|
55
|
+
drop(key: string): void | Promise<void>;
|
|
56
|
+
}
|
|
40
57
|
export interface BrowserAuthOptions {
|
|
41
58
|
/** Public namespace registry projection; never include broker or cookie secrets. */
|
|
42
59
|
namespace?: NamespaceSelection;
|
|
@@ -57,6 +74,20 @@ export interface BrowserAuthOptions {
|
|
|
57
74
|
callbackPath?: string;
|
|
58
75
|
/** Scopes to ask for. `openid` is required; the rest is the app's business. */
|
|
59
76
|
scope?: string;
|
|
77
|
+
/**
|
|
78
|
+
* Extra scopes appended to `scope` at `/authorize`, de-duplicated with it —
|
|
79
|
+
* an audience scope for one project, say. The refresh grant freezes the
|
|
80
|
+
* audience from first sign-in, so this has no effect on `refresh()`.
|
|
81
|
+
*/
|
|
82
|
+
extraScopes?: string[];
|
|
83
|
+
/** Leaves the tab for the issuer. Default: `window.location.assign`. */
|
|
84
|
+
open?: BrowserOpener;
|
|
85
|
+
/**
|
|
86
|
+
* Holds the token, verifier, return path and silent-tried mark. Default
|
|
87
|
+
* wraps `sessionStorage` and never throws. Two instances given different
|
|
88
|
+
* stores never see each other's values — inject one per app.
|
|
89
|
+
*/
|
|
90
|
+
store?: BrowserAuthStore;
|
|
60
91
|
}
|
|
61
92
|
export interface BrowserSession {
|
|
62
93
|
namespaceId?: string;
|
|
@@ -82,6 +113,25 @@ export type SignInOutcome = {
|
|
|
82
113
|
kind: 'error';
|
|
83
114
|
error: string;
|
|
84
115
|
};
|
|
116
|
+
/** What `refresh()` found. */
|
|
117
|
+
export type RefreshOutcome = {
|
|
118
|
+
kind: 'refreshed';
|
|
119
|
+
session: BrowserSession;
|
|
120
|
+
}
|
|
121
|
+
/** No session held, or the held one has no refresh token. Not an error. */
|
|
122
|
+
| {
|
|
123
|
+
kind: 'no-refresh-token';
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The issuer would not rotate the refresh token. ZITADEL spends a refresh
|
|
127
|
+
* token on every use whether it accepts or refuses it, so either way the
|
|
128
|
+
* old one cannot be tried again — the held session is dropped before this
|
|
129
|
+
* returns.
|
|
130
|
+
*/
|
|
131
|
+
| {
|
|
132
|
+
kind: 'refused';
|
|
133
|
+
error: string;
|
|
134
|
+
};
|
|
85
135
|
export interface BrowserAuth {
|
|
86
136
|
/** Another account/binding replaced this context; require a deliberate sign-in. */
|
|
87
137
|
accountChanged(): boolean;
|
|
@@ -112,7 +162,18 @@ export interface BrowserAuth {
|
|
|
112
162
|
signIn(returnTo?: string): Promise<void>;
|
|
113
163
|
/** Finish the flow at the callback path. */
|
|
114
164
|
completeSignIn(search?: string): Promise<SignInOutcome>;
|
|
115
|
-
/**
|
|
116
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Rotate the held refresh token for a new session, when one is held.
|
|
167
|
+
* `currentSession()` never carries the refresh token itself — a value
|
|
168
|
+
* passed around the app should not be able to mint new access tokens — so
|
|
169
|
+
* this is the only way to use it. A refusal drops the held session.
|
|
170
|
+
* Concurrent calls share one in-flight exchange, so two callers racing
|
|
171
|
+
* near expiry both get the freshly rotated session rather than one losing
|
|
172
|
+
* to the other's now-spent refresh token.
|
|
173
|
+
*/
|
|
174
|
+
refresh(): Promise<RefreshOutcome>;
|
|
175
|
+
/** Drop the session, and do not silently take another in this tab. Awaits
|
|
176
|
+
* the store's writes, so a caller that awaits this knows they landed. */
|
|
177
|
+
forget(): Promise<void>;
|
|
117
178
|
}
|
|
118
179
|
export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
|
package/dist/browser.js
CHANGED
|
@@ -6,7 +6,7 @@ const RETURN = 'wtfalch.auth.return';
|
|
|
6
6
|
const TRIED = 'wtfalch.auth.silent-tried';
|
|
7
7
|
/** Storage that never throws. A browser refusing it signs in every visit,
|
|
8
8
|
* which is worse than the alternative but not broken. */
|
|
9
|
-
const
|
|
9
|
+
const defaultStore = {
|
|
10
10
|
get(key) {
|
|
11
11
|
try {
|
|
12
12
|
return sessionStorage.getItem(key);
|
|
@@ -47,17 +47,33 @@ function verifier() {
|
|
|
47
47
|
// inside the 43..128 the spec allows.
|
|
48
48
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
49
49
|
}
|
|
50
|
+
function publicSession(session) {
|
|
51
|
+
const { refreshToken, ...rest } = session;
|
|
52
|
+
return rest;
|
|
53
|
+
}
|
|
50
54
|
export function createBrowserAuth(options) {
|
|
51
|
-
if (options.namespace)
|
|
55
|
+
if (options.namespace) {
|
|
56
|
+
// The namespace path coordinates a shared session across tabs and
|
|
57
|
+
// clients through fixed `sessionStorage`/`localStorage` keys (see
|
|
58
|
+
// `namespace-browser.ts`); swapping that for an injected store means
|
|
59
|
+
// redesigning that coordination, not wiring one in. Refused with a clear
|
|
60
|
+
// error rather than silently ignored.
|
|
61
|
+
if (options.store)
|
|
62
|
+
throw new Error('createBrowserAuth: a namespace client does not support a custom store');
|
|
52
63
|
return createNamespaceBrowserAuth(options, options.namespace);
|
|
64
|
+
}
|
|
53
65
|
const origin = options.origin ?? window.location.origin;
|
|
54
66
|
const callbackPath = options.callbackPath ?? '/auth/callback';
|
|
55
|
-
const
|
|
67
|
+
const baseScope = options.scope ?? 'openid email profile offline_access';
|
|
68
|
+
const extraScopes = (options.extraScopes ?? []).flatMap((s) => s.split(/\s+/).filter(Boolean));
|
|
69
|
+
const scope = Array.from(new Set([...baseScope.split(/\s+/).filter(Boolean), ...extraScopes])).join(' ');
|
|
56
70
|
const redirectUri = `${origin}${callbackPath}`;
|
|
71
|
+
const store = options.store ?? defaultStore;
|
|
72
|
+
const open = options.open ?? ((url) => window.location.assign(url));
|
|
57
73
|
const authorize = async (prompt, returnTo) => {
|
|
58
74
|
const proof = verifier();
|
|
59
|
-
store.set(VERIFIER, proof);
|
|
60
|
-
store.set(RETURN, returnTo);
|
|
75
|
+
await store.set(VERIFIER, proof);
|
|
76
|
+
await store.set(RETURN, returnTo);
|
|
61
77
|
const url = new URL(`${options.issuer}/oauth/v2/authorize`);
|
|
62
78
|
url.searchParams.set('client_id', options.clientId);
|
|
63
79
|
url.searchParams.set('redirect_uri', redirectUri);
|
|
@@ -67,21 +83,68 @@ export function createBrowserAuth(options) {
|
|
|
67
83
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
68
84
|
if (prompt)
|
|
69
85
|
url.searchParams.set('prompt', prompt);
|
|
70
|
-
|
|
86
|
+
await open(url.toString());
|
|
71
87
|
};
|
|
72
|
-
const
|
|
88
|
+
const readStored = () => {
|
|
73
89
|
const raw = store.get(TOKEN);
|
|
74
90
|
if (!raw)
|
|
75
91
|
return null;
|
|
76
92
|
try {
|
|
77
|
-
|
|
78
|
-
return session.expiresAt - 60_000 > Date.now() ? session : null;
|
|
93
|
+
return JSON.parse(raw);
|
|
79
94
|
}
|
|
80
95
|
catch {
|
|
81
96
|
return null;
|
|
82
97
|
}
|
|
83
98
|
};
|
|
99
|
+
const currentSession = () => {
|
|
100
|
+
const session = readStored();
|
|
101
|
+
if (!session)
|
|
102
|
+
return null;
|
|
103
|
+
return session.expiresAt - 60_000 > Date.now() ? publicSession(session) : null;
|
|
104
|
+
};
|
|
84
105
|
const silentSignInAvailable = () => !currentSession() && !store.get(TRIED);
|
|
106
|
+
// Shared by every `refresh()` call on this instance, so two callers racing
|
|
107
|
+
// near expiry share one exchange rather than each spending the same
|
|
108
|
+
// refresh token — ZITADEL rotates it on first use, so the second would be
|
|
109
|
+
// refused and drop the session the first just set.
|
|
110
|
+
let refreshing;
|
|
111
|
+
const performRefresh = async () => {
|
|
112
|
+
const session = readStored();
|
|
113
|
+
if (!session?.refreshToken)
|
|
114
|
+
return { kind: 'no-refresh-token' };
|
|
115
|
+
const response = await fetch(`${options.issuer}/oauth/v2/token`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
118
|
+
body: new URLSearchParams({
|
|
119
|
+
grant_type: 'refresh_token',
|
|
120
|
+
client_id: options.clientId,
|
|
121
|
+
refresh_token: session.refreshToken,
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
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
|
+
const body = await response.text();
|
|
130
|
+
return {
|
|
131
|
+
kind: 'refused',
|
|
132
|
+
error: `the issuer refused the refresh (${response.status}): ${body.slice(0, 200)}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const token = (await response.json());
|
|
136
|
+
if (!token.access_token || !token.refresh_token) {
|
|
137
|
+
await store.drop(TOKEN);
|
|
138
|
+
return { kind: 'refused', error: 'the issuer returned an incomplete refresh response' };
|
|
139
|
+
}
|
|
140
|
+
const next = {
|
|
141
|
+
accessToken: token.access_token,
|
|
142
|
+
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
143
|
+
refreshToken: token.refresh_token,
|
|
144
|
+
};
|
|
145
|
+
await store.set(TOKEN, JSON.stringify(next));
|
|
146
|
+
return { kind: 'refreshed', session: publicSession(next) };
|
|
147
|
+
};
|
|
85
148
|
return {
|
|
86
149
|
accountChanged: () => false,
|
|
87
150
|
currentSession,
|
|
@@ -89,14 +152,14 @@ export function createBrowserAuth(options) {
|
|
|
89
152
|
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
90
153
|
if (!silentSignInAvailable())
|
|
91
154
|
return false;
|
|
92
|
-
store.set(TRIED, '1');
|
|
155
|
+
await store.set(TRIED, '1');
|
|
93
156
|
await authorize('none', returnTo);
|
|
94
157
|
return true;
|
|
95
158
|
},
|
|
96
159
|
async signIn(returnTo = window.location.pathname) {
|
|
97
160
|
// A deliberate sign-in clears the mark: whatever the issuer said before,
|
|
98
161
|
// the person is asking now.
|
|
99
|
-
store.drop(TRIED);
|
|
162
|
+
await store.drop(TRIED);
|
|
100
163
|
await authorize(undefined, returnTo);
|
|
101
164
|
},
|
|
102
165
|
async completeSignIn(search = window.location.search) {
|
|
@@ -106,8 +169,8 @@ export function createBrowserAuth(options) {
|
|
|
106
169
|
// Removed whatever happens: a code that failed to exchange must not be
|
|
107
170
|
// retried against the same proof, and a stale verifier is what makes the
|
|
108
171
|
// *next* sign-in fail confusingly.
|
|
109
|
-
store.drop(VERIFIER);
|
|
110
|
-
store.drop(RETURN);
|
|
172
|
+
await store.drop(VERIFIER);
|
|
173
|
+
await store.drop(RETURN);
|
|
111
174
|
const issuerError = params.get('error');
|
|
112
175
|
if (issuerError) {
|
|
113
176
|
// The three the spec gives for "I would have had to ask them
|
|
@@ -151,18 +214,29 @@ export function createBrowserAuth(options) {
|
|
|
151
214
|
const session = {
|
|
152
215
|
accessToken: token.access_token,
|
|
153
216
|
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
217
|
+
refreshToken: token.refresh_token,
|
|
154
218
|
};
|
|
155
|
-
store.set(TOKEN, JSON.stringify(session));
|
|
219
|
+
await store.set(TOKEN, JSON.stringify(session));
|
|
156
220
|
// Signed in: a later silent attempt in this tab is allowed again, which
|
|
157
221
|
// matters when the token expires and the page reloads.
|
|
158
|
-
store.drop(TRIED);
|
|
159
|
-
return { kind: 'signed-in', session, returnTo };
|
|
222
|
+
await store.drop(TRIED);
|
|
223
|
+
return { kind: 'signed-in', session: publicSession(session), returnTo };
|
|
224
|
+
},
|
|
225
|
+
async refresh() {
|
|
226
|
+
// One in-flight exchange per instance: two callers racing near expiry
|
|
227
|
+
// must not both spend the same refresh token — ZITADEL rotates it on
|
|
228
|
+
// first use, so the second would be refused and drop the session the
|
|
229
|
+
// first just set.
|
|
230
|
+
refreshing ??= performRefresh().finally(() => {
|
|
231
|
+
refreshing = undefined;
|
|
232
|
+
});
|
|
233
|
+
return refreshing;
|
|
160
234
|
},
|
|
161
|
-
forget() {
|
|
162
|
-
store.drop(TOKEN);
|
|
235
|
+
async forget() {
|
|
236
|
+
await store.drop(TOKEN);
|
|
163
237
|
// The mark stays set: a sign-out that silently signs you back in is not
|
|
164
238
|
// a sign-out.
|
|
165
|
-
store.set(TRIED, '1');
|
|
239
|
+
await store.set(TRIED, '1');
|
|
166
240
|
},
|
|
167
241
|
};
|
|
168
242
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
|
+
import type { SetCookie } from './cookies.js';
|
|
1
2
|
import { type NamespaceSelection, resolveNamespaceContext } from './namespaces.js';
|
|
2
3
|
import type { SessionStore } from './sessions.js';
|
|
4
|
+
/**
|
|
5
|
+
* An auth lifecycle event, for audit logging. Each variant carries only
|
|
6
|
+
* what the call that fires it already has in hand -- `signIn`/`signUp`
|
|
7
|
+
* resolve to a redirect, not a decoded identity, so there is no `userId`
|
|
8
|
+
* to offer there; `invite` calls the broker's own endpoint, which returns
|
|
9
|
+
* one.
|
|
10
|
+
*/
|
|
11
|
+
export type AuthEvent = {
|
|
12
|
+
type: 'signIn';
|
|
13
|
+
email: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'signUp';
|
|
16
|
+
email: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'invite';
|
|
19
|
+
email: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
invited: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
25
|
+
* record behind the namespace handle in namespace mode), a session with no
|
|
26
|
+
* refresh token (`no_refresh_token`), or the issuer refusing the refresh
|
|
27
|
+
* grant itself, including the same organisation/id-token validation the
|
|
28
|
+
* ordinary refresh path applies (`refused`). A transient outage is not in
|
|
29
|
+
* this list -- `accessToken` throws for that instead, see its own doc
|
|
30
|
+
* comment.
|
|
31
|
+
*/
|
|
32
|
+
export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
|
|
33
|
+
export type AccessTokenResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
/** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
|
|
36
|
+
accessToken: string;
|
|
37
|
+
/** Epoch milliseconds. */
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* Cookie mode: always exactly one, even when the refresh token itself
|
|
41
|
+
* did not rotate, because the grant always returns a fresh id token
|
|
42
|
+
* too. Persist it, e.g. as a `Set-Cookie`, before the value passed in
|
|
43
|
+
* is used again: the refresh token in it is spent.
|
|
44
|
+
*
|
|
45
|
+
* Namespace mode: always empty. The record updates itself under its
|
|
46
|
+
* own lease; there is nothing for the caller to persist.
|
|
47
|
+
*/
|
|
48
|
+
cookies: SetCookie[];
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: AccessTokenReason;
|
|
52
|
+
};
|
|
3
53
|
export interface AuthOptions {
|
|
4
54
|
/** Opt-in namespace mode. Resolve this from trusted workspace/host configuration. */
|
|
5
55
|
namespace?: NamespaceSelection;
|
|
@@ -14,6 +64,15 @@ export interface AuthOptions {
|
|
|
14
64
|
clientId: string | undefined;
|
|
15
65
|
/** From scripts/provisioned.json. Sent as a scope and checked on every token. */
|
|
16
66
|
organizationId: string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Extra scopes appended to `scopeFor`'s fixed set, on both the
|
|
69
|
+
* authorization request `start` builds and the QR device grant --
|
|
70
|
+
* a project's `:aud` scope, say. Each entry is split on whitespace,
|
|
71
|
+
* empty entries dropped, and duplicates (against the fixed scopes and
|
|
72
|
+
* each other) removed. Default: none, and the scope string this SDK has
|
|
73
|
+
* always sent is unchanged, byte for byte.
|
|
74
|
+
*/
|
|
75
|
+
extraScopes?: string[];
|
|
17
76
|
/**
|
|
18
77
|
* 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app.
|
|
19
78
|
*
|
|
@@ -50,6 +109,13 @@ export interface AuthOptions {
|
|
|
50
109
|
*/
|
|
51
110
|
freshness?: number;
|
|
52
111
|
fetch?: typeof fetch;
|
|
112
|
+
/**
|
|
113
|
+
* Called after a sign-in, a sign-up, or an invite succeeds, so an app can
|
|
114
|
+
* add audit logging without threading it through every call site by hand.
|
|
115
|
+
* Never awaited; a throw from it is caught and logged rather than failing
|
|
116
|
+
* the call it rode in on.
|
|
117
|
+
*/
|
|
118
|
+
onAuthEvent?: (event: AuthEvent) => void;
|
|
53
119
|
}
|
|
54
120
|
export interface ResolvedOptions {
|
|
55
121
|
namespace: ReturnType<typeof resolveNamespaceContext> | null;
|
|
@@ -57,6 +123,7 @@ export interface ResolvedOptions {
|
|
|
57
123
|
appUrl: URL;
|
|
58
124
|
clientId: string;
|
|
59
125
|
organizationId: string;
|
|
126
|
+
extraScopes: string[];
|
|
60
127
|
/**
|
|
61
128
|
* Decoded on first access, not at resolve time.
|
|
62
129
|
*
|
|
@@ -83,6 +150,7 @@ export interface ResolvedOptions {
|
|
|
83
150
|
freshness: number;
|
|
84
151
|
secure: boolean;
|
|
85
152
|
fetch: typeof fetch;
|
|
153
|
+
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
86
154
|
}
|
|
87
155
|
export declare const ISSUER = "https://auth.wtfalch.dev";
|
|
88
156
|
export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
|
package/dist/config.js
CHANGED
|
@@ -41,6 +41,7 @@ export function resolveOptions(options) {
|
|
|
41
41
|
appUrl,
|
|
42
42
|
clientId: options.clientId,
|
|
43
43
|
organizationId: options.organizationId,
|
|
44
|
+
extraScopes: options.extraScopes ?? [],
|
|
44
45
|
get cookieKey() {
|
|
45
46
|
cookieKey ??= decodeKey(options.cookieSecret);
|
|
46
47
|
return cookieKey;
|
|
@@ -58,6 +59,7 @@ export function resolveOptions(options) {
|
|
|
58
59
|
freshness: seconds(options.freshness, 'freshness', 5 * 60),
|
|
59
60
|
secure: appUrl.protocol === 'https:',
|
|
60
61
|
fetch: options.fetch ?? globalThis.fetch,
|
|
62
|
+
onAuthEvent: options.onAuthEvent ?? null,
|
|
61
63
|
};
|
|
62
64
|
}
|
|
63
65
|
function required(value, name) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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 { type AuthOptions, ISSUER } from './config.js';
|
|
2
|
+
export { type AccessTokenReason, type AccessTokenResult, type AuthEvent, type AuthOptions, ISSUER, } 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
|
@@ -30,7 +30,9 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
30
30
|
appOrigin: origin,
|
|
31
31
|
redirectUri,
|
|
32
32
|
});
|
|
33
|
-
const
|
|
33
|
+
const open = options.open ?? ((url) => window.location.assign(url));
|
|
34
|
+
const baseScope = options.scope ?? 'openid email profile offline_access';
|
|
35
|
+
const scope = [baseScope, ...(options.extraScopes ?? [])].join(' ');
|
|
34
36
|
const scopes = new Set(scope.split(/\s+/).filter(Boolean));
|
|
35
37
|
if (!scopes.has('openid') ||
|
|
36
38
|
[...scopes].some((s) => s.startsWith('urn:zitadel:iam:org:') &&
|
|
@@ -100,7 +102,7 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
100
102
|
}).toString();
|
|
101
103
|
if (!ownsPending(transaction.state))
|
|
102
104
|
throw new Error('Another sign-in replaced this attempt');
|
|
103
|
-
|
|
105
|
+
await open(url.href);
|
|
104
106
|
};
|
|
105
107
|
const completeSignIn = async (search = window.location.search) => {
|
|
106
108
|
const reject = () => ({
|
|
@@ -205,6 +207,12 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
205
207
|
currentSession,
|
|
206
208
|
silentSignInAvailable,
|
|
207
209
|
completeSignIn,
|
|
210
|
+
async refresh() {
|
|
211
|
+
// completeSignIn above never reads `token.refresh_token`, so this
|
|
212
|
+
// store never holds one to rotate: there is nothing offered here that
|
|
213
|
+
// isn't already true of every namespace session.
|
|
214
|
+
return { kind: 'no-refresh-token' };
|
|
215
|
+
},
|
|
208
216
|
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
209
217
|
if (!silentSignInAvailable())
|
|
210
218
|
return false;
|
|
@@ -216,7 +224,9 @@ export function createNamespaceBrowserAuth(options, selection) {
|
|
|
216
224
|
sessionStorage.removeItem(triedKey);
|
|
217
225
|
await authorize(undefined, returnTo);
|
|
218
226
|
},
|
|
219
|
-
forget()
|
|
227
|
+
// Matches BrowserAuth.forget()'s Promise<void> signature; this path's
|
|
228
|
+
// storage is always synchronous, so there is nothing to await.
|
|
229
|
+
async forget() {
|
|
220
230
|
sessionStorage.removeItem(tokenKey);
|
|
221
231
|
sessionStorage.removeItem(txKey);
|
|
222
232
|
sessionStorage.setItem(triedKey, '1');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JWTPayload } from 'jose';
|
|
2
2
|
import { type Broker } from './broker.js';
|
|
3
|
-
import type { ResolvedOptions } from './config.js';
|
|
3
|
+
import type { AccessTokenResult, ResolvedOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
import { type Oidc, type Tokens } from './oidc.js';
|
|
6
6
|
import type { SessionRecord } from './sessions.js';
|
|
@@ -42,4 +42,5 @@ export declare function namespaceSessions(deps: {
|
|
|
42
42
|
listSessions: (subject: string) => Promise<SessionRecord[]>;
|
|
43
43
|
endSession: (key: string, subject: string) => Promise<boolean>;
|
|
44
44
|
keyOf: (value: string | undefined) => Promise<string | null>;
|
|
45
|
+
accessToken: (value: string | undefined) => Promise<AccessTokenResult>;
|
|
45
46
|
};
|
|
@@ -266,6 +266,116 @@ export function namespaceSessions(deps) {
|
|
|
266
266
|
tokens: { k: key, idt: fresh.idToken, rt: fresh.refreshToken },
|
|
267
267
|
};
|
|
268
268
|
};
|
|
269
|
+
/**
|
|
270
|
+
* A fresh access token for this namespace session, under the record's own
|
|
271
|
+
* lease -- so this can never race the rotation a due `read` triggers, and
|
|
272
|
+
* two calls for the same session serialise rather than spend the refresh
|
|
273
|
+
* token twice.
|
|
274
|
+
*
|
|
275
|
+
* Unlike `refresh`, this can never settle for "another request just
|
|
276
|
+
* refreshed it, that generation bump is good enough": the access token
|
|
277
|
+
* from that grant was never kept (`RecordPayload` holds only an id token
|
|
278
|
+
* and a refresh token, see `namespace-session.ts`'s own module comment),
|
|
279
|
+
* so the only way to answer is a grant this call runs itself. It waits
|
|
280
|
+
* out whoever holds the lease first, the same wait `refresh` does, but
|
|
281
|
+
* always performs its own grant once it takes the lease rather than
|
|
282
|
+
* reusing `refreshLeased`'s "give up and use what's there" shortcut.
|
|
283
|
+
*/
|
|
284
|
+
const accessToken = async (value) => {
|
|
285
|
+
const key = await keyOf(value);
|
|
286
|
+
if (!key)
|
|
287
|
+
return { ok: false, reason: 'no_session' };
|
|
288
|
+
// A suspended binding mints nothing, the same gate `read` and every
|
|
289
|
+
// sign-in entry point apply. It throws rather than answering `ok: false`,
|
|
290
|
+
// as `startSignIn` does: a suspension is not a verdict on this session,
|
|
291
|
+
// and the fail-closed answer while the service is unreachable is not
|
|
292
|
+
// definitive either.
|
|
293
|
+
if (!(await bindingActive()))
|
|
294
|
+
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
295
|
+
let current = await load(key);
|
|
296
|
+
if (!current)
|
|
297
|
+
return { ok: false, reason: 'no_session' };
|
|
298
|
+
if (!current.tokens.rt)
|
|
299
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
300
|
+
for (let attempt = 0;; attempt++) {
|
|
301
|
+
const { record } = current;
|
|
302
|
+
const leased = record.leaseUntil !== null && record.leaseUntil > now();
|
|
303
|
+
if (!leased) {
|
|
304
|
+
const generation = record.generation + 1;
|
|
305
|
+
const acquired = await store().advance(key, record.generation, {
|
|
306
|
+
payload: record.payload,
|
|
307
|
+
expiresAt: record.expiresAt,
|
|
308
|
+
leaseUntil: now() + LEASE_SECONDS,
|
|
309
|
+
});
|
|
310
|
+
if (acquired)
|
|
311
|
+
return runGrant(key, current, generation);
|
|
312
|
+
}
|
|
313
|
+
// Still leased after the wait: someone else's grant is in flight, or a
|
|
314
|
+
// crashed holder's lease has not run out yet. Neither is a verdict on
|
|
315
|
+
// this session, so this throws like a transient issuer outage rather
|
|
316
|
+
// than answering `refused`, which a caller may treat as signed out.
|
|
317
|
+
if (attempt >= WAIT_STEPS)
|
|
318
|
+
throw new Error('@wtfalch/auth: accessToken timed out waiting for the session lease');
|
|
319
|
+
await sleep(WAIT_STEP_MS);
|
|
320
|
+
const latest = await load(key);
|
|
321
|
+
if (!latest)
|
|
322
|
+
return { ok: false, reason: 'no_session' };
|
|
323
|
+
if (!latest.tokens.rt)
|
|
324
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
325
|
+
current = latest;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/** The grant `accessToken` runs once it holds the lease itself. */
|
|
329
|
+
const runGrant = async (key, leased, generation) => {
|
|
330
|
+
const { record, tokens } = leased;
|
|
331
|
+
let fresh;
|
|
332
|
+
try {
|
|
333
|
+
fresh = await deps.oidc().refresh(tokens.rt);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
await store()
|
|
337
|
+
.advance(key, generation, {
|
|
338
|
+
payload: record.payload,
|
|
339
|
+
expiresAt: record.expiresAt,
|
|
340
|
+
leaseUntil: null,
|
|
341
|
+
})
|
|
342
|
+
.catch(() => { });
|
|
343
|
+
if (definitive(error)) {
|
|
344
|
+
// The lease means nobody else spent the token: the issuer has ended it.
|
|
345
|
+
await store().revoke(key, now());
|
|
346
|
+
return { ok: false, reason: 'refused' };
|
|
347
|
+
}
|
|
348
|
+
// A network error or a 5xx: no access token was ever minted, and
|
|
349
|
+
// unlike a plain session read there is no still-valid one to fall
|
|
350
|
+
// back to -- so this throws, exactly as the cookie-mode accessToken
|
|
351
|
+
// does for the same case.
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
const committed = await store().advance(key, generation, {
|
|
355
|
+
payload: await seal(key, fresh),
|
|
356
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
357
|
+
leaseUntil: null,
|
|
358
|
+
});
|
|
359
|
+
if (!committed) {
|
|
360
|
+
// Revoked while the grant was in flight, or the lease ran out and
|
|
361
|
+
// another request took over. The fresh tokens are kept nowhere, so
|
|
362
|
+
// the refresh token is not left live if it rotated.
|
|
363
|
+
if (fresh.refreshToken !== tokens.rt)
|
|
364
|
+
revokeToken(fresh.refreshToken);
|
|
365
|
+
return { ok: false, reason: 'refused' };
|
|
366
|
+
}
|
|
367
|
+
if (!fresh.accessToken || fresh.accessTokenExpiresAt === undefined) {
|
|
368
|
+
// The issuer answered the grant but sent no access token -- treat it
|
|
369
|
+
// the same as a refusal rather than handing back an unusable result.
|
|
370
|
+
return { ok: false, reason: 'refused' };
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
accessToken: fresh.accessToken,
|
|
375
|
+
expiresAt: fresh.accessTokenExpiresAt,
|
|
376
|
+
cookies: [],
|
|
377
|
+
};
|
|
378
|
+
};
|
|
269
379
|
/**
|
|
270
380
|
* The person behind this cookie. Never sets a cookie: a dead or revoked
|
|
271
381
|
* handle just reads as nobody until the next sign-in replaces it.
|
|
@@ -298,7 +408,17 @@ export function namespaceSessions(deps) {
|
|
|
298
408
|
return { kind: 'none' };
|
|
299
409
|
}
|
|
300
410
|
};
|
|
301
|
-
return {
|
|
411
|
+
return {
|
|
412
|
+
bindingActive,
|
|
413
|
+
create,
|
|
414
|
+
end,
|
|
415
|
+
read,
|
|
416
|
+
revokeSubject,
|
|
417
|
+
listSessions,
|
|
418
|
+
endSession,
|
|
419
|
+
keyOf,
|
|
420
|
+
accessToken,
|
|
421
|
+
};
|
|
302
422
|
}
|
|
303
423
|
/**
|
|
304
424
|
* These apps sit behind a proxy, so the client never reaches this process
|
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
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';
|
|
3
|
-
import type { AuthOptions } from './config.js';
|
|
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>;
|
|
6
6
|
export interface QrSignIn {
|
|
@@ -157,4 +157,4 @@ export interface NextAuth {
|
|
|
157
157
|
}>;
|
|
158
158
|
}
|
|
159
159
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
160
|
-
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
|
160
|
+
export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
package/dist/oidc.d.ts
CHANGED
|
@@ -9,12 +9,28 @@ export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
|
|
|
9
9
|
* or a request more than a minute behind, still gets the real refusal.
|
|
10
10
|
*/
|
|
11
11
|
export declare const ROTATION_CACHE_MS = 60000;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The scopes every flow asks for: the code-exchange authorization request
|
|
14
|
+
* and the QR device grant alike. `extraScopes` -- `AuthOptions.extraScopes`,
|
|
15
|
+
* passed straight through -- is appended after the fixed set: each entry is
|
|
16
|
+
* split on whitespace, empty entries dropped, and anything already present
|
|
17
|
+
* (in the fixed set or an earlier extra entry) skipped. With none, the
|
|
18
|
+
* result is exactly the fixed-set string, byte for byte.
|
|
19
|
+
*/
|
|
20
|
+
export declare function scopeFor(organizationId: string, extraScopes?: readonly string[]): string;
|
|
14
21
|
export interface Tokens {
|
|
15
22
|
idToken: string;
|
|
16
23
|
refreshToken?: string;
|
|
17
24
|
claims: jose.JWTPayload;
|
|
25
|
+
/**
|
|
26
|
+
* The issuer's own access token from this exchange or refresh grant.
|
|
27
|
+
* Never persisted to a cookie or a namespace record -- `tokensFrom` is the
|
|
28
|
+
* only place that reads it off the response; `Auth.accessToken` is the
|
|
29
|
+
* only public caller, and it reads this off a refresh grant it runs itself.
|
|
30
|
+
*/
|
|
31
|
+
accessToken?: string;
|
|
32
|
+
/** Epoch ms `accessToken` expires at. Present exactly when `accessToken` is and the issuer sent `expires_in`. */
|
|
33
|
+
accessTokenExpiresAt?: number;
|
|
18
34
|
}
|
|
19
35
|
export declare class Oidc {
|
|
20
36
|
private readonly options;
|
package/dist/oidc.js
CHANGED
|
@@ -9,16 +9,35 @@ export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
|
|
|
9
9
|
* or a request more than a minute behind, still gets the real refusal.
|
|
10
10
|
*/
|
|
11
11
|
export const ROTATION_CACHE_MS = 60_000;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The scopes every flow asks for: the code-exchange authorization request
|
|
14
|
+
* and the QR device grant alike. `extraScopes` -- `AuthOptions.extraScopes`,
|
|
15
|
+
* passed straight through -- is appended after the fixed set: each entry is
|
|
16
|
+
* split on whitespace, empty entries dropped, and anything already present
|
|
17
|
+
* (in the fixed set or an earlier extra entry) skipped. With none, the
|
|
18
|
+
* result is exactly the fixed-set string, byte for byte.
|
|
19
|
+
*/
|
|
20
|
+
export function scopeFor(organizationId, extraScopes) {
|
|
21
|
+
const scopes = [
|
|
15
22
|
'openid',
|
|
16
23
|
'email',
|
|
17
24
|
'profile',
|
|
18
25
|
'offline_access',
|
|
19
26
|
`urn:zitadel:iam:org:id:${organizationId}`,
|
|
20
27
|
'urn:zitadel:iam:user:resourceowner',
|
|
21
|
-
]
|
|
28
|
+
];
|
|
29
|
+
if (extraScopes?.length) {
|
|
30
|
+
const seen = new Set(scopes);
|
|
31
|
+
for (const entry of extraScopes) {
|
|
32
|
+
for (const scope of entry.split(/\s+/)) {
|
|
33
|
+
if (scope && !seen.has(scope)) {
|
|
34
|
+
seen.add(scope);
|
|
35
|
+
scopes.push(scope);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return scopes.join(' ');
|
|
22
41
|
}
|
|
23
42
|
export class Oidc {
|
|
24
43
|
options;
|
|
@@ -67,7 +86,7 @@ export class Oidc {
|
|
|
67
86
|
...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
|
|
68
87
|
...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
|
|
69
88
|
redirect_uri: this.redirectUri.href,
|
|
70
|
-
scope: scopeFor(this.options.organizationId),
|
|
89
|
+
scope: scopeFor(this.options.organizationId, this.options.extraScopes),
|
|
71
90
|
state,
|
|
72
91
|
nonce,
|
|
73
92
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
@@ -129,10 +148,13 @@ export class Oidc {
|
|
|
129
148
|
throw new AuthError('exchange', 'the issuer returned no id token');
|
|
130
149
|
}
|
|
131
150
|
this.assertOrganization(claims);
|
|
151
|
+
const expiresIn = response.expiresIn();
|
|
132
152
|
return {
|
|
133
153
|
idToken: response.id_token,
|
|
134
154
|
refreshToken: response.refresh_token ?? previousRefreshToken,
|
|
135
155
|
claims,
|
|
156
|
+
accessToken: response.access_token,
|
|
157
|
+
accessTokenExpiresAt: expiresIn === undefined ? undefined : Date.now() + expiresIn * 1000,
|
|
136
158
|
};
|
|
137
159
|
}
|
|
138
160
|
async verify(idToken) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Auth, User } from './auth.js';
|
|
2
|
+
export declare function setSessionUser(user: User | null): void;
|
|
3
|
+
/** A User shaped like the SDK's, for `setSessionUser`. */
|
|
4
|
+
export declare function testUser(id: string, overrides?: Partial<User>): User;
|
|
5
|
+
export declare function getUser(): Promise<User | null>;
|
|
6
|
+
export declare function requireUser(_next?: string): Promise<User>;
|
|
7
|
+
export declare const handlers: () => never;
|
|
8
|
+
export declare const proxy: () => never;
|
|
9
|
+
export declare const authRequest: () => never;
|
|
10
|
+
export declare const signIn: () => never;
|
|
11
|
+
export declare const signUp: () => never;
|
|
12
|
+
export declare const requestPasswordReset: () => never;
|
|
13
|
+
export declare const resetPassword: () => never;
|
|
14
|
+
export declare const sendLink: () => never;
|
|
15
|
+
export declare const resendVerification: () => never;
|
|
16
|
+
/**
|
|
17
|
+
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
18
|
+
*
|
|
19
|
+
* The real one talks to the issuer. This one records what was asked and
|
|
20
|
+
* answers with whatever a test set, so a device/link sign-in flow can be
|
|
21
|
+
* exercised against a real database without one. Only the members named
|
|
22
|
+
* here are stubbed; anything else read off `auth` is a mistake worth a loud
|
|
23
|
+
* one.
|
|
24
|
+
*/
|
|
25
|
+
export declare const sentLinks: {
|
|
26
|
+
email: string;
|
|
27
|
+
next?: string;
|
|
28
|
+
}[];
|
|
29
|
+
export declare function setFollowLinkResult(result: unknown): void;
|
|
30
|
+
export declare function setCookieUser(user: User | null): void;
|
|
31
|
+
export declare const auth: Auth;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stub for whatever `@/lib/auth` resolves to in an app's integration test
|
|
3
|
+
* tier. The real module reads a sealed cookie through `next/headers`, which
|
|
4
|
+
* does not load outside a Next.js build; this one answers with whatever
|
|
5
|
+
* `setSessionUser` last set, so a test can be "signed in" as one person,
|
|
6
|
+
* call the app's own authorization logic for real, then sign in as another.
|
|
7
|
+
* Nothing here talks to the issuer.
|
|
8
|
+
*
|
|
9
|
+
* Alias the app's auth module to `@wtfalch/auth/testing` for that tier (for
|
|
10
|
+
* example in a vitest config's `resolve.alias`), or re-export from it.
|
|
11
|
+
*/
|
|
12
|
+
let current = null;
|
|
13
|
+
export function setSessionUser(user) {
|
|
14
|
+
current = user;
|
|
15
|
+
}
|
|
16
|
+
/** A User shaped like the SDK's, for `setSessionUser`. */
|
|
17
|
+
export function testUser(id, overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
email: `${id}@example.test`,
|
|
21
|
+
emailVerified: true,
|
|
22
|
+
name: `Person ${id}`,
|
|
23
|
+
givenName: null,
|
|
24
|
+
familyName: null,
|
|
25
|
+
preferredUsername: null,
|
|
26
|
+
organizationId: 'org-test',
|
|
27
|
+
// Never fresh, which is what a stubbed session should be: the issuer only
|
|
28
|
+
// owes `auth_time` when the request asked for it, and nothing here asks.
|
|
29
|
+
authTime: null,
|
|
30
|
+
claims: {},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function getUser() {
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
export async function requireUser(_next) {
|
|
38
|
+
if (!current)
|
|
39
|
+
throw new Error('requireUser: no session set; call setSessionUser first');
|
|
40
|
+
return current;
|
|
41
|
+
}
|
|
42
|
+
function unavailable(name) {
|
|
43
|
+
return () => {
|
|
44
|
+
throw new Error(`${name} is not available in the integration tier`);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export const handlers = unavailable('handlers');
|
|
48
|
+
export const proxy = unavailable('proxy');
|
|
49
|
+
export const authRequest = unavailable('authRequest');
|
|
50
|
+
export const signIn = unavailable('signIn');
|
|
51
|
+
export const signUp = unavailable('signUp');
|
|
52
|
+
export const requestPasswordReset = unavailable('requestPasswordReset');
|
|
53
|
+
export const resetPassword = unavailable('resetPassword');
|
|
54
|
+
export const sendLink = unavailable('sendLink');
|
|
55
|
+
export const resendVerification = unavailable('resendVerification');
|
|
56
|
+
/**
|
|
57
|
+
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
58
|
+
*
|
|
59
|
+
* The real one talks to the issuer. This one records what was asked and
|
|
60
|
+
* answers with whatever a test set, so a device/link sign-in flow can be
|
|
61
|
+
* exercised against a real database without one. Only the members named
|
|
62
|
+
* here are stubbed; anything else read off `auth` is a mistake worth a loud
|
|
63
|
+
* one.
|
|
64
|
+
*/
|
|
65
|
+
export const sentLinks = [];
|
|
66
|
+
let linkResult = { ok: false, error: 'invalid_code', message: 'no result set' };
|
|
67
|
+
let cookieUser = null;
|
|
68
|
+
export function setFollowLinkResult(result) {
|
|
69
|
+
linkResult = result;
|
|
70
|
+
}
|
|
71
|
+
export function setCookieUser(user) {
|
|
72
|
+
cookieUser = user;
|
|
73
|
+
}
|
|
74
|
+
export const auth = {
|
|
75
|
+
sessionCookieName: '__Host-wtfalch_auth',
|
|
76
|
+
sendLink: async (email, next) => {
|
|
77
|
+
sentLinks.push({ email, next });
|
|
78
|
+
},
|
|
79
|
+
followLink: async () => linkResult,
|
|
80
|
+
readCookie: async () => ({ user: cookieUser, cookies: [] }),
|
|
81
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
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.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"./sessions": {
|
|
38
38
|
"types": "./dist/sessions.d.ts",
|
|
39
39
|
"default": "./dist/sessions.js"
|
|
40
|
+
},
|
|
41
|
+
"./testing": {
|
|
42
|
+
"types": "./dist/testing.d.ts",
|
|
43
|
+
"default": "./dist/testing.js"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"sideEffects": false,
|