Appearance
Code samples: Node, Python and cURL
Each sample signs a person in with Authorization Code + PKCE, exchanges the code, checks the ID token, and can ask for a fresh face scan before a sensitive action (step-up). Use them to see the flow working, or as a starting point when your framework has no OIDC library you trust.
In a real app, prefer your framework's OIDC library (see Integrate your app). It does all of this for you.
Before you start
You need a client ID with http://localhost:5173/callback as a redirect URI. Your sandbox's Starter app already has it: copy its client ID from the Apps tab at {issuer}/tenant (see Sandbox). Any other client works if you add that redirect URI, or change REDIRECT_URI to one it has.
The samples read three settings from the environment:
| Variable | Value |
|---|---|
ISSUER | Your environment's issuer, for example https://idp-test.entryidp.com. |
CLIENT_ID | Your client ID. |
CLIENT_SECRET | Only for a confidential client. Leave it unset for the Starter app. |
Step-up: asking for a fresh scan
EntryIdP runs a face scan on every sign-in, so sending the person back to /authorize is already a real step-up. To make your app enforce it rather than assume it:
- Add
max_age=0to the authorization request. - Note the time you sent the person to
/authorize. - After the exchange, check that the ID token's
auth_time(the time of the face scan) is not earlier than that time.
auth_time is the time of the scan, not the time the token was issued. A token from a refresh keeps the auth_time of the original scan, so it can never pass this check.
Each sample does this when you pass --step-up. The checks allow 30 seconds for a difference between your clock and ours.
Node
Node 18 or later. No packages to install.
js
// signin.mjs
// Run: ISSUER=https://idp-test.entryidp.com CLIENT_ID=your-client-id node signin.mjs [--step-up]
import { createServer } from 'node:http';
import { createHash, createPublicKey, createVerify, randomBytes } from 'node:crypto';
const ISSUER = process.env.ISSUER ?? 'https://idp-test.entryidp.com';
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET; // confidential clients only
const REDIRECT_URI = process.env.REDIRECT_URI ?? 'http://localhost:5173/callback';
const STEP_UP = process.argv.includes('--step-up');
const CLOCK_SKEW = 30;
const getJson = async (url) => (await fetch(url)).json();
const config = await getJson(`${ISSUER}/.well-known/openid-configuration`);
// 1. Send the person to /authorize with PKCE, state and nonce.
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(16).toString('base64url');
const nonce = randomBytes(16).toString('base64url');
const sentAt = Math.floor(Date.now() / 1000);
const authorize = new URL(config.authorization_endpoint);
authorize.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'openid profile',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
...(STEP_UP && { max_age: '0' }),
});
console.log(`Open this link and complete the face scan:\n\n${authorize}\n`);
// 2. Receive the code at the redirect URI.
const redirect = new URL(REDIRECT_URI);
const code = await new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const url = new URL(req.url, REDIRECT_URI);
if (url.pathname !== redirect.pathname) return res.writeHead(404).end();
res.end('You can close this tab and go back to the terminal.');
server.close();
const q = url.searchParams;
if (q.get('error')) reject(new Error(`${q.get('error')}: ${q.get('error_description')}`));
else if (q.get('state') !== state) reject(new Error('state does not match'));
else resolve(q.get('code'));
}).listen(redirect.port);
});
// 3. Exchange the code within 60 seconds. It works once.
const form = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
});
if (CLIENT_SECRET) form.set('client_secret', CLIENT_SECRET);
const response = await fetch(config.token_endpoint, { method: 'POST', body: form });
const tokens = await response.json();
if (!response.ok) throw new Error(`${tokens.error}: ${tokens.error_description}`);
// 4. Check the ID token: signature against the published keys, then issuer, audience, expiry and nonce.
const [header64, payload64, signature64] = tokens.id_token.split('.');
const header = JSON.parse(Buffer.from(header64, 'base64url'));
const claims = JSON.parse(Buffer.from(payload64, 'base64url'));
if (header.alg !== 'RS256') throw new Error(`unexpected alg ${header.alg}`);
const { keys } = await getJson(config.jwks_uri);
const jwk = keys.find((k) => k.kid === header.kid);
if (!jwk) throw new Error(`no published key with kid ${header.kid}`);
const signed = createVerify('RSA-SHA256')
.update(`${header64}.${payload64}`)
.verify(createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(signature64, 'base64url'));
const now = Math.floor(Date.now() / 1000);
const audience = [claims.aud].flat();
if (!signed) throw new Error('signature does not verify');
if (claims.iss !== config.issuer) throw new Error(`wrong issuer ${claims.iss}`);
if (!audience.includes(CLIENT_ID)) throw new Error('token is for another client');
if (claims.exp + CLOCK_SKEW < now) throw new Error('token has expired');
if (claims.nonce !== nonce) throw new Error('nonce does not match');
// 5. Step-up: the face scan must have happened after this request was sent.
if (STEP_UP && claims.auth_time + CLOCK_SKEW < sentAt) throw new Error('no fresh face scan');
console.log('Signed in:', { sub: claims.sub, auth_time: new Date(claims.auth_time * 1000).toISOString(), acr: claims.acr });Python
Python 3.9 or later, with PyJWT to check the signature: pip install "pyjwt[crypto]".
python
# signin.py
# Run: ISSUER=https://idp-test.entryidp.com CLIENT_ID=your-client-id python signin.py [--step-up]
import base64, hashlib, json, os, secrets, sys, time
import urllib.error, urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
import jwt # PyJWT
ISSUER = os.environ.get("ISSUER", "https://idp-test.entryidp.com")
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ.get("CLIENT_SECRET") # confidential clients only
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:5173/callback")
STEP_UP = "--step-up" in sys.argv
CLOCK_SKEW = 30
def get_json(url):
with urllib.request.urlopen(url) as response:
return json.load(response)
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
config = get_json(f"{ISSUER}/.well-known/openid-configuration")
# 1. Send the person to /authorize with PKCE, state and nonce.
verifier = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = secrets.token_urlsafe(16)
nonce = secrets.token_urlsafe(16)
sent_at = int(time.time())
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid profile",
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
if STEP_UP:
params["max_age"] = "0"
print(f"Open this link and complete the face scan:\n\n{config['authorization_endpoint']}?{urllib.parse.urlencode(params)}\n")
# 2. Receive the code at the redirect URI.
redirect = urllib.parse.urlsplit(REDIRECT_URI)
result = {}
class Callback(BaseHTTPRequestHandler):
def do_GET(self):
url = urllib.parse.urlsplit(self.path)
if url.path != redirect.path:
self.send_error(404)
return
result.update(urllib.parse.parse_qsl(url.query))
self.send_response(200)
self.end_headers()
self.wfile.write(b"You can close this tab and go back to the terminal.")
def log_message(self, *args):
pass
server = HTTPServer((redirect.hostname, redirect.port), Callback)
while not result:
server.handle_request()
if "error" in result:
sys.exit(f"{result['error']}: {result.get('error_description')}")
if result.get("state") != state:
sys.exit("state does not match")
# 3. Exchange the code within 60 seconds. It works once.
form = {
"grant_type": "authorization_code",
"code": result["code"],
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"code_verifier": verifier,
}
if CLIENT_SECRET:
form["client_secret"] = CLIENT_SECRET
request = urllib.request.Request(config["token_endpoint"], data=urllib.parse.urlencode(form).encode())
try:
with urllib.request.urlopen(request) as response:
tokens = json.load(response)
except urllib.error.HTTPError as error:
body = json.load(error)
sys.exit(f"{body.get('error')}: {body.get('error_description')}")
# 4. Check the ID token: signature against the published keys, then issuer, audience, expiry and nonce.
signing_key = jwt.PyJWKClient(config["jwks_uri"]).get_signing_key_from_jwt(tokens["id_token"])
claims = jwt.decode(
tokens["id_token"],
signing_key.key,
algorithms=["RS256"],
audience=CLIENT_ID,
issuer=config["issuer"],
leeway=CLOCK_SKEW,
)
if claims.get("nonce") != nonce:
sys.exit("nonce does not match")
# 5. Step-up: the face scan must have happened after this request was sent.
if STEP_UP and claims["auth_time"] + CLOCK_SKEW < sent_at:
sys.exit("no fresh face scan")
print("Signed in:", {"sub": claims["sub"], "auth_time": claims["auth_time"], "acr": claims.get("acr")})cURL
For exploring the flow by hand. cURL can show the ID token's claims but can't check its signature, so use one of the samples above, or a library, for that. You need openssl and jq.
1. Set up and create the PKCE values.
sh
ISSUER=https://idp-test.entryidp.com
CLIENT_ID=your-client-id
REDIRECT_URI=http://localhost:5173/callback
VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
SENT_AT=$(date +%s)2. Build the sign-in link and open it. For step-up, add &max_age=0 to the end.
sh
echo "$ISSUER/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$(jq -rn --arg v "$REDIRECT_URI" '$v|@uri')&scope=openid%20profile&state=$STATE&nonce=$NONCE&code_challenge=$CHALLENGE&code_challenge_method=S256"After the face scan, the browser goes to your redirect URI. Nothing is listening there, so the page won't load, but the address bar holds the code. Check that its state matches $STATE, then copy the code.
3. Exchange the code within 60 seconds. It works once. For a confidential client, add -d client_secret=....
sh
CODE=paste-the-code-here
curl -s "$ISSUER/token" \
-d grant_type=authorization_code \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=$REDIRECT_URI" \
-d client_id="$CLIENT_ID" \
-d code_verifier="$VERIFIER" | tee tokens.json | jq .4. Read the ID token's claims. Check iss, aud (your client ID), exp and nonce ($NONCE). For step-up, check that auth_time is not earlier than $SENT_AT.
sh
b64url_decode() { s=$(tr '_-' '/+'); while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done; printf %s "$s" | openssl base64 -d -A; }
jq -r .id_token tokens.json | cut -d. -f2 | b64url_decode | jq .
echo "Sent at: $SENT_AT"5. Call /userinfo with the access token.
sh
curl -s "$ISSUER/userinfo" -H "Authorization: Bearer $(jq -r .access_token tokens.json)" | jq .The ID token checks above are the minimum. See the pre-launch checklist before you ship, and the Reference for every endpoint, claim and error.