@web-ts-toolkit/express-oidc-vault 0.51.0 → 0.53.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 +164 -335
- package/index.d.mts +15 -64
- package/index.d.ts +15 -64
- package/index.js +50 -31
- package/index.mjs +50 -31
- package/package.json +11 -1
- package/wire.d.mts +76 -0
- package/wire.d.ts +76 -0
- package/wire.js +18 -0
- package/wire.mjs +0 -0
package/README.md
CHANGED
|
@@ -27,13 +27,29 @@ pnpm add @web-ts-toolkit/express-oidc-vault express
|
|
|
27
27
|
|
|
28
28
|
For the quick start, also install `@web-ts-toolkit/express-oidc-vault-memory-store`. TypeScript applications need `@types/express` and `@types/node` as development dependencies. Express `>=5.0.0` is the runtime peer dependency.
|
|
29
29
|
|
|
30
|
-
Use **named imports from the package root
|
|
30
|
+
Use **named imports from the package root** for backend APIs; there is no default export. The pure [`/wire` type entry](#browser-wire-types) is available for browser DTO consumers.
|
|
31
31
|
|
|
32
32
|
## Requirements
|
|
33
33
|
|
|
34
34
|
- Node.js `>=22.12.0`. The published CJS entry (`index.js`) synchronously requires the ESM-only `jose` dependency, which needs Node's `require(esm)` support. That support is enabled by default starting with Node `22.12.0`; earlier Node 22 releases fail to load the CJS root with `ERR_REQUIRE_ESM` unless an experimental flag is passed. Both the CJS (`require`) and ESM (`import`) roots load without experimental flags on every verified runtime (`22.12.0`, `22.18.0`, `22.20.0`, `24.x`, `26.x`).
|
|
35
35
|
- TypeScript consumers typecheck with `skipLibCheck: false` under strict `NodeNext`/`Bundler` settings. ESM consumers resolve the `import` declaration condition (`index.d.mts`); CommonJS (`.cts`) consumers resolve the `require` condition (`index.d.ts`). Both include the public Express `req.auth` augmentation. Workspace builds place these files under `dist/`; release packaging moves them to the package root and rewrites metadata accordingly. Consumer imports always use the package name.
|
|
36
36
|
|
|
37
|
+
### Browser wire types
|
|
38
|
+
|
|
39
|
+
`@web-ts-toolkit/express-oidc-vault/wire` exports **types only**:
|
|
40
|
+
`OidcVaultLoginInitiationInput`, `OidcVaultLoginInitiationResult`,
|
|
41
|
+
`OidcVaultExchangeResult`, `OidcVaultLogoutResult`, `OidcVaultSessionTransport`,
|
|
42
|
+
`OidcVaultTokenIssueResult`, and `OidcVaultUserProfile`. These are the same
|
|
43
|
+
single-sourced DTOs available at the backend root. Use `import type` from
|
|
44
|
+
`/wire` in browser code; it has no Express augmentation or Node type dependency.
|
|
45
|
+
Its ESM/CJS declarations are `wire.d.mts` / `wire.d.ts`, with empty
|
|
46
|
+
`wire.mjs` / `wire.js` runtime entries and no runtime imports. Both layouts
|
|
47
|
+
follow the workspace/release path rules above.
|
|
48
|
+
|
|
49
|
+
The companion browser client bundles the DTO declarations it needs, so installing
|
|
50
|
+
`@web-ts-toolkit/oidc-vault-dpop-client` needs neither this backend package nor
|
|
51
|
+
Express/Node ambient types. See [Browser DPoP client](#browser-dpop-client).
|
|
52
|
+
|
|
37
53
|
## Frontend Storage Policy
|
|
38
54
|
|
|
39
55
|
Default browser-side transport:
|
|
@@ -107,19 +123,19 @@ Cookie-authenticated `refresh` and `logout` requests use a fail-closed CSRF poli
|
|
|
107
123
|
|
|
108
124
|
**Session transport and device-binding mode are independent options.** Opt-in POST login and guarded exchange always need the temporary transaction cookie, including with `sessionTransport: 'body'`. The session cookie is separate and starts at successful exchange. Same-site subdomains can use Lax cookies with credentialed cross-origin requests; cross-origin does not itself mean cross-site. Cross-site SPAs need an explicit HTTPS `transactionCookie: { sameSite: 'none' }` as well as suitable session-cookie settings, credentialed CORS, and a browser policy that permits those cookies.
|
|
109
125
|
|
|
110
|
-
Recommended frontend boot flow:
|
|
126
|
+
Recommended frontend boot flow (default unbound transport):
|
|
111
127
|
|
|
112
128
|
1. Read `sessionId` from `sessionStorage`.
|
|
113
129
|
2. If present, call `POST /auth/oidc/refresh` immediately.
|
|
114
130
|
3. If refresh succeeds, replace the stored `sessionId` with the rotated value and keep the returned `accessToken` in memory only.
|
|
115
|
-
4.
|
|
131
|
+
4. On a terminal session error, clear the handle and route to sign-in. A transient/proof/nonce failure is not evidence that the session was revoked; retain recovery state and apply the operation's retry contract.
|
|
116
132
|
|
|
117
133
|
If you use `sessionTransport: 'cookie'`, the frontend boot flow becomes simpler:
|
|
118
134
|
|
|
119
135
|
1. Keep `accessToken` in memory only.
|
|
120
136
|
2. Call `POST /auth/oidc/refresh` on app startup.
|
|
121
137
|
3. Let the backend read and rotate the session cookie.
|
|
122
|
-
4.
|
|
138
|
+
4. On a terminal session error, clear local auth and route to sign-in. The DPoP client classifies these using `OidcVaultDpopClientError.requiresLogin`, not HTTP status alone.
|
|
123
139
|
|
|
124
140
|
## Endpoints
|
|
125
141
|
|
|
@@ -300,7 +316,7 @@ For cookie transport, change `sessionTransport` to `'cookie'` and configure the
|
|
|
300
316
|
|
|
301
317
|
### Device-binding policy and issuance contracts
|
|
302
318
|
|
|
303
|
-
The package root exposes policy, record, local-issuer, guarded-store, POST-login DTO/cookie, and request-aware API contracts. **The backend lifecycle is implemented:** POST initiation, cookie-authenticated callback, original-key exchange/refresh/logout (including unexpired aliases), and API proof/nonce/shared replay enforcement. The [
|
|
319
|
+
The package root exposes policy, record, local-issuer, guarded-store, POST-login DTO/cookie, and request-aware API contracts. **The backend lifecycle is implemented:** POST initiation, cookie-authenticated callback, original-key exchange/refresh/logout (including unexpired aliases), and API proof/nonce/shared replay enforcement. The [persistent-key SPA example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md) includes both transports, an Express/local IdP fixture, and real-browser redirect/reload/IndexedDB/CORS/nonce/cross-tab checks. [Request-aware DPoP APIs](#request-aware-dpop-apis) describes API configuration.
|
|
304
320
|
|
|
305
321
|
`OidcVaultOptions.deviceBinding?: OidcVaultDeviceBindingOptions` is opt-in. Omitting it preserves bearer behavior. An object defaults to `mode: 'optional'`; `required` rejects unbound context. Legacy records are never enrolled by a later proof, and stored bound credentials never become bearer credentials by omission. Construction requires all six `OidcVaultDeviceBindingStoreProvider` capabilities and a static HTTPS `backendOrigin`, except loopback HTTP development.
|
|
306
322
|
|
|
@@ -385,10 +401,13 @@ app.use(
|
|
|
385
401
|
|
|
386
402
|
The initiation wire contract is **`POST <basePath>/login`**, `Content-Type: application/json`, body **`{ returnTo?: string }`**, response **`200 { authorizationUrl: string }`** (`OidcVaultLoginInitiationInput` / `OidcVaultLoginInitiationResult`). The key is selected only by a verified DPoP header. `returnTo` is read only from JSON and uses the existing same-frontend-origin policy; query-string/body JWK/thumbprint shortcuts do not select binding. In optional mode, no proof creates an unbound cookie-guarded POST transaction; any supplied invalid proof fails. Required mode needs a proof and rejects legacy GET login before discovery or state allocation. GET in disabled/optional mode remains the unbound redirect and ignores proof/key shortcuts.
|
|
387
403
|
|
|
388
|
-
A browser using a persistent key can initiate as follows; the [
|
|
404
|
+
A browser using a persistent key can initiate as follows; the [published browser client](#browser-dpop-client) generates/persists the key and bounds retries:
|
|
389
405
|
|
|
390
406
|
```ts
|
|
391
|
-
import type {
|
|
407
|
+
import type {
|
|
408
|
+
OidcVaultLoginInitiationInput,
|
|
409
|
+
OidcVaultLoginInitiationResult,
|
|
410
|
+
} from '@web-ts-toolkit/express-oidc-vault/wire';
|
|
392
411
|
|
|
393
412
|
async function initiateLogin(freshDpopProof: string): Promise<OidcVaultLoginInitiationResult> {
|
|
394
413
|
// Proof: typ=dpop+jwt, allowed public JWK, POST, pinned absolute login htu,
|
|
@@ -436,7 +455,7 @@ Vault POST proofs use the exact method and pinned `backendOrigin` plus externall
|
|
|
436
455
|
For example, with a configured local issuer, the exchange response can be checked without backend deep imports (the caller supplies a fresh proof from its persistent original key):
|
|
437
456
|
|
|
438
457
|
```ts
|
|
439
|
-
import type { OidcVaultExchangeResult } from '@web-ts-toolkit/express-oidc-vault';
|
|
458
|
+
import type { OidcVaultExchangeResult } from '@web-ts-toolkit/express-oidc-vault/wire';
|
|
440
459
|
|
|
441
460
|
async function exchangeBoundCode(code: string, freshDpopProof: string): Promise<OidcVaultExchangeResult> {
|
|
442
461
|
const response = await fetch('https://api.example.com/auth/oidc/exchange', {
|
|
@@ -558,7 +577,9 @@ async function initiateRecognizedLogin(getSignal: () => Promise<string | undefin
|
|
|
558
577
|
|
|
559
578
|
Obtain the same current signal through the source at `/exchange` (body `{ code }`) and `/refresh` (body `{ sessionId }` or cookie `{}`). Login/exchange always use `credentials: 'include'` for the temporary cookie; cookie refresh does too. On a recognition 403, start fresh login rather than a refresh retry loop. CORS must allow the configured fingerprint header and `Content-Type`, explicit trusted origins and credentials; also allow `Authorization`/`DPoP` and expose `DPoP-Nonce`/`WWW-Authenticate` when using DPoP. Cross-site SPAs may need explicit HTTPS `transactionCookie: { sameSite: 'none' }`; browser third-party-cookie policy still applies.
|
|
560
579
|
|
|
561
|
-
|
|
580
|
+
### Published recognition adapter
|
|
581
|
+
|
|
582
|
+
The published browser client [`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md) ships this generic adapter as `createDeviceFingerprint(source, { headerName? })` plus a structural optional-vendor adapter:
|
|
562
583
|
|
|
563
584
|
```ts
|
|
564
585
|
// In your frontend, after choosing to collect recognition:
|
|
@@ -605,358 +626,157 @@ Omitting `sessionTtlMs` assigns no default session expiry and retains applicatio
|
|
|
605
626
|
|
|
606
627
|
## Frontend Integration Example
|
|
607
628
|
|
|
608
|
-
###
|
|
609
|
-
|
|
610
|
-
For the [complete server above](#complete-dpop-server), install `jose` + `idb` in a browser-bundled TypeScript app. This self-contained, **single-tab body-transport** recipe includes persistent key creation, POST login, callback exchange, API proof/nonce retry, refresh after JWT expiry, and logout. It uses only browser libraries and application code; nothing is imported from an Express package at browser runtime. Configure `backendOrigin`/`basePath` to the exact public mount and keep the same key throughout.
|
|
611
|
-
|
|
612
|
-
```sh
|
|
613
|
-
pnpm add jose idb
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
```ts
|
|
617
|
-
import { base64url, calculateJwkThumbprint, decodeJwt, SignJWT, type JWK } from 'jose';
|
|
618
|
-
import { openDB, type DBSchema } from 'idb';
|
|
619
|
-
|
|
620
|
-
const backendOrigin = 'https://api.example.com';
|
|
621
|
-
const basePath = '/auth/oidc';
|
|
622
|
-
const scope = JSON.stringify(['oidc-vault-dpop-v1', location.origin, backendOrigin, basePath]);
|
|
623
|
-
const handleName = `dpop:handle:${scope}`;
|
|
624
|
-
const pendingName = `dpop:pending:${scope}`;
|
|
625
|
-
interface Key {
|
|
626
|
-
privateKey: CryptoKey;
|
|
627
|
-
publicJwk: JWK;
|
|
628
|
-
jkt: string;
|
|
629
|
-
}
|
|
630
|
-
interface Database extends DBSchema {
|
|
631
|
-
keys: { key: string; value: Key };
|
|
632
|
-
}
|
|
633
|
-
interface Handle {
|
|
634
|
-
sessionId: string;
|
|
635
|
-
jkt: string;
|
|
636
|
-
}
|
|
637
|
-
interface Token {
|
|
638
|
-
accessToken: string;
|
|
639
|
-
expiresAt: number;
|
|
640
|
-
jkt: string;
|
|
641
|
-
}
|
|
642
|
-
let token: Token | undefined; // Never persist the access token.
|
|
643
|
-
let refreshPromise: Promise<Token> | undefined;
|
|
644
|
-
const nonces = new Map<string, string>();
|
|
645
|
-
const database = () =>
|
|
646
|
-
openDB<Database>('my-app-dpop', 1, {
|
|
647
|
-
upgrade(db) {
|
|
648
|
-
db.createObjectStore('keys');
|
|
649
|
-
},
|
|
650
|
-
});
|
|
651
|
-
function clearAuth(): void {
|
|
652
|
-
token = undefined;
|
|
653
|
-
nonces.clear();
|
|
654
|
-
sessionStorage.removeItem(handleName);
|
|
655
|
-
sessionStorage.removeItem(pendingName);
|
|
656
|
-
}
|
|
657
|
-
function requireLogin(): never {
|
|
658
|
-
clearAuth();
|
|
659
|
-
throw new Error('Sign in again.');
|
|
660
|
-
}
|
|
661
|
-
function readHandle(): Handle {
|
|
662
|
-
const value: unknown = JSON.parse(sessionStorage.getItem(handleName) ?? 'null');
|
|
663
|
-
if (
|
|
664
|
-
!value ||
|
|
665
|
-
typeof value !== 'object' ||
|
|
666
|
-
!('sessionId' in value) ||
|
|
667
|
-
typeof value.sessionId !== 'string' ||
|
|
668
|
-
!value.sessionId ||
|
|
669
|
-
!('jkt' in value) ||
|
|
670
|
-
typeof value.jkt !== 'string'
|
|
671
|
-
)
|
|
672
|
-
return requireLogin();
|
|
673
|
-
return { sessionId: value.sessionId, jkt: value.jkt };
|
|
674
|
-
}
|
|
675
|
-
async function readKey(create = false, expectedJkt?: string): Promise<Key> {
|
|
676
|
-
if (!isSecureContext || !crypto.subtle || !globalThis.indexedDB) return requireLogin();
|
|
677
|
-
const db = await database();
|
|
678
|
-
try {
|
|
679
|
-
let key = await db.get('keys', scope);
|
|
680
|
-
if (!key) {
|
|
681
|
-
if (!create) return requireLogin(); // Only a fresh login may create a key.
|
|
682
|
-
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
|
|
683
|
-
const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
|
|
684
|
-
const publicJwk: JWK = { kty: 'EC', crv: 'P-256', x: jwk.x!, y: jwk.y! };
|
|
685
|
-
const candidate: Key = { privateKey: pair.privateKey, publicJwk, jkt: await calculateJwkThumbprint(publicJwk) };
|
|
686
|
-
// Crypto outside the transaction; one atomic first-key winner across tabs.
|
|
687
|
-
const tx = db.transaction('keys', 'readwrite');
|
|
688
|
-
key = await tx.store.get(scope);
|
|
689
|
-
if (!key) {
|
|
690
|
-
await tx.store.add(candidate, scope);
|
|
691
|
-
key = candidate;
|
|
692
|
-
}
|
|
693
|
-
await tx.done;
|
|
694
|
-
}
|
|
695
|
-
const algorithm = key.privateKey?.algorithm as EcKeyAlgorithm | undefined;
|
|
696
|
-
if (
|
|
697
|
-
!(key.privateKey instanceof CryptoKey) ||
|
|
698
|
-
key.privateKey.type !== 'private' ||
|
|
699
|
-
key.privateKey.extractable ||
|
|
700
|
-
algorithm?.name !== 'ECDSA' ||
|
|
701
|
-
algorithm.namedCurve !== 'P-256' ||
|
|
702
|
-
key.publicJwk?.kty !== 'EC' ||
|
|
703
|
-
key.publicJwk.crv !== 'P-256' ||
|
|
704
|
-
Object.keys(key.publicJwk).length !== 4 ||
|
|
705
|
-
(await calculateJwkThumbprint(key.publicJwk)) !== key.jkt ||
|
|
706
|
-
(expectedJkt !== undefined && expectedJkt !== key.jkt)
|
|
707
|
-
)
|
|
708
|
-
return requireLogin();
|
|
709
|
-
const publicKey = await crypto.subtle.importKey(
|
|
710
|
-
'jwk',
|
|
711
|
-
key.publicJwk,
|
|
712
|
-
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
713
|
-
true,
|
|
714
|
-
['verify'],
|
|
715
|
-
);
|
|
716
|
-
const data = new TextEncoder().encode('dpop-key-pair-check');
|
|
717
|
-
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, data);
|
|
718
|
-
if (!(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature, data)))
|
|
719
|
-
return requireLogin();
|
|
720
|
-
return key;
|
|
721
|
-
} finally {
|
|
722
|
-
db.close();
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
function target(url: URL): string {
|
|
726
|
-
// URL already normalizes host/default port/dot segments. Normalize unreserved
|
|
727
|
-
// escapes before final dot removal; preserve reserved escapes such as %2F.
|
|
728
|
-
const path = url.pathname.replace(/%([0-9a-f]{2})/gi, (_, hex: string) => {
|
|
729
|
-
const c = String.fromCharCode(parseInt(hex, 16));
|
|
730
|
-
return /^[A-Za-z0-9._~-]$/.test(c) ? c : `%${hex.toUpperCase()}`;
|
|
731
|
-
});
|
|
732
|
-
return new URL(`${url.origin}${path}`).href; // No query or fragment.
|
|
733
|
-
}
|
|
734
|
-
export async function signRequestProof(
|
|
735
|
-
key: Key,
|
|
736
|
-
method: string,
|
|
737
|
-
url: URL,
|
|
738
|
-
accessToken?: string,
|
|
739
|
-
nonce?: string,
|
|
740
|
-
): Promise<string> {
|
|
741
|
-
const ath =
|
|
742
|
-
accessToken === undefined
|
|
743
|
-
? undefined
|
|
744
|
-
: base64url.encode(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken))));
|
|
745
|
-
return new SignJWT({
|
|
746
|
-
htm: method,
|
|
747
|
-
htu: target(url),
|
|
748
|
-
iat: Math.floor(Date.now() / 1000),
|
|
749
|
-
jti: base64url.encode(crypto.getRandomValues(new Uint8Array(16))),
|
|
750
|
-
...(ath === undefined ? {} : { ath }),
|
|
751
|
-
...(nonce === undefined ? {} : { nonce }),
|
|
752
|
-
})
|
|
753
|
-
.setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk })
|
|
754
|
-
.sign(key.privateKey);
|
|
755
|
-
}
|
|
756
|
-
function rememberNonce(space: string, response: Response): boolean {
|
|
757
|
-
const nonce = response.headers.get('DPoP-Nonce');
|
|
758
|
-
if (!nonce || nonce.length > 512 || !/^[\x20-\x7e]+$/.test(nonce)) return false;
|
|
759
|
-
nonces.set(space, nonce);
|
|
760
|
-
return true;
|
|
761
|
-
}
|
|
762
|
-
async function post(route: 'login' | 'exchange' | 'refresh' | 'logout', body: object, key: Key): Promise<unknown> {
|
|
763
|
-
const url = new URL(`${backendOrigin}${basePath}/${route}`);
|
|
764
|
-
const space = JSON.stringify(['vault', scope, key.jkt]);
|
|
765
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
766
|
-
key = await readKey(false, key.jkt);
|
|
767
|
-
const proof = await signRequestProof(key, 'POST', url, undefined, nonces.get(space));
|
|
768
|
-
const response = await fetch(url, {
|
|
769
|
-
method: 'POST',
|
|
770
|
-
redirect: 'error',
|
|
771
|
-
cache: 'no-store',
|
|
772
|
-
credentials: route === 'login' || route === 'exchange' ? 'include' : 'omit',
|
|
773
|
-
headers: { 'Content-Type': 'application/json', DPoP: proof },
|
|
774
|
-
body: JSON.stringify(body),
|
|
775
|
-
});
|
|
776
|
-
const value: unknown = await response.json();
|
|
777
|
-
if (response.ok) return value;
|
|
778
|
-
const code = value && typeof value === 'object' && 'code' in value ? value.code : undefined;
|
|
779
|
-
if (
|
|
780
|
-
response.status === 400 &&
|
|
781
|
-
code === 'OIDC_VAULT_USE_DPOP_NONCE' &&
|
|
782
|
-
attempt === 0 &&
|
|
783
|
-
rememberNonce(space, response)
|
|
784
|
-
)
|
|
785
|
-
continue;
|
|
786
|
-
if (code === 'OIDC_VAULT_INVALID_SESSION' || code === 'OIDC_VAULT_FINGERPRINT_REAUTH_REQUIRED')
|
|
787
|
-
return requireLogin();
|
|
788
|
-
throw new Error('Vault request failed.'); // Do not silently retry proof/503/network failures.
|
|
789
|
-
}
|
|
790
|
-
throw new Error('Repeated nonce challenge.');
|
|
791
|
-
}
|
|
792
|
-
function accept(value: unknown, key: Key): Token {
|
|
793
|
-
if (
|
|
794
|
-
!value ||
|
|
795
|
-
typeof value !== 'object' ||
|
|
796
|
-
!('accessToken' in value) ||
|
|
797
|
-
typeof value.accessToken !== 'string' ||
|
|
798
|
-
!('tokenType' in value) ||
|
|
799
|
-
value.tokenType !== 'DPoP' ||
|
|
800
|
-
!('expiresIn' in value) ||
|
|
801
|
-
typeof value.expiresIn !== 'number' ||
|
|
802
|
-
!Number.isSafeInteger(value.expiresIn) ||
|
|
803
|
-
value.expiresIn < 0 ||
|
|
804
|
-
!('sessionId' in value) ||
|
|
805
|
-
typeof value.sessionId !== 'string'
|
|
806
|
-
)
|
|
807
|
-
return requireLogin();
|
|
808
|
-
// Check the trusted server's response contract; this decoding is NOT JWT authentication.
|
|
809
|
-
const cnf = decodeJwt(value.accessToken).cnf as { jkt?: unknown } | undefined;
|
|
810
|
-
if (cnf?.jkt !== key.jkt) return requireLogin();
|
|
811
|
-
sessionStorage.setItem(handleName, JSON.stringify({ sessionId: value.sessionId, jkt: key.jkt }));
|
|
812
|
-
token = { accessToken: value.accessToken, expiresAt: Date.now() + value.expiresIn * 1000, jkt: key.jkt };
|
|
813
|
-
return token;
|
|
814
|
-
}
|
|
815
|
-
export async function signIn(): Promise<void> {
|
|
816
|
-
clearAuth();
|
|
817
|
-
const key = await readKey(true);
|
|
818
|
-
sessionStorage.setItem(pendingName, key.jkt);
|
|
819
|
-
const value = await post('login', { returnTo: '/callback' }, key);
|
|
820
|
-
if (
|
|
821
|
-
!value ||
|
|
822
|
-
typeof value !== 'object' ||
|
|
823
|
-
!('authorizationUrl' in value) ||
|
|
824
|
-
typeof value.authorizationUrl !== 'string'
|
|
825
|
-
)
|
|
826
|
-
return requireLogin();
|
|
827
|
-
const url = new URL(value.authorizationUrl);
|
|
828
|
-
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) return requireLogin();
|
|
829
|
-
location.assign(url.href);
|
|
830
|
-
}
|
|
831
|
-
export function refresh(): Promise<Token> {
|
|
832
|
-
if (!refreshPromise)
|
|
833
|
-
refreshPromise = (async () => {
|
|
834
|
-
const handle = readHandle();
|
|
835
|
-
const key = await readKey(false, handle.jkt);
|
|
836
|
-
return accept(await post('refresh', { sessionId: handle.sessionId }, key), key);
|
|
837
|
-
})().finally(() => {
|
|
838
|
-
refreshPromise = undefined;
|
|
839
|
-
});
|
|
840
|
-
return refreshPromise; // No access token/Authorization/ath needed, even after expiry.
|
|
841
|
-
}
|
|
842
|
-
export async function bootstrap(): Promise<void> {
|
|
843
|
-
const url = new URL(location.href);
|
|
844
|
-
const code = url.searchParams.get('code');
|
|
845
|
-
if (code) {
|
|
846
|
-
url.searchParams.delete('code');
|
|
847
|
-
history.replaceState(null, '', url.href);
|
|
848
|
-
const pending = sessionStorage.getItem(pendingName);
|
|
849
|
-
if (!pending) return requireLogin();
|
|
850
|
-
const key = await readKey(false, pending);
|
|
851
|
-
accept(await post('exchange', { code }, key), key);
|
|
852
|
-
sessionStorage.removeItem(pendingName);
|
|
853
|
-
} else if (sessionStorage.getItem(handleName)) {
|
|
854
|
-
await refresh();
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
export async function getProfile(): Promise<unknown> {
|
|
858
|
-
const url = new URL(`${backendOrigin}/api/profile`); // Fixed API scope; no arbitrary credential target.
|
|
859
|
-
let credential = token;
|
|
860
|
-
let refreshed = false;
|
|
861
|
-
if (!credential || credential.expiresAt <= Date.now()) {
|
|
862
|
-
credential = await refresh();
|
|
863
|
-
refreshed = true;
|
|
864
|
-
}
|
|
865
|
-
const space = JSON.stringify(['api', url.origin, 'app-api-v1', credential.jkt]);
|
|
866
|
-
let nonceRetried = false;
|
|
867
|
-
for (;;) {
|
|
868
|
-
const key = await readKey(false, credential.jkt);
|
|
869
|
-
const proof = await signRequestProof(key, 'GET', url, credential.accessToken, nonces.get(space));
|
|
870
|
-
const response = await fetch(url, {
|
|
871
|
-
redirect: 'error',
|
|
872
|
-
credentials: 'omit',
|
|
873
|
-
cache: 'no-store',
|
|
874
|
-
headers: { Authorization: `DPoP ${credential.accessToken}`, DPoP: proof },
|
|
875
|
-
});
|
|
876
|
-
const error = /(?:^|,\s*)DPoP\s+error="([a-z_]+)"(?:,|$)/i.exec(
|
|
877
|
-
response.headers.get('WWW-Authenticate') ?? '',
|
|
878
|
-
)?.[1];
|
|
879
|
-
if (response.status === 401 && error === 'use_dpop_nonce' && !nonceRetried && rememberNonce(space, response)) {
|
|
880
|
-
nonceRetried = true;
|
|
881
|
-
await response.body?.cancel();
|
|
882
|
-
continue;
|
|
883
|
-
}
|
|
884
|
-
if (response.status === 401 && error === 'invalid_token' && !refreshed) {
|
|
885
|
-
refreshed = true;
|
|
886
|
-
await response.body?.cancel();
|
|
887
|
-
credential = await refresh();
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
if (!response.ok) throw new Error('Protected API request failed.');
|
|
891
|
-
return response.json();
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
export async function signOut(): Promise<void> {
|
|
895
|
-
const handle = readHandle();
|
|
896
|
-
const key = await readKey(false, handle.jkt);
|
|
897
|
-
try {
|
|
898
|
-
await post('logout', { sessionId: handle.sessionId }, key);
|
|
899
|
-
} finally {
|
|
900
|
-
clearAuth();
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
```
|
|
904
|
-
|
|
905
|
-
Wire it to a sign-in button (`signIn()`), callback/app startup (`bootstrap()`), a profile action (`getProfile()`), and logout (`signOut()`). Keep these operations sequential around login/logout. Storage/crypto failures stop the operation; clear frontend auth and request fresh login, without an ephemeral-key or Bearer fallback. This recipe's body handle is **tab-local**: atomic key creation does not coordinate shared refresh authority. Do not copy handles or rely on opener-cloned sessionStorage. The example helpers below add cookie-mode Web Locks/BroadcastChannel, richer scoped fetch/body retry rules, current recognition and error handling.
|
|
906
|
-
|
|
907
|
-
### Persistent-key DPoP SPA example
|
|
629
|
+
### Browser DPoP client
|
|
908
630
|
|
|
909
631
|
For a bound flow, install the published browser client
|
|
910
632
|
[`@web-ts-toolkit/oidc-vault-dpop-client`](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md)
|
|
911
633
|
in your frontend (it brings `jose` + `idb` as runtime dependencies) and use its
|
|
912
634
|
named root exports. These are **versioned package exports**, separate from the
|
|
913
|
-
Express package.
|
|
914
|
-
|
|
915
|
-
`/auth/oidc/body` and `/auth/oidc/cookie`. Your own core mount can use the
|
|
916
|
-
default `/auth/oidc`.
|
|
635
|
+
Express package. This complete browser module pairs with the
|
|
636
|
+
[DPoP server above](#complete-dpop-server) at `/auth/oidc`:
|
|
917
637
|
|
|
918
638
|
```sh
|
|
919
639
|
pnpm add @web-ts-toolkit/oidc-vault-dpop-client
|
|
920
640
|
```
|
|
921
641
|
|
|
922
642
|
```ts
|
|
923
|
-
import {
|
|
643
|
+
import {
|
|
644
|
+
createOidcVaultDpopSession,
|
|
645
|
+
fetchWithDpop,
|
|
646
|
+
OidcVaultDpopClientError,
|
|
647
|
+
} from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
924
648
|
|
|
925
|
-
const backendOrigin = '
|
|
926
|
-
const session = createOidcVaultDpopSession({
|
|
649
|
+
const backendOrigin = 'https://api.example.com';
|
|
650
|
+
const session = createOidcVaultDpopSession({
|
|
651
|
+
backendOrigin,
|
|
652
|
+
basePath: '/auth/oidc',
|
|
653
|
+
sessionTransport: 'body',
|
|
654
|
+
requestTimeoutMs: 15_000,
|
|
655
|
+
maxResponseBytes: 1_048_576,
|
|
656
|
+
});
|
|
927
657
|
|
|
928
|
-
export const signIn = (): Promise<void> => session.login('/callback
|
|
658
|
+
export const signIn = (): Promise<void> => session.login('/callback');
|
|
929
659
|
|
|
930
|
-
export async function bootstrapBoundAuth(): Promise<
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
660
|
+
export async function bootstrapBoundAuth(): Promise<'ready' | 'sign-in'> {
|
|
661
|
+
try {
|
|
662
|
+
const url = new URL(location.href);
|
|
663
|
+
const code = url.searchParams.get('code');
|
|
664
|
+
if (code !== null) {
|
|
665
|
+
url.searchParams.delete('code');
|
|
666
|
+
history.replaceState(null, '', url.href);
|
|
667
|
+
await session.exchange(code);
|
|
668
|
+
} else {
|
|
669
|
+
await session.refresh(); // Persisted key; no access-token Authorization/ath.
|
|
670
|
+
}
|
|
671
|
+
return 'ready';
|
|
672
|
+
} catch (error) {
|
|
673
|
+
if (error instanceof OidcVaultDpopClientError && error.requiresLogin) return 'sign-in';
|
|
674
|
+
throw error; // Retain state on transient/proof/nonce errors; no blanket retry.
|
|
939
675
|
}
|
|
940
676
|
}
|
|
941
677
|
|
|
942
|
-
export async function getBoundProfile(): Promise<unknown> {
|
|
678
|
+
export async function getBoundProfile(signal?: AbortSignal): Promise<unknown> {
|
|
943
679
|
const response = await fetchWithDpop(
|
|
944
|
-
{ session, apis: [{ origin: backendOrigin, replayNamespace: '
|
|
680
|
+
{ session, apis: [{ origin: backendOrigin, pathPrefix: '/api', replayNamespace: 'app-api-v1' }] },
|
|
945
681
|
`${backendOrigin}/api/profile`,
|
|
682
|
+
{ signal },
|
|
946
683
|
);
|
|
947
684
|
if (!response.ok) throw new Error('Protected API request failed.');
|
|
948
685
|
return response.json();
|
|
949
686
|
}
|
|
950
687
|
export const signOut = (): Promise<void> => session.logout();
|
|
688
|
+
|
|
689
|
+
// Explicit user action for an existing corrupt key; ordinary sign-in needs no reset.
|
|
690
|
+
export async function recoverBrowserKey(): Promise<void> {
|
|
691
|
+
await session.resetKey();
|
|
692
|
+
await signIn();
|
|
693
|
+
}
|
|
951
694
|
```
|
|
952
695
|
|
|
953
|
-
|
|
696
|
+
For cookie transport, select `sessionTransport: 'cookie'` on both sides and the
|
|
697
|
+
matching public mount. The runnable [SPA + vault/API + local IdP example](https://github.com/egose/web-ts-toolkit/blob/main/apps/oidc-vault-dpop-example/README.md)
|
|
698
|
+
uses separate `/auth/oidc/body` and `/auth/oidc/cookie` mounts and cookie names.
|
|
699
|
+
|
|
700
|
+
`login()` atomically persists a non-extractable P-256 key before POST/navigation.
|
|
701
|
+
Exchange/refresh/API/logout use that existing key; loss/change requires fresh
|
|
702
|
+
login, while an existing unusable key needs explicit `resetKey()` then login.
|
|
703
|
+
Tokens stay in memory, body handles/pending login in tab-local sessionStorage,
|
|
704
|
+
and cookie handles in backend HttpOnly cookies. All flows require secure-context
|
|
705
|
+
Web Crypto and IndexedDB CryptoKey persistence; cookie mode additionally needs
|
|
706
|
+
Web Locks/BroadcastChannel. There is no implicit replacement-key/Bearer fallback.
|
|
707
|
+
|
|
708
|
+
Both transports use a lifecycle FIFO, with refresh sharing until an intervening
|
|
709
|
+
mutation. Cookie mode adds same-origin/scope locks and a bounded 180 ms live-peer
|
|
710
|
+
token wait. `clear()` immediately invalidates local auth/owned HTTP and preserves
|
|
711
|
+
the key; `dispose()` closes the context while preserving committed reload state;
|
|
712
|
+
`resetKey()` atomically deletes only this scope's key/coordination records.
|
|
713
|
+
Await clear/reset before disposing if their persistence must finish. Only
|
|
714
|
+
`logout()` requests server revocation. Reset cannot remove HttpOnly cookies,
|
|
715
|
+
revoke old sessions/JWTs, or undo already-started API work; other sessions sharing
|
|
716
|
+
the deleted key need fresh login.
|
|
717
|
+
|
|
718
|
+
Vault attempts default to **15,000 ms** through headers/body/JSON parsing and
|
|
719
|
+
**1 MiB** of streamed response bytes. These limits exclude queue/storage/crypto/
|
|
720
|
+
recognition/other lock owners and cannot preempt synchronous JavaScript or a
|
|
721
|
+
suspended tab. Clear/reset/dispose abort owned HTTP waits; `fetchWithDpop` and
|
|
722
|
+
`createDpopProof` accept a caller `signal`. Aborting one API waiter does not
|
|
723
|
+
cancel shared refresh. API response bodies remain caller-owned and outside the
|
|
724
|
+
vault limits. A lost mutation response leaves server commit/Set-Cookie uncertain:
|
|
725
|
+
no automatic timeout/network retry; a lost body-handle rotation may need login.
|
|
726
|
+
|
|
727
|
+
`fetchWithDpop` owns auth headers and rejects redirects. API `pathPrefix`
|
|
728
|
+
uses longest normalized segment matching; omission or `/` is origin-wide.
|
|
729
|
+
Outbound paths share proof normalization (unreserved escapes/dots normalize,
|
|
730
|
+
reserved separators stay encoded); query data stays intact. Use separate
|
|
731
|
+
`replayNamespace`s for independent gateway services. Valid response nonces are
|
|
732
|
+
learned even from success or a single-attempt mutation for the next explicit
|
|
733
|
+
call. Each cache holds 64 space/key tuples, evicting the oldest remembered; it
|
|
734
|
+
does not infer nonce TTLs. Vaults retry a usable nonce once, then report
|
|
735
|
+
non-terminal `DPOP_NONCE_RETRY_EXHAUSTED`; retry-enabled APIs allow one nonce
|
|
736
|
+
retry and one `invalid_token` refresh budget. Final API responses belong to the
|
|
737
|
+
caller. Mutations stay single-attempt unless `retry: 'idempotent'` is justified
|
|
738
|
+
by the server operation. Fresh proofs/JTIs are mandatory on every attempt.
|
|
739
|
+
|
|
740
|
+
Use `requiresLogin`, not every HTTP 401, for session recovery: terminal session,
|
|
741
|
+
browser/key-binding and enrolled recognition errors clear auth; recoverable
|
|
742
|
+
proof/nonce/transient errors retain it. Unknown vault codes become fixed
|
|
743
|
+
`OIDC_REQUEST_FAILED`; API HTTP failures are returned as `Response`s. A valid
|
|
744
|
+
zero-lifetime refresh retains its rotated handle but `fetchWithDpop` stops with
|
|
745
|
+
non-terminal `DPOP_ACCESS_TOKEN_EXPIRED` instead of sending an expired token or
|
|
746
|
+
looping. Full tables and upgrade guidance ship in the
|
|
747
|
+
[client README](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md).
|
|
954
748
|
|
|
955
|
-
|
|
749
|
+
The example has real redirect/reload/cookie/CORS/key/expiry/retry/tab coverage on **Chromium 151 and Firefox 153**. Its tests use cross-origin **same-site loopback** HTTP. WebKit/Safari is not certified in the recorded environment because required Linux libraries were unavailable; arbitrary HTTPS cross-site/third-party-cookie policies and external IdPs need deployment-specific checks. Browser locks coordinate only the same frontend origin/storage partition/vault scope; they do not supply a distributed backend refresh lease.
|
|
956
750
|
|
|
957
|
-
|
|
751
|
+
### Low-level proof signing for an owned request
|
|
958
752
|
|
|
959
|
-
|
|
753
|
+
Prefer `fetchWithDpop` for session-backed APIs. When your application already
|
|
754
|
+
owns key/session validation and the HTTP/retry policy, the root proof helper
|
|
755
|
+
can sign one attempt. Supply a live same-key JWT and obtain the key via
|
|
756
|
+
`session.getKey()` immediately before use; recreate the proof for every retry.
|
|
757
|
+
This example intentionally returns one response for the caller to handle:
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
import { createDpopProof, type DpopKey } from '@web-ts-toolkit/oidc-vault-dpop-client';
|
|
761
|
+
|
|
762
|
+
export async function getWithProof(
|
|
763
|
+
key: DpopKey,
|
|
764
|
+
accessToken: string,
|
|
765
|
+
nonce?: string,
|
|
766
|
+
signal?: AbortSignal,
|
|
767
|
+
): Promise<Response> {
|
|
768
|
+
const url = 'https://api.example.com/api/profile?view=summary';
|
|
769
|
+
const proof = await createDpopProof(key, { method: 'GET', url, accessToken, nonce, signal });
|
|
770
|
+
return fetch(url, {
|
|
771
|
+
method: 'GET',
|
|
772
|
+
credentials: 'omit',
|
|
773
|
+
redirect: 'error',
|
|
774
|
+
cache: 'no-store',
|
|
775
|
+
signal,
|
|
776
|
+
headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof },
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
```
|
|
960
780
|
|
|
961
781
|
### Default unbound bearer frontend
|
|
962
782
|
|
|
@@ -1694,9 +1514,13 @@ That local token is separate from the upstream IdP access token:
|
|
|
1694
1514
|
|
|
1695
1515
|
`tokenIssuer.issue` must resolve to a non-null, non-array object with:
|
|
1696
1516
|
|
|
1697
|
-
- `accessToken`: nonempty opaque string, returned verbatim without trimming
|
|
1698
|
-
- `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER
|
|
1699
|
-
- `tokenType`: exact `'Bearer'` or `'DPoP'`. **Unbound issuance** permits only `'Bearer'` or omission; omitted/undefined stays absent in JSON. **Bound issuance** requires exact `'DPoP'
|
|
1517
|
+
- `accessToken`: nonempty opaque string for unbound issuance, returned verbatim without trimming; bound issuance requires the JWT profile below;
|
|
1518
|
+
- `expiresIn`: finite nonnegative safe-integer seconds, from 0 through `Number.MAX_SAFE_INTEGER` for unbound issuance. Bound issuance additionally requires `expiresIn * 1000` to be safe;
|
|
1519
|
+
- `tokenType`: exact `'Bearer'` or `'DPoP'`. **Unbound issuance** permits only `'Bearer'` or omission; omitted/undefined stays absent in JSON. **Bound issuance** requires exact `'DPoP'`. Null, lowercase, whitespace variants, and a bound Bearer result are invalid.
|
|
1520
|
+
|
|
1521
|
+
The **bound local-JWT profile** is a compact signed JWT with three nonempty canonical unpadded base64url segments, a nonempty signing `alg` other than `none` (no `b64: false` payload), exactly `cnf: { jkt }`, and a mandatory `exp`. The `jkt` must be the matching canonical RFC 7638 SHA-256 thumbprint (43 base64url characters with canonical final bits); additional confirmation fields/methods are unsupported. `exp` is a nonnegative safe integer in epoch **seconds**, and `exp * 1000` must also be a safe integer (so `exp <= Math.floor(Number.MAX_SAFE_INTEGER / 1000)`). Both the issuer boundary and built-in JWT validator enforce this profile.
|
|
1522
|
+
|
|
1523
|
+
`expiresIn: 0` remains valid output: the response can still deliver a rotated body handle or set a rotated cookie. It supplies no live reusable API/peer credential. The companion browser client caps its deadline by both `now + expiresIn * 1000` and JWT `exp * 1000`, checking the clock, conversions, and addition for safe nonnegative integers before clamping. It retains zero-lifetime response handles and rejects expired effective peer credentials. Browser decoding and trusted issuer output checks are **not JWT authentication**; JOSE/API expiry enforcement remains authoritative.
|
|
1700
1524
|
|
|
1701
1525
|
Only these three fields are copied once into a fresh result. Extra fields (including upstream tokens, `metadata`, `sessionId`, `user`, and `toJSON`) are ignored without evaluating their getters. The vault supplies the response session ID/profile. Omitting `tokenIssuer` is supported and returns no local token fields.
|
|
1702
1526
|
|
|
@@ -1708,11 +1532,14 @@ This projection contains accidental result extensions. Issuers/hooks remain trus
|
|
|
1708
1532
|
|
|
1709
1533
|
## Migration And Behavior Changes
|
|
1710
1534
|
|
|
1535
|
+
- Declaration upgrade: browser DTO consumers can switch type-only imports from the backend root to `@web-ts-toolkit/express-oidc-vault/wire`. All seven DTO root imports and backend `req.auth` augmentation remain supported in strict ESM/CJS consumers. The companion client now ships DOM-only declarations and requires Node **>=22.12.0** for CJS tooling.
|
|
1536
|
+
- Browser-client upgrade: use the [published client quickstart](#browser-dpop-client) in place of the old standalone implementation. Its shipped [upgrade notes](https://github.com/egose/web-ts-toolkit/blob/main/packages/oidc-vault-dpop-client/README.md#upgrade-notes) cover finite HTTP budgets/cancellation, FIFO/cleanup, terminal versus recoverable errors, single-attempt nonce learning, gateway path normalization and explicit scoped key recovery. Existing browser keys keep their database name/schema.
|
|
1711
1537
|
- Optional-only endpoint settings previously ignored now select manual mode and fail without the complete manual set. Supply all required manual values or remove endpoint settings to use discovery. Correct malformed optional discovery capabilities at the provider, or omit unsupported fields.
|
|
1712
1538
|
- DPoP remains opt-in: omitted policy preserves unbound behavior; an object defaults to optional, required rejects legacy unbound use. New binding is selected only by proof-aware POST login. Disabling DPoP or omitting later proofs never downgrades existing bound records/JWTs. Recognition remains a separate opt-in with POST-only enrollment; a changed enrolled signal requires fresh login.
|
|
1713
1539
|
- Invalid transaction/code TTLs previously had store-dependent behavior; supply positive safe-integer milliseconds. `sessionTtlMs` is opt-in for new sessions and never renews on refresh. Custom clocks are now sampled during construction.
|
|
1714
1540
|
- Route each session to its owning issuer/client configuration. Known foreign live sessions now fail with 401. Correct inaccurate stored identity only from trusted provenance or require login again; do not remove identity fields to bypass the guard. Legacy omissions and shared code/alias limits remain as described above.
|
|
1715
1541
|
- Issuers must return the declared local credential shape; previously accepted malformed results now fail with rollback. Extra result properties no longer extend/override JSON responses.
|
|
1542
|
+
- Bound JWT upgrade: emit exactly `cnf: { jkt }`, canonical compact encoding, and a safe integer `exp` with safe milliseconds; bound `expiresIn` must also convert safely to milliseconds. Unsupported confirmation extensions and missing/unsafe expiry now fail issuance with the same sanitized HTTP 500/original-lineage rollback. Already-issued invalid bound JWTs fail both built-in validator methods **before `mapClaims`**. Generic unbound JWT optional-exp behavior and opaque unbound issuance remain compatible; zero-lifetime responses still carry session-rotation results.
|
|
1716
1543
|
- Provider network/reset failures now produce sanitized endpoint-specific 502s instead of generic internal errors. Cancellation no longer waits for an uncooperative cleanup promise. Alias-retention wording reflects existing SVH-05 behavior, with no store migration.
|
|
1717
1544
|
|
|
1718
1545
|
## Access Token Validation Middleware
|
|
@@ -1867,6 +1694,8 @@ If you need a custom mapping, pass `mapClaims(...)` to `createOidcVaultJwtAccess
|
|
|
1867
1694
|
|
|
1868
1695
|
The helper implements both `validate(token)` and `validateWithRequest({ token, scheme, req })`. Its legacy result omits confirmation only for an originally unbound JWT, and reports a bound confirmation so even binding-disabled vault API middleware refuses a known bound-token downgrade. The helper itself authenticates JWT data; proof enforcement is performed by the middleware below.
|
|
1869
1696
|
|
|
1697
|
+
In **both methods**, a cryptographically verified bound JWT must also satisfy the [bound local-JWT profile](#local-issuer-result-contract), including mandatory safe `exp`, before `mapClaims` runs. A mapper cannot repair missing expiry or unsupported confirmation. JOSE still verifies signature, expiration, and configured issuer/audience/algorithms first. Genuinely unbound JWTs retain JOSE's generic optional-exp contract; this bound profile does not impose a new token-purpose or mandatory-exp policy on them.
|
|
1698
|
+
|
|
1870
1699
|
### Request-aware DPoP APIs
|
|
1871
1700
|
|
|
1872
1701
|
**Every API accepting a bound JWT must enforce the request proof.** A signature-only JWT verifier remains bearer-equivalent. Configure the API middleware with `OidcVaultApiDeviceBindingOptions` and a request-aware validator. This API path works with local JWTs whose verified `cnf.jkt` binds the client key. Vault POST login/callback persist the initiating key; exchange/refresh pass its verified request context to the local issuer, and bound logout requires that same key.
|