@tdacorp/identity-client
v0.3.0
Published
OIDC relying-party client for TDACorp Identity: discovery, PKCE, token exchange, token verification and Next.js route helpers.
Maintainers
Readme
@tdacorp/identity-client
OIDC relying-party client for TDACorp Identity: discovery, PKCE, token exchange/refresh/client-credentials, ID and access token verification, sealed cookie storage for OAuth transaction state, and Next.js App Router route handlers.
Use this when a product wants to let a user sign in through TDACorp Identity's OIDC provider, or needs to verify a TDACorp-issued token server-side.
Install
npm install @tdacorp/identity-client@tdacorp/identity-authz comes along as a real dependency, not a peer — no
separate install needed unless you want to call its permission functions
directly, outside of what this package already re-exposes on a verified
access token's roles claim.
Quickstart
The recommended shape: mount two Route Handlers with ./next's
createLoginRoute and createCallbackRoute, request the full
openid profile email roles scope, and verify access tokens later with
verifyAccessToken plus @tdacorp/identity-authz's permits().
// app/api/auth/login/route.ts
import { createLoginRoute } from '@tdacorp/identity-client/next'
export const GET = createLoginRoute({
issuer: process.env.IDENTITY_ISSUER!,
clientId: process.env.IDENTITY_CLIENT_ID!,
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
cookieSecret: process.env.COOKIE_SECRET!,
// scope defaults to "openid profile email roles" -- see the ./next section
// below for why omitting "roles" is a real functional loss, not a nicety.
})// app/api/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createCallbackRoute } from '@tdacorp/identity-client/next'
export const GET = createCallbackRoute({
issuer: process.env.IDENTITY_ISSUER!,
clientId: process.env.IDENTITY_CLIENT_ID!,
clientSecret: process.env.IDENTITY_CLIENT_SECRET!,
redirectUri: `${process.env.APP_URL}/api/auth/callback`,
cookieSecret: process.env.COOKIE_SECRET!,
onSuccess: async (tokens, idClaims, { returnTo }) => {
// returnTo, when present, has already been validated to a same-app
// relative path (single leading `/`, never an absolute URL) by
// createLoginRoute -- safe to resolve against APP_URL like this.
const response = NextResponse.redirect(new URL(returnTo ?? '/', process.env.APP_URL))
// Store tokens.accessToken / tokens.refreshToken in your own session --
// this package stops at verified claims, session storage is your call.
return response
},
})createLoginRoute rejects anything but a relative path for returnTo --
an absolute URL, a protocol-relative //host value, a backslash, or a
control character is silently dropped (coerced to undefined) rather than
sealed into the transaction cookie, closing the open-redirect this shape
would otherwise allow. This validation applies regardless of whether
returnTo came from the default returnTo search param or a custom
getReturnTo, since this package has no per-app trusted-origin registry to
check an absolute URL's origin against. If your app legitimately needs to
redirect to an absolute URL after login, carry it through your own
mechanism (a separate cookie your app validates against its own known
origins) rather than this package's returnTo.
Both handlers seal the PKCE verifier and CSRF state into an encrypted
cookie (via ./sealed's seal()/unseal()) rather than storing them in
plaintext — you never handle that transaction cookie directly.
Later, on a request that carries an access token, verify it and decide a
permission with the same 3-branch switch @tdacorp/identity-authz's own
README documents:
import { verifyAccessToken } from '@tdacorp/identity-client'
import { permits } from '@tdacorp/identity-authz'
const result = await verifyAccessToken(accessToken, {
issuer: process.env.IDENTITY_ISSUER!,
audience: process.env.IDENTITY_CLIENT_ID!,
})
if (!result.success) {
// result.errors: TokenVerificationError[]
} else if (result.data.kind === 'user') {
const permit = permits(result.data.roles, 'identity.users.view')
if (permit.decision === 'allow') {
// proceed
} else if (permit.decision === 'deny') {
// reject
} else {
switch (permit.reason) {
case 'ref':
case 'absent-claim':
case 'unknown-claim-version':
case 'malformed-claim':
// see @tdacorp/identity-authz's README for what each means
break
}
}
}Omitting roles from the requested scope means the minted token carries no
roles claim at all, so every permits() check against it stays
indeterminate with reason: 'absent-claim' forever — this is exactly why
createLoginRoute's default scope includes it.
Discovery
import { fetchDiscovery } from '@tdacorp/identity-client'
const discovery = await fetchDiscovery('https://identity.tdacorp.in')
// discovery.authorization_endpoint, discovery.token_endpoint, discovery.jwks_uri, ...fetchDiscovery resolves an issuer's OpenID Provider Metadata document
(/.well-known/openid-configuration), cached for 10 minutes with in-flight
deduplication — concurrent callers for the same issuer during a cold start
share one fetch rather than each firing their own. Before the result is
trusted, it is validated: the document's issuer must match the URL you
called with, and jwks_uri / token_endpoint / authorization_endpoint
must be https (loopback hosts excepted) and same-origin with the issuer.
This is what stops a compromised edge cache or a cached error page from
redirecting key material, the token endpoint, or the login page itself
somewhere untrusted.
Most callers reach fetchDiscovery indirectly — every function below calls
it internally.
PKCE
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@tdacorp/identity-client'
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const state = generateState()RFC 7636 code verifier, S256 code challenge, and CSRF state generation.
Built on Web Crypto (crypto.getRandomValues / crypto.subtle.digest) only,
never Node's crypto module, because a consuming app's auth routes may run
on an Edge runtime rather than Node — Web Crypto is the one API both are
guaranteed to have.
Token exchange, refresh, and client credentials
Authorization code exchange (RFC 6749 §4.1.3 / RFC 7636 §4.5):
import { exchangeAuthorizationCode } from '@tdacorp/identity-client'
const tokens = await exchangeAuthorizationCode({
issuer: 'https://identity.tdacorp.in',
clientId: process.env.IDENTITY_CLIENT_ID!,
clientSecret: process.env.IDENTITY_CLIENT_SECRET!, // omit for a public client using PKCE alone
code,
redirectUri,
codeVerifier,
})If your relying party is registered on the identity platform for
client_secret_post rather than the default client_secret_basic, pass
clientAuthMethod so credentials are sent the way the token endpoint
actually expects them:
const tokens = await exchangeAuthorizationCode({
issuer: 'https://identity.tdacorp.in',
clientId: process.env.IDENTITY_CLIENT_ID!,
clientSecret: process.env.IDENTITY_CLIENT_SECRET!,
clientAuthMethod: 'client_secret_post',
code,
redirectUri,
codeVerifier,
})Refresh (RFC 6749 §6), returning a classified outcome instead of throwing — so a transient infrastructure failure is never mistaken for a rejected refresh token:
import { refreshTokens } from '@tdacorp/identity-client'
const outcome = await refreshTokens({ issuer, clientId, clientSecret, refreshToken })
if (outcome.outcome === 'success') {
// outcome.tokens
} else if (outcome.outcome === 'terminal') {
// the refresh token is confirmed dead -- safe to clear it now
} else {
// outcome.outcome === 'transient': status still unknown, retry later.
// Do NOT clear the refresh token or the session built on it.
}Machine-to-machine calls with no end user use clientCredentialsGrant (RFC
6749 §4.4) instead, always with a confidential clientSecret since there is
no PKCE verifier to authenticate a public client with. A token minted this
way carries no sub — verifying it with verifyAccessToken returns the
kind: 'machine' variant of VerifiedAccessToken, distinct from the
kind: 'user' variant a normal login produces, so reading .sub off the
wrong branch is a compile error rather than undefined. It can still carry a
roles claim: request roles in the client_credentials scope and the server
resolves it against the requesting client itself as the subject, exactly as
it does for a user token requesting the same scope.
Verification
import { verifyIdToken, verifyAccessToken } from '@tdacorp/identity-client'
const result = await verifyAccessToken(accessToken, {
issuer: 'https://identity.tdacorp.in',
audience: clientId,
authorizedParties: [clientId],
})verifyIdToken and verifyAccessToken verify signature, issuer, audience,
and expiry (60 seconds of clock-skew tolerance), returning
{ success: false, errors } for any expected verification failure instead of
throwing — only a missing options.issuer / options.audience throws, since
that is a programming error rather than a property of the token. The
signature algorithm is pinned to EdDSA; a token signed any other way is
rejected outright.
authorizedParties is worth setting deliberately. Every TDACorp product
shares one platform domain and one identity server, so a token minted for
one product is not otherwise prevented from being replayed against another
product on the same platform — the plain audience check only confirms a
token is valid for some resource server, not that it was issued through a
client you trust. authorizedParties checks the token's azp claim (falling
back to a single-string aud) against an allowlist you provide, closing that
gap.
./sealed
import { seal, unseal } from '@tdacorp/identity-client/sealed'
const sealedValue = await seal(
{ state, codeVerifier },
{ secret: process.env.COOKIE_SECRET!, ttlSeconds: 600, purpose: 'my-app:oauth-transaction' }
)
// store sealedValue as a cookie value
const transaction = await unseal<{ state: string; codeVerifier: string }>(sealedValue, {
secret: process.env.COOKIE_SECRET!,
purpose: 'my-app:oauth-transaction',
})
// transaction is null on any failure (expired, tampered, wrong secret/purpose)seal() encrypts a JWT-payload-shaped object into a compact JWE
(alg: "dir", enc: "A256GCM") rather than merely signing it, so a cookie's
contents are opaque to whoever holds it, not just tamper-evident. The
content-encryption key is derived via HKDF from your secret, mixed with a
purpose string, so the same base secret used for two different purposes
(an OAuth transaction cookie and an unrelated signed claim, say) derives two
different keys — sealing under one purpose and unsealing under another fails
rather than silently succeeding on the wrong data. seal() throws
synchronously if the result would exceed a 4096-byte hard limit, rather than
truncating or chunking it across multiple cookies. secret also accepts a
Record<number, string> rotation map: seal() always encrypts under the
highest-numbered key and stamps it into the JWE header as kid, and
unseal() reads kid back to pick the matching key, so values sealed under
a retired key keep decrypting.
Every real integration ends up minting its own short-lived, app-owned
session after verifying an id_token — identity's own access_token cookie
is a private, short-lived, cross-subdomain contract, not something a
consuming app should read directly. signSession/verifySession, from the
same ./sealed subpath, are that pattern built once instead of hand-rolled
per app:
import { signSession, verifySession } from '@tdacorp/identity-client/sealed'
const { value, maxAge } = await signSession(
{ sub: userId, role: 'member' },
{ secret: process.env.SESSION_SECRET!, ttlSeconds: 60 * 20, purpose: 'my-app:session' }
)
// set `value` as a cookie, with `maxAge`
const session = await verifySession<{ sub: string; role: string }>(cookies.get('session')?.value, {
secret: process.env.SESSION_SECRET!,
purpose: 'my-app:session',
})
// session is null on any failure (absent, expired, tampered, wrong secret/purpose)Signed (JWS, alg: "HS256"), not sealed: a session's own contents are read
by your app's middleware on every request and don't need to be opaque to
the browser holding the cookie the way an OAuth transaction's PKCE verifier
does. sub is required on the payload — a session with no subject is a
session about nobody. Same SealSecret rotation-map support and
purpose-scoped HKDF key derivation as seal/unseal above, and the same
fail-closed contract: verifySession never throws, returns null on any
problem, and accepts undefined directly so a caller reading a cookie that
may not be present needs no separate guard first.
./next
import { createLoginRoute, createCallbackRoute } from '@tdacorp/identity-client/next'createLoginRoute and createCallbackRoute build plain Next.js App Router
Route Handlers (see the Quickstart above) — there is deliberately no
middleware or route-matcher factory alongside them. Clerk deprecated its own
middleware route-matcher helper in favor of protecting resources as close to
them as possible, because middleware path-matching can silently diverge from
how the framework actually routes a request. Auth0's Next.js SDK needed a
breaking rename (middleware.ts → proxy.ts) to keep working on Next.js
16's Node runtime — not a stable foundation to wrap a second time. This
package ships Route Handlers a consuming app mounts wherever it wants
instead.
Both factories' redirectUri accepts a function as well as a plain string —
(request) => string, useful when the app is reachable at more than one
origin (a preview deployment per branch, say):
redirectUri: (request) => `${request.nextUrl.origin}/api/auth/callback`,The resolved value must be identical on both the login and callback route's
own redirectUri, since the token endpoint checks it against what the
authorization request used (RFC 6749 §4.1.3) — true automatically for
request.nextUrl.origin, since one browser session's login and callback
requests share a domain.
createCallbackRoute also accepts clientAuthMethod, the same option
exchangeAuthorizationCode takes directly (see "Token exchange, refresh,
and client credentials" above) — set it to 'client_secret_post' if your
relying party is registered on the identity platform for that method
instead of the default client_secret_basic.
Limitations in v0.1
- No built-in fallback for an
indeterminatepermits()result. That is@tdacorp/identity-authz's concern, not this package's, but worth knowing up front since most integrations hit both together — see that package's README for what eachindeterminatereason means and why it is not resolved to a default for you.
Documentation
This package's TSDoc-annotated types are the API reference: viewable via
editor hover, or in the shipped .d.ts files. There is no separate docs
site.
Contributing
This package is developed inside TDACorp's internal monorepo, which isn't public, so there's no GitHub issue tracker to file against. Bug reports go to [email protected] instead — the same address for a genuine bug as for a security report.
License
MIT. See LICENSE.
