@tdacorp/identity-client 0.1.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/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/chunk-KLXEPP4S.js +80 -0
- package/dist/chunk-VJSMXIZB.js +320 -0
- package/dist/index.cjs +357 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +28 -0
- package/dist/next.cjs +461 -0
- package/dist/next.d.cts +85 -0
- package/dist/next.d.ts +85 -0
- package/dist/next.js +129 -0
- package/dist/sealed.cjs +101 -0
- package/dist/sealed.d.cts +95 -0
- package/dist/sealed.d.ts +95 -0
- package/dist/sealed.js +10 -0
- package/dist/verify-BiGhwaIz.d.cts +306 -0
- package/dist/verify-BiGhwaIz.d.ts +306 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TDACorp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# @tdacorp/identity-client
|
|
2
|
+
|
|
3
|
+
OIDC relying-party client for TDACorp Identity: discovery, PKCE, token
|
|
4
|
+
exchange/refresh/client-credentials, ID and access token verification, sealed
|
|
5
|
+
cookie storage for OAuth transaction state, and Next.js App Router route
|
|
6
|
+
handlers.
|
|
7
|
+
|
|
8
|
+
Use this when a product wants to let a user sign in through TDACorp
|
|
9
|
+
Identity's OIDC provider, or needs to verify a TDACorp-issued token
|
|
10
|
+
server-side.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @tdacorp/identity-client
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`@tdacorp/identity-authz` comes along as a real dependency, not a peer — no
|
|
19
|
+
separate install needed unless you want to call its permission functions
|
|
20
|
+
directly, outside of what this package already re-exposes on a verified
|
|
21
|
+
access token's `roles` claim.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
The recommended shape: mount two Route Handlers with `./next`'s
|
|
26
|
+
`createLoginRoute` and `createCallbackRoute`, request the full
|
|
27
|
+
`openid profile email roles` scope, and verify access tokens later with
|
|
28
|
+
`verifyAccessToken` plus `@tdacorp/identity-authz`'s `permits()`.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// app/api/auth/login/route.ts
|
|
32
|
+
import { createLoginRoute } from '@tdacorp/identity-client/next'
|
|
33
|
+
|
|
34
|
+
export const GET = createLoginRoute({
|
|
35
|
+
issuer: process.env.IDENTITY_ISSUER!,
|
|
36
|
+
clientId: process.env.IDENTITY_CLIENT_ID!,
|
|
37
|
+
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
|
|
38
|
+
cookieSecret: process.env.COOKIE_SECRET!,
|
|
39
|
+
// scope defaults to "openid profile email roles" -- see the ./next section
|
|
40
|
+
// below for why omitting "roles" is a real functional loss, not a nicety.
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// app/api/auth/callback/route.ts
|
|
46
|
+
import { NextResponse } from 'next/server'
|
|
47
|
+
import { createCallbackRoute } from '@tdacorp/identity-client/next'
|
|
48
|
+
|
|
49
|
+
export const GET = createCallbackRoute({
|
|
50
|
+
issuer: process.env.IDENTITY_ISSUER!,
|
|
51
|
+
clientId: process.env.IDENTITY_CLIENT_ID!,
|
|
52
|
+
clientSecret: process.env.IDENTITY_CLIENT_SECRET!,
|
|
53
|
+
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
|
|
54
|
+
cookieSecret: process.env.COOKIE_SECRET!,
|
|
55
|
+
onSuccess: async (tokens, idClaims, { returnTo }) => {
|
|
56
|
+
const response = NextResponse.redirect(new URL(returnTo ?? '/', process.env.APP_URL))
|
|
57
|
+
// Store tokens.accessToken / tokens.refreshToken in your own session --
|
|
58
|
+
// this package stops at verified claims, session storage is your call.
|
|
59
|
+
return response
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Both handlers seal the PKCE verifier and CSRF `state` into an encrypted
|
|
65
|
+
cookie (via `./sealed`'s `seal()`/`unseal()`) rather than storing them in
|
|
66
|
+
plaintext — you never handle that transaction cookie directly.
|
|
67
|
+
|
|
68
|
+
Later, on a request that carries an access token, verify it and decide a
|
|
69
|
+
permission with the same 3-branch switch `@tdacorp/identity-authz`'s own
|
|
70
|
+
README documents:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { verifyAccessToken } from '@tdacorp/identity-client'
|
|
74
|
+
import { permits } from '@tdacorp/identity-authz'
|
|
75
|
+
|
|
76
|
+
const result = await verifyAccessToken(accessToken, {
|
|
77
|
+
issuer: process.env.IDENTITY_ISSUER!,
|
|
78
|
+
audience: process.env.IDENTITY_CLIENT_ID!,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
// result.errors: TokenVerificationError[]
|
|
83
|
+
} else if (result.data.kind === 'user') {
|
|
84
|
+
const permit = permits(result.data.roles, 'identity.users.view')
|
|
85
|
+
|
|
86
|
+
if (permit.decision === 'allow') {
|
|
87
|
+
// proceed
|
|
88
|
+
} else if (permit.decision === 'deny') {
|
|
89
|
+
// reject
|
|
90
|
+
} else {
|
|
91
|
+
switch (permit.reason) {
|
|
92
|
+
case 'ref':
|
|
93
|
+
case 'absent-claim':
|
|
94
|
+
case 'unknown-claim-version':
|
|
95
|
+
case 'malformed-claim':
|
|
96
|
+
// see @tdacorp/identity-authz's README for what each means
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Omitting `roles` from the requested scope means the minted token carries no
|
|
104
|
+
`roles` claim at all, so every `permits()` check against it stays
|
|
105
|
+
`indeterminate` with `reason: 'absent-claim'` forever — this is exactly why
|
|
106
|
+
`createLoginRoute`'s default scope includes it.
|
|
107
|
+
|
|
108
|
+
## Discovery
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { fetchDiscovery } from '@tdacorp/identity-client'
|
|
112
|
+
|
|
113
|
+
const discovery = await fetchDiscovery('https://identity.tdacorp.in')
|
|
114
|
+
// discovery.authorization_endpoint, discovery.token_endpoint, discovery.jwks_uri, ...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`fetchDiscovery` resolves an issuer's OpenID Provider Metadata document
|
|
118
|
+
(`/.well-known/openid-configuration`), cached for 10 minutes with in-flight
|
|
119
|
+
deduplication — concurrent callers for the same issuer during a cold start
|
|
120
|
+
share one fetch rather than each firing their own. Before the result is
|
|
121
|
+
trusted, it is validated: the document's `issuer` must match the URL you
|
|
122
|
+
called with, and `jwks_uri` / `token_endpoint` must be https (loopback hosts
|
|
123
|
+
excepted) and same-origin with the issuer. This is what stops a compromised
|
|
124
|
+
edge cache or a cached error page from redirecting key material or the token
|
|
125
|
+
endpoint somewhere untrusted.
|
|
126
|
+
|
|
127
|
+
Most callers reach `fetchDiscovery` indirectly — every function below calls
|
|
128
|
+
it internally.
|
|
129
|
+
|
|
130
|
+
## PKCE
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@tdacorp/identity-client'
|
|
134
|
+
|
|
135
|
+
const codeVerifier = generateCodeVerifier()
|
|
136
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
137
|
+
const state = generateState()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
RFC 7636 code verifier, S256 code challenge, and CSRF `state` generation.
|
|
141
|
+
Built on Web Crypto (`crypto.getRandomValues` / `crypto.subtle.digest`) only,
|
|
142
|
+
never Node's `crypto` module, because a consuming app's auth routes may run
|
|
143
|
+
on an Edge runtime rather than Node — Web Crypto is the one API both are
|
|
144
|
+
guaranteed to have.
|
|
145
|
+
|
|
146
|
+
## Token exchange, refresh, and client credentials
|
|
147
|
+
|
|
148
|
+
Authorization code exchange (RFC 6749 §4.1.3 / RFC 7636 §4.5):
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { exchangeAuthorizationCode } from '@tdacorp/identity-client'
|
|
152
|
+
|
|
153
|
+
const tokens = await exchangeAuthorizationCode({
|
|
154
|
+
issuer: 'https://identity.tdacorp.in',
|
|
155
|
+
clientId: process.env.IDENTITY_CLIENT_ID!,
|
|
156
|
+
clientSecret: process.env.IDENTITY_CLIENT_SECRET!, // omit for a public client using PKCE alone
|
|
157
|
+
code,
|
|
158
|
+
redirectUri,
|
|
159
|
+
codeVerifier,
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Refresh (RFC 6749 §6), returning a classified outcome instead of throwing —
|
|
164
|
+
so a transient infrastructure failure is never mistaken for a rejected
|
|
165
|
+
refresh token:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { refreshTokens } from '@tdacorp/identity-client'
|
|
169
|
+
|
|
170
|
+
const outcome = await refreshTokens({ issuer, clientId, clientSecret, refreshToken })
|
|
171
|
+
|
|
172
|
+
if (outcome.outcome === 'success') {
|
|
173
|
+
// outcome.tokens
|
|
174
|
+
} else if (outcome.outcome === 'terminal') {
|
|
175
|
+
// the refresh token is confirmed dead -- safe to clear it now
|
|
176
|
+
} else {
|
|
177
|
+
// outcome.outcome === 'transient': status still unknown, retry later.
|
|
178
|
+
// Do NOT clear the refresh token or the session built on it.
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Machine-to-machine calls with no end user use `clientCredentialsGrant` (RFC
|
|
183
|
+
6749 §4.4) instead, always with a confidential `clientSecret` since there is
|
|
184
|
+
no PKCE verifier to authenticate a public client with. A token minted this
|
|
185
|
+
way carries no `sub` and no `roles` claim — verifying it with
|
|
186
|
+
`verifyAccessToken` returns the `kind: 'machine'` variant of
|
|
187
|
+
`VerifiedAccessToken`, distinct from the `kind: 'user'` variant a normal
|
|
188
|
+
login produces, so reading `.sub` or `.roles` off the wrong branch is a
|
|
189
|
+
compile error rather than `undefined`.
|
|
190
|
+
|
|
191
|
+
## Verification
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { verifyIdToken, verifyAccessToken } from '@tdacorp/identity-client'
|
|
195
|
+
|
|
196
|
+
const result = await verifyAccessToken(accessToken, {
|
|
197
|
+
issuer: 'https://identity.tdacorp.in',
|
|
198
|
+
audience: clientId,
|
|
199
|
+
authorizedParties: [clientId],
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`verifyIdToken` and `verifyAccessToken` verify signature, issuer, audience,
|
|
204
|
+
and expiry (60 seconds of clock-skew tolerance), returning
|
|
205
|
+
`{ success: false, errors }` for any expected verification failure instead of
|
|
206
|
+
throwing — only a missing `options.issuer` / `options.audience` throws, since
|
|
207
|
+
that is a programming error rather than a property of the token. The
|
|
208
|
+
signature algorithm is pinned to EdDSA; a token signed any other way is
|
|
209
|
+
rejected outright.
|
|
210
|
+
|
|
211
|
+
`authorizedParties` is worth setting deliberately. Every TDACorp product
|
|
212
|
+
shares one platform domain and one identity server, so a token minted for
|
|
213
|
+
one product is not otherwise prevented from being replayed against another
|
|
214
|
+
product on the same platform — the plain `audience` check only confirms a
|
|
215
|
+
token is valid for *some* resource server, not that it was issued through a
|
|
216
|
+
client you trust. `authorizedParties` checks the token's `azp` claim (falling
|
|
217
|
+
back to a single-string `aud`) against an allowlist you provide, closing that
|
|
218
|
+
gap.
|
|
219
|
+
|
|
220
|
+
## `./sealed`
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { seal, unseal } from '@tdacorp/identity-client/sealed'
|
|
224
|
+
|
|
225
|
+
const sealedValue = await seal(
|
|
226
|
+
{ state, codeVerifier },
|
|
227
|
+
{ secret: process.env.COOKIE_SECRET!, ttlSeconds: 600, purpose: 'my-app:oauth-transaction' }
|
|
228
|
+
)
|
|
229
|
+
// store sealedValue as a cookie value
|
|
230
|
+
|
|
231
|
+
const transaction = await unseal<{ state: string; codeVerifier: string }>(sealedValue, {
|
|
232
|
+
secret: process.env.COOKIE_SECRET!,
|
|
233
|
+
purpose: 'my-app:oauth-transaction',
|
|
234
|
+
})
|
|
235
|
+
// transaction is null on any failure (expired, tampered, wrong secret/purpose)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
`seal()` encrypts a JWT-payload-shaped object into a compact JWE
|
|
239
|
+
(`alg: "dir"`, `enc: "A256GCM"`) rather than merely signing it, so a cookie's
|
|
240
|
+
contents are opaque to whoever holds it, not just tamper-evident. The
|
|
241
|
+
content-encryption key is derived via HKDF from your secret, mixed with a
|
|
242
|
+
`purpose` string, so the same base secret used for two different purposes
|
|
243
|
+
(an OAuth transaction cookie and an unrelated signed claim, say) derives two
|
|
244
|
+
different keys — sealing under one purpose and unsealing under another fails
|
|
245
|
+
rather than silently succeeding on the wrong data. `seal()` throws
|
|
246
|
+
synchronously if the result would exceed a 4096-byte hard limit, rather than
|
|
247
|
+
truncating or chunking it across multiple cookies. `secret` also accepts a
|
|
248
|
+
`Record<number, string>` rotation map: `seal()` always encrypts under the
|
|
249
|
+
highest-numbered key and stamps it into the JWE header as `kid`, and
|
|
250
|
+
`unseal()` reads `kid` back to pick the matching key, so values sealed under
|
|
251
|
+
a retired key keep decrypting.
|
|
252
|
+
|
|
253
|
+
## `./next`
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
import { createLoginRoute, createCallbackRoute } from '@tdacorp/identity-client/next'
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`createLoginRoute` and `createCallbackRoute` build plain Next.js App Router
|
|
260
|
+
Route Handlers (see the Quickstart above) — there is deliberately no
|
|
261
|
+
middleware or route-matcher factory alongside them. Clerk deprecated its own
|
|
262
|
+
middleware route-matcher helper in favor of protecting resources as close to
|
|
263
|
+
them as possible, because middleware path-matching can silently diverge from
|
|
264
|
+
how the framework actually routes a request. Auth0's Next.js SDK needed a
|
|
265
|
+
breaking rename (`middleware.ts` → `proxy.ts`) to keep working on Next.js
|
|
266
|
+
16's Node runtime — not a stable foundation to wrap a second time. This
|
|
267
|
+
package ships Route Handlers a consuming app mounts wherever it wants
|
|
268
|
+
instead.
|
|
269
|
+
|
|
270
|
+
## Limitations in v0.1
|
|
271
|
+
|
|
272
|
+
- **Only `client_secret_basic` client authentication is supported** for the
|
|
273
|
+
authorization-code exchange (`exchangeAuthorizationCode` sends an HTTP
|
|
274
|
+
Basic `Authorization` header when a `clientSecret` is given). A relying
|
|
275
|
+
party configured on TDACorp Identity for `client_secret_post` cannot use
|
|
276
|
+
`exchangeAuthorizationCode` as-is today — the token endpoint will reject
|
|
277
|
+
the request.
|
|
278
|
+
- **No built-in fallback for an `indeterminate` `permits()` result.** That is
|
|
279
|
+
`@tdacorp/identity-authz`'s concern, not this package's, but worth knowing
|
|
280
|
+
up front since most integrations hit both together — see that package's
|
|
281
|
+
README for what each `indeterminate` reason means and why it is not
|
|
282
|
+
resolved to a default for you.
|
|
283
|
+
|
|
284
|
+
## Documentation
|
|
285
|
+
|
|
286
|
+
This package's TSDoc-annotated types are the API reference: viewable via
|
|
287
|
+
editor hover, or in the shipped `.d.ts` files. There is no separate docs
|
|
288
|
+
site.
|
|
289
|
+
|
|
290
|
+
## Contributing
|
|
291
|
+
|
|
292
|
+
This package is developed inside TDACorp's internal monorepo, which isn't
|
|
293
|
+
public, so there's no GitHub issue tracker to file against. Bug reports go to
|
|
294
|
+
security@tdacorp.in instead — the same address for a genuine bug as for a
|
|
295
|
+
security report.
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
MIT. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/sealed.ts
|
|
2
|
+
import {
|
|
3
|
+
EncryptJWT,
|
|
4
|
+
jwtDecrypt,
|
|
5
|
+
decodeProtectedHeader,
|
|
6
|
+
errors as joseErrors
|
|
7
|
+
} from "jose";
|
|
8
|
+
async function deriveContentEncryptionKey(secret, purpose) {
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveBits"]);
|
|
11
|
+
const bits = await crypto.subtle.deriveBits(
|
|
12
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(purpose) },
|
|
13
|
+
keyMaterial,
|
|
14
|
+
256
|
|
15
|
+
);
|
|
16
|
+
return new Uint8Array(bits);
|
|
17
|
+
}
|
|
18
|
+
function resolveSealingKey(secret) {
|
|
19
|
+
if (typeof secret === "string") return { secretValue: secret };
|
|
20
|
+
const keyNumbers = Object.keys(secret).map(Number);
|
|
21
|
+
if (keyNumbers.length === 0) throw new Error("seal: secret key-rotation map is empty");
|
|
22
|
+
const highest = Math.max(...keyNumbers);
|
|
23
|
+
return { kid: String(highest), secretValue: secret[highest] };
|
|
24
|
+
}
|
|
25
|
+
var MAX_SEALED_BYTES = 4096;
|
|
26
|
+
async function seal(payload, options) {
|
|
27
|
+
const { kid, secretValue } = resolveSealingKey(options.secret);
|
|
28
|
+
const cek = await deriveContentEncryptionKey(secretValue, options.purpose);
|
|
29
|
+
const header = {
|
|
30
|
+
alg: "dir",
|
|
31
|
+
enc: "A256GCM",
|
|
32
|
+
...kid !== void 0 ? { kid } : {}
|
|
33
|
+
};
|
|
34
|
+
const sealed = await new EncryptJWT(payload).setProtectedHeader(header).setIssuedAt().setExpirationTime(`${options.ttlSeconds}s`).encrypt(cek);
|
|
35
|
+
const byteLength = new TextEncoder().encode(sealed).length;
|
|
36
|
+
if (byteLength > MAX_SEALED_BYTES) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`seal: sealed payload is ${byteLength} bytes, over the ${MAX_SEALED_BYTES}-byte limit (MAX_SEALED_BYTES). Shrink the payload -- do not chunk it across cookies; see MAX_SEALED_BYTES's own docblock for why.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return sealed;
|
|
42
|
+
}
|
|
43
|
+
function resolveUnsealingKey(secret, kid) {
|
|
44
|
+
if (typeof secret === "string") return { secretValue: secret };
|
|
45
|
+
if (kid === void 0 || !(Number(kid) in secret)) return { reason: "unknown-key" };
|
|
46
|
+
return { secretValue: secret[Number(kid)] };
|
|
47
|
+
}
|
|
48
|
+
function classifyUnsealError(error) {
|
|
49
|
+
const code = error instanceof joseErrors.JOSEError ? error.code : void 0;
|
|
50
|
+
return code === "ERR_JWT_EXPIRED" ? "expired" : "invalid";
|
|
51
|
+
}
|
|
52
|
+
async function unseal(sealedValue, options) {
|
|
53
|
+
let kid;
|
|
54
|
+
try {
|
|
55
|
+
kid = decodeProtectedHeader(sealedValue).kid;
|
|
56
|
+
} catch {
|
|
57
|
+
options.onError?.("invalid");
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const resolved = resolveUnsealingKey(options.secret, kid);
|
|
61
|
+
if ("reason" in resolved) {
|
|
62
|
+
options.onError?.(resolved.reason);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const cek = await deriveContentEncryptionKey(resolved.secretValue, options.purpose);
|
|
67
|
+
const { payload } = await jwtDecrypt(sealedValue, cek);
|
|
68
|
+
return payload;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const reason = classifyUnsealError(error);
|
|
71
|
+
options.onError?.(reason);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
MAX_SEALED_BYTES,
|
|
78
|
+
seal,
|
|
79
|
+
unseal
|
|
80
|
+
};
|