npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 indeterminate permits() 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 each indeterminate reason 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.