@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 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
+ };