@wtfalch/auth 0.11.0 → 0.12.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 +57 -0
- package/dist/auth.js +59 -11
- package/dist/browser.d.ts +63 -2
- package/dist/browser.js +93 -19
- package/dist/config.d.ts +10 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/namespace-browser.js +13 -3
- package/dist/oidc.d.ts +18 -2
- package/dist/oidc.js +27 -5
- package/package.json +1 -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
|
+
### Hub 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 a hub 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 hub'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
|
@@ -163,6 +163,33 @@ export interface QrPollResult {
|
|
|
163
163
|
reason?: string;
|
|
164
164
|
cookies: SetCookie[];
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Why `accessToken` refused: no session cookie, or one that will not open
|
|
168
|
+
* (`no_session`); a session with no refresh token (`no_refresh_token`); or
|
|
169
|
+
* the issuer refusing the refresh grant itself, including the same
|
|
170
|
+
* organisation/id-token validation `readCookie`'s refresh path applies
|
|
171
|
+
* (`refused`). A transient outage is not in this list -- `accessToken`
|
|
172
|
+
* throws for that instead, see its own doc comment.
|
|
173
|
+
*/
|
|
174
|
+
export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
|
|
175
|
+
export type AccessTokenResult = {
|
|
176
|
+
ok: true;
|
|
177
|
+
/** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
|
|
178
|
+
accessToken: string;
|
|
179
|
+
/** Epoch milliseconds. */
|
|
180
|
+
expiresAt: number;
|
|
181
|
+
/**
|
|
182
|
+
* The re-sealed session -- always exactly one cookie, even when the
|
|
183
|
+
* refresh token itself did not rotate, because the grant always
|
|
184
|
+
* returns a fresh id token too. Persist it, e.g. as a `Set-Cookie`,
|
|
185
|
+
* before the value passed in is used again: the refresh token in it
|
|
186
|
+
* is spent.
|
|
187
|
+
*/
|
|
188
|
+
cookies: SetCookie[];
|
|
189
|
+
} | {
|
|
190
|
+
ok: false;
|
|
191
|
+
reason: AccessTokenReason;
|
|
192
|
+
};
|
|
166
193
|
export interface Auth {
|
|
167
194
|
readonly sessionCookieName: string;
|
|
168
195
|
/** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
|
|
@@ -387,6 +414,36 @@ export interface Auth {
|
|
|
387
414
|
* failed callback rather than throwing.
|
|
388
415
|
*/
|
|
389
416
|
pollQrSignIn(request: Request): Promise<QrPollResult>;
|
|
417
|
+
/**
|
|
418
|
+
* Trades a sealed session cookie's value (`readCookie`'s own `value`
|
|
419
|
+
* parameter, not a `Cookie` header) for a fresh issuer access token. The
|
|
420
|
+
* cookie never holds one -- `Tokens` keeps only an id token and a refresh
|
|
421
|
+
* token -- so this runs the refresh grant itself and reads `access_token`
|
|
422
|
+
* straight off that response, applying the same id-token and organisation
|
|
423
|
+
* checks `readCookie`'s own refresh path applies, through the same
|
|
424
|
+
* `Oidc.refresh`.
|
|
425
|
+
*
|
|
426
|
+
* The refresh token is SPENT on every call: the issuer rotates it on each
|
|
427
|
+
* grant. A caller must serialise calls for one session -- two in flight
|
|
428
|
+
* would race the same token -- and persist `cookies`, the re-sealed
|
|
429
|
+
* session this always returns on `ok: true` (a fresh id token comes back
|
|
430
|
+
* on every grant, even on the rare one that does not rotate the refresh
|
|
431
|
+
* token), before the session is read or `accessToken` is called again.
|
|
432
|
+
*
|
|
433
|
+
* `ok: false` is a definitive refusal -- no session, no refresh token in
|
|
434
|
+
* it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
|
|
435
|
+
* organisation/id-token check `readCookie` applies). A transient issuer
|
|
436
|
+
* outage (network error, 5xx, 429) throws instead of answering
|
|
437
|
+
* `ok: false`: unlike `readCookie`, there is no still-valid access token
|
|
438
|
+
* to fall back to, because one was never kept.
|
|
439
|
+
*
|
|
440
|
+
* Not available in namespace mode: those sessions are a handle to a
|
|
441
|
+
* server-held record, refreshed under a lease (see `namespace-session.ts`)
|
|
442
|
+
* that serialises rotation across replicas. Spending the refresh token
|
|
443
|
+
* here would run outside that lease and could race, or duplicate, the
|
|
444
|
+
* rotation the lease exists to prevent. Throws when `namespace` is set.
|
|
445
|
+
*/
|
|
446
|
+
accessToken(cookieValue: string): Promise<AccessTokenResult>;
|
|
390
447
|
}
|
|
391
448
|
export declare function createAuth(input: AuthOptions): Auth;
|
|
392
449
|
export type { AuthRequest, NewUser, SetCookie };
|
package/dist/auth.js
CHANGED
|
@@ -175,11 +175,21 @@ export function createAuth(input) {
|
|
|
175
175
|
/**
|
|
176
176
|
* Where a sign-out lands: the issuer's own logout, so its session ends and
|
|
177
177
|
* the next sign-in asks who again, rather than straight back to
|
|
178
|
-
* `afterLogout`. What this function can see fail -- no
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
178
|
+
* `afterLogout`. What this function can see fail -- no `end_session_endpoint`
|
|
179
|
+
* published, the issuer not answering -- falls back to the plain redirect a
|
|
180
|
+
* sign-out has always ended with. Losing the issuer's co-operation is never
|
|
181
|
+
* a reason to fail somebody's sign-out.
|
|
182
|
+
*
|
|
183
|
+
* Having no id token is the one failure that does not fall back, because the
|
|
184
|
+
* plain redirect leaves the issuer's session standing and the next sign-in is
|
|
185
|
+
* silent. `end_session` cannot rescue it either: without a hint the issuer
|
|
186
|
+
* takes its v1 branch and ends v1 user-agent sessions, of which a v2 sign-in
|
|
187
|
+
* has none. Routing a hint-less `end_session` to the hosted login instead
|
|
188
|
+
* needs the instance's `loginV2.required`, which overrides every app's own
|
|
189
|
+
* `loginVersion.loginV2.baseUri` with the instance's -- and each app here
|
|
190
|
+
* hosts its own sign-in at `/auth`, so that flag is not available to us.
|
|
191
|
+
* The hosted login's `/logout` sits at the issuer's origin, where
|
|
192
|
+
* `scripts/provision.mjs` points the instance's `loginV2.baseUri`.
|
|
183
193
|
*
|
|
184
194
|
* `afterLogout` not being a registered `post_logout_redirect_uri` is a
|
|
185
195
|
* failure this function cannot see: that request reaches the issuer, which
|
|
@@ -191,11 +201,12 @@ export function createAuth(input) {
|
|
|
191
201
|
*/
|
|
192
202
|
const endIssuerSession = async (idToken, cookies) => {
|
|
193
203
|
const afterLogout = new URL(options().afterLogout, options().appUrl).href;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
204
|
+
if (!idToken) {
|
|
205
|
+
return redirect(new URL('/logout', options().issuer).href, cookies, 303);
|
|
206
|
+
}
|
|
207
|
+
const target = await oidc()
|
|
208
|
+
.endSessionUrl(idToken, afterLogout)
|
|
209
|
+
.catch(() => null);
|
|
199
210
|
return redirect(target?.href ?? afterLogout, cookies, 303);
|
|
200
211
|
};
|
|
201
212
|
const logout = async (request) => {
|
|
@@ -644,7 +655,7 @@ export function createAuth(input) {
|
|
|
644
655
|
const startQrSignIn = async () => {
|
|
645
656
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
646
657
|
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
647
|
-
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
|
|
658
|
+
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
|
|
648
659
|
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
|
|
649
660
|
return {
|
|
650
661
|
url: qrVerificationUrl(options(), device.verificationUriComplete),
|
|
@@ -695,6 +706,42 @@ export function createAuth(input) {
|
|
|
695
706
|
await replacePreviousSession(request.headers.get('cookie'));
|
|
696
707
|
return { status: 'signed_in', cookies: [cleared, session] };
|
|
697
708
|
};
|
|
709
|
+
const accessToken = async (cookieValue) => {
|
|
710
|
+
if (options().namespace) {
|
|
711
|
+
throw new Error('@wtfalch/auth: accessToken is not supported in namespace mode -- those sessions refresh under a server-side lease this would bypass');
|
|
712
|
+
}
|
|
713
|
+
const session = await openSession(options(), cookieValue);
|
|
714
|
+
if (!session)
|
|
715
|
+
return { ok: false, reason: 'no_session' };
|
|
716
|
+
if (!session.rt)
|
|
717
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
718
|
+
let tokens;
|
|
719
|
+
try {
|
|
720
|
+
tokens = await oidc().refresh(session.rt);
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (error instanceof AuthError || isOAuthRefusal(error))
|
|
724
|
+
return { ok: false, reason: 'refused' };
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
if (!tokens.accessToken || tokens.accessTokenExpiresAt === undefined) {
|
|
728
|
+
// The issuer answered the grant but sent no access token -- treat it
|
|
729
|
+
// the same as a refusal rather than handing back an unusable result.
|
|
730
|
+
return { ok: false, reason: 'refused' };
|
|
731
|
+
}
|
|
732
|
+
// Always re-sealed, even when the refresh token itself did not rotate:
|
|
733
|
+
// the grant always returns a fresh id token, and readCookie's own
|
|
734
|
+
// refresh path re-seals on every successful refresh for the same reason.
|
|
735
|
+
const cookies = [
|
|
736
|
+
await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken }),
|
|
737
|
+
];
|
|
738
|
+
return {
|
|
739
|
+
ok: true,
|
|
740
|
+
accessToken: tokens.accessToken,
|
|
741
|
+
expiresAt: tokens.accessTokenExpiresAt,
|
|
742
|
+
cookies,
|
|
743
|
+
};
|
|
744
|
+
};
|
|
698
745
|
const failure = (reason, cookies) => {
|
|
699
746
|
const url = new URL(options().onError, options().appUrl);
|
|
700
747
|
url.searchParams.set('auth_error', reason);
|
|
@@ -748,6 +795,7 @@ export function createAuth(input) {
|
|
|
748
795
|
finishPasskeySignIn,
|
|
749
796
|
startQrSignIn,
|
|
750
797
|
pollQrSignIn,
|
|
798
|
+
accessToken,
|
|
751
799
|
};
|
|
752
800
|
}
|
|
753
801
|
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 hub.
|
|
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
|
@@ -14,6 +14,15 @@ export interface AuthOptions {
|
|
|
14
14
|
clientId: string | undefined;
|
|
15
15
|
/** From scripts/provisioned.json. Sent as a scope and checked on every token. */
|
|
16
16
|
organizationId: string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Extra scopes appended to `scopeFor`'s fixed set, on both the
|
|
19
|
+
* authorization request `start` builds and the QR device grant --
|
|
20
|
+
* a project's `:aud` scope, say. Each entry is split on whitespace,
|
|
21
|
+
* empty entries dropped, and duplicates (against the fixed scopes and
|
|
22
|
+
* each other) removed. Default: none, and the scope string this SDK has
|
|
23
|
+
* always sent is unchanged, byte for byte.
|
|
24
|
+
*/
|
|
25
|
+
extraScopes?: string[];
|
|
17
26
|
/**
|
|
18
27
|
* 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app.
|
|
19
28
|
*
|
|
@@ -57,6 +66,7 @@ export interface ResolvedOptions {
|
|
|
57
66
|
appUrl: URL;
|
|
58
67
|
clientId: string;
|
|
59
68
|
organizationId: string;
|
|
69
|
+
extraScopes: string[];
|
|
60
70
|
/**
|
|
61
71
|
* Decoded on first access, not at resolve time.
|
|
62
72
|
*
|
package/dist/config.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
1
|
+
export { type AccessTokenReason, type AccessTokenResult, 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
2
|
export { type AuthOptions, ISSUER } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
@@ -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');
|
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) {
|
package/package.json
CHANGED