Skip to content
TDACorpIDENTITYSign in

Raw HTTP walkthrough

Sign one person in and call userinfo using only curl and a short script, with no SDK, to see every request identity expects.

On this page

This page does one complete sign-in with curl and nothing else from identity: discovery, a pushed authorization request, the browser step, the token request and a call to /userinfo. It is the SDK's job laid bare. Use it to learn the protocol, to debug a client in any language, or to port the flow.

What you need#

  • A registered web client, and its client id, key id and private key. See Get a client registered. The redirect address can be any address you registered, even one that does not answer: you only need to read the address bar.
  • A second key pair for DPoP. Generate it as you did the client key.
  • Node 24 or later, curl and jq. The helper runs TypeScript directly and uses import.meta.main, which is why it needs more than the Node 22 the SDK requires.
  • The helper script below, saved as jwt.ts. It signs the two kinds of JWT identity needs and makes a PKCE pair, using only Node's built-in crypto module.
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, type JsonWebKey } from 'node:crypto'// Everything the raw HTTP walkthrough needs and nothing else: node:crypto only, no SDK, no JWT library.// Ed25519 and P-256 private JWKs are the two key types identity accepts.const b64 = (data: Buffer | string): string => Buffer.from(data).toString('base64url')function algOf(jwk: JsonWebKey): 'EdDSA' | 'ES256' {  if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') return 'EdDSA'  if (jwk.kty === 'EC' && jwk.crv === 'P-256') return 'ES256'  throw new Error('use an Ed25519 or P-256 private key')}export function signJwt(privateJwk: JsonWebKey, header: Record<string, unknown>, payload: Record<string, unknown>): string {  const alg = algOf(privateJwk)  const key = createPrivateKey({ key: privateJwk, format: 'jwk' })  const input = `${b64(JSON.stringify({ alg, ...header }))}.${b64(JSON.stringify(payload))}`  const signature =    alg === 'EdDSA' ? sign(null, Buffer.from(input), key) : sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' })  return `${input}.${b64(signature)}`}const nowSeconds = (): number => Math.floor(Date.now() / 1000)/** The client assertion for private_key_jwt. Use each one for exactly one request. `aud` is the issuer string. */export function clientAssertion(input: { clientId: string; kid: string; privateJwk: JsonWebKey; issuer: string; lifetimeSeconds?: number }): string {  const iat = nowSeconds()  return signJwt(    input.privateJwk,    { kid: input.kid },    { iss: input.clientId, sub: input.clientId, aud: input.issuer, jti: randomUUID(), iat, exp: iat + (input.lifetimeSeconds ?? 60) },  )}/** A DPoP proof. Pass `accessToken` only when the request carries one (userinfo, your API); never to /par, /token, /revoke or /introspect. */export function dpopProof(input: { privateJwk: JsonWebKey; method: string; url: string; nonce?: string; accessToken?: string }): string {  const target = new URL(input.url)  target.search = ''  target.hash = ''  const publicJwk = createPublicKey(createPrivateKey({ key: input.privateJwk, format: 'jwk' })).export({ format: 'jwk' })  return signJwt(    input.privateJwk,    { typ: 'dpop+jwt', jwk: publicJwk },    {      jti: randomUUID(),      htm: input.method.toUpperCase(),      htu: target.toString(),      iat: nowSeconds(),      ...(input.nonce === undefined ? {} : { nonce: input.nonce }),      ...(input.accessToken === undefined ? {} : { ath: b64(createHash('sha256').update(input.accessToken).digest()) }),    },  )}/** A PKCE pair: a 43 character verifier and its S256 challenge. */export function pkcePair(): { verifier: string; challenge: string } {  const verifier = randomBytes(32).toString('base64url')  return { verifier, challenge: b64(createHash('sha256').update(verifier).digest()) }}// Command line use, with node 24 or later, after saving this file as jwt.ts://   node jwt.ts pkce//   node jwt.ts assertion https://identity.tdacorp.in//   node jwt.ts dpop POST https://identity.tdacorp.in/par [nonce] [accessToken]if (import.meta.main) {  const [command, ...args] = process.argv.slice(2)  const clientKey = JSON.parse(process.env.CLIENT_KEY ?? '{}') as JsonWebKey  const dpopKey = JSON.parse(process.env.DPOP_KEY ?? '{}') as JsonWebKey  if (command === 'pkce') console.log(JSON.stringify(pkcePair()))  else if (command === 'assertion' && args[0] !== undefined) {    console.log(clientAssertion({ clientId: process.env.CLIENT_ID ?? '', kid: process.env.CLIENT_KID ?? '', privateJwk: clientKey, issuer: args[0] }))  } else if (command === 'dpop' && args[0] !== undefined && args[1] !== undefined) {    console.log(      dpopProof({        privateJwk: dpopKey,        method: args[0],        url: args[1],        ...(args[2] === undefined || args[2] === '' ? {} : { nonce: args[2] }),        ...(args[3] === undefined ? {} : { accessToken: args[3] }),      }),    )  } else console.error('usage: pkce | assertion <issuer> | dpop <method> <url> [nonce] [accessToken]')}

Put the two private JWKs in the environment as JSON, and the rest in shell variables.

export CLIENT_KEY="$(cat client-private.jwk)"   # the private JWK of your client keyexport DPOP_KEY="$(cat dpop-private.jwk)"       # the private JWK of your DPoP keyexport CLIENT_ID='tdaid_c_...'export CLIENT_KID='app-key-1'export ISSUER='https://identity.tdacorp.in'export REDIRECT_URI='https://app.example.com/api/auth/callback'

1. Discovery#

DISCOVERY=$(curl -s "$ISSUER/.well-known/openid-configuration")PAR=$(echo "$DISCOVERY" | jq -r .pushed_authorization_request_endpoint)AUTHZ=$(echo "$DISCOVERY" | jq -r .authorization_endpoint)TOKEN=$(echo "$DISCOVERY" | jq -r .token_endpoint)USERINFO=$(echo "$DISCOVERY" | jq -r .userinfo_endpoint)

Never hard-code these. See Environments and the issuer.

2. The values for one sign-in#

PKCE=$(node jwt.ts pkce)VERIFIER=$(echo "$PKCE" | jq -r .verifier)CHALLENGE=$(echo "$PKCE" | jq -r .challenge)STATE=$(openssl rand -hex 16)        # 32 characters, the minimum is 16OIDC_NONCE=$(openssl rand -hex 16)

Keep VERIFIER, STATE and OIDC_NONCE until the end. They are what ties the browser's return to this request.

3. Push the request#

Identity requires a server nonce in every DPoP proof, so the first request is always refused and tells you the nonce. A shell function keeps the second try short. Every request builds its own proof and its own client assertion, because both are single use.

par() {  curl -s -D par-headers.txt -X POST "$PAR" \    -H "DPoP: $(node jwt.ts dpop POST "$PAR" "$DPOP_NONCE")" \    --data-urlencode "client_id=$CLIENT_ID" \    --data-urlencode "response_type=code" \    --data-urlencode "redirect_uri=$REDIRECT_URI" \    --data-urlencode "scope=openid profile email" \    --data-urlencode "state=$STATE" \    --data-urlencode "nonce=$OIDC_NONCE" \    --data-urlencode "code_challenge=$CHALLENGE" \    --data-urlencode "code_challenge_method=S256" \    --data-urlencode "response_mode=query" \    --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \    --data-urlencode "client_assertion=$(node jwt.ts assertion "$ISSUER")"}nonce_from() { awk -F': ' 'tolower($1)=="dpop-nonce"{print $2}' "$1" | tr -d '\r'; }DPOP_NONCE=''par                          # {"error":"use_dpop_nonce", ...}DPOP_NONCE=$(nonce_from par-headers.txt)PUSHED=$(par)                # 201DPOP_NONCE=$(nonce_from par-headers.txt)   # every answer carries a fresh nonceecho "$PUSHED"

You should see:

{"request_uri":"urn:ietf:params:oauth:request_uri:...","expires_in":60}

The first call returns 400 with use_dpop_nonce and a DPoP-Nonce header, which is expected.

response_mode=query is sent so the code arrives in the address. A web client's default is form_post, which posts the answer to your redirect address instead. The request is valid for at most 60 seconds; expires_in in the answer is the figure to use.

4. Send the browser#

REQUEST_URI=$(echo "$PUSHED" | jq -r .request_uri)echo "$AUTHZ?client_id=$CLIENT_ID&request_uri=$(node -p "encodeURIComponent(process.argv[1])" "$REQUEST_URI")"

Open that address in a browser. Identity shows its sign-in page, then, for a third-party client, the consent screen the first time. After you approve, the browser goes to your redirect address. If nothing listens there you will see an error page, but the address bar holds the answer:

https://app.example.com/api/auth/callback?code=...&state=...&iss=https%3A%2F%2Fidentity.tdacorp.in

Check, as your application would, that state equals $STATE and iss equals $ISSUER byte for byte. Then copy the code. It lives for at most 60 seconds, so do the next step straight away. An address that is not registered exactly is refused earlier, at step 3.

5. Redeem the code#

CODE='...the code from the address bar...'curl -s -D token-headers.txt -X POST "$TOKEN" \  -H "DPoP: $(node jwt.ts dpop POST "$TOKEN" "$DPOP_NONCE")" \  --data-urlencode "grant_type=authorization_code" \  --data-urlencode "client_id=$CLIENT_ID" \  --data-urlencode "code=$CODE" \  --data-urlencode "redirect_uri=$REDIRECT_URI" \  --data-urlencode "code_verifier=$VERIFIER" \  --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \  --data-urlencode "client_assertion=$(node jwt.ts assertion "$ISSUER")" > tokens.jsonDPOP_NONCE=$(nonce_from token-headers.txt)jq '{token_type, expires_in, scope}' tokens.json

The DPoP key must be the one used in step 3, because the code is bound to it. If the answer is use_dpop_nonce, the nonce had gone stale (it is good for five to ten minutes): take the new one from token-headers.txt and repeat with a fresh assertion.

Then look inside the ID token. This decodes it without verifying it, which is fine for looking and never for trusting:

ID_TOKEN=$(jq -r .id_token tokens.json)node -p "JSON.parse(Buffer.from(process.argv[1].split('.')[1],'base64url'))" "$ID_TOKEN"

Check iss, that aud is your client id, and that nonce equals $OIDC_NONCE. A real client also verifies the signature against the keys at jwks_uri before it believes any of it. See Authorization code with PKCE.

6. Call userinfo#

token_type is DPoP, so the call carries the token under the DPoP scheme with a proof that includes ath, the hash of the token.

ACCESS=$(jq -r .access_token tokens.json)curl -s "$USERINFO" \  -H "Authorization: DPoP $ACCESS" \  -H "DPoP: $(node jwt.ts dpop GET "$USERINFO" "$DPOP_NONCE" "$ACCESS")"
{"sub":"...","name":"...","email":"...","email_verified":true}

Which claims appear depends on the scopes granted: name and the other profile claims need profile, and email needs email. /userinfo requires the openid scope. For a client registered pairwise, sub is that client's own value, the same one its ID token carries, and there is no picture. If it answers 401 with use_dpop_nonce, repeat with the nonce in that response's DPoP-Nonce header.

7. Refresh and revoke, if you asked for offline access#

Add offline_access to scope in step 3 and the token response also has a refresh_token. Use it as in Refresh tokens: the same request shape as step 5 with grant_type=refresh_token and refresh_token. Revoke it by posting token to the revocation endpoint with the same assertion and proof.

When a step fails#

StepYou seeLook at
3invalid_clientThe assertion: iss, sub and aud must be the client id, client id and issuer. The kid must be registered. See Client authentication.
3invalid_request naming redirect_uriThe address is not registered exactly.
3 or 5invalid_dpop_proofThe proof's htu or htm does not match the request, iat is more than 60 seconds off (identity may allow less), or you sent ath to a token endpoint. See DPoP.
4An error page with a codeThe request_uri expired or was used once already. Start again from step 3.
5invalid_grantThe code expired, was used once already, the verifier is wrong, or the DPoP key differs from step 3.
any429Wait for Retry-After. See Rate limits and retries.

This script is for learning. In an application, do the same things in your language's HTTP and JWT libraries, keep the keys in a secret store, keep the verifier and state in server-side storage tied to the browser, and verify every token's signature.

Verified against commit ca46b57