Identity Provider (IdP) - Concepts
Deep dive into OIDC/OAuth2 fundamentals, IdP architecture, token types, and JWT structure. Learn how tokens flow through the system and why every claim matters.
📋Learning Objectives
- Understand OIDC and OAuth2 protocols
- Know the different token types and their purposes
- Understand JWT structure and claims
- Explore the IdP architecture
- Understand the Hot-Core + Extension database architecture
- Run migrations and reset the IdP database
- Inspect IdP data using pgAdmin or psql
🔐OAuth2 Fundamentals
OAuth2 is an authorization framework that enables applications to obtain limited access to user accounts:
Key Concepts
- Resource Owner: The user who owns the data
- Client: The application requesting access (e.g., BFF)
- Authorization Server: Issues tokens (our IdP)
- Resource Server: Hosts protected resources (APIs)
OAuth2 Grant Types
| Grant Type | Use Case | Flow |
|---|---|---|
authorization_code | Web apps with backend | User redirected to IdP, exchanges code for tokens |
client_credentials | Machine-to-machine | No user, app authenticates directly |
refresh_token | Token renewal | Exchange refresh token for new access token |
password | Legacy apps (avoid) | Direct username/password exchange |
🪪OpenID Connect (OIDC)
OIDC is an identity layer built on top of OAuth2. While OAuth2 handles authorization, OIDC adds authentication:
What OIDC Adds
- ID Token: Contains user identity information
- UserInfo Endpoint: Returns additional user claims
- Discovery: Standard endpoint for configuration
- Standard Claims: sub, email, name, etc.
OIDC Discovery Endpoint
# Get IdP's OIDC configuration
curl -k https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration
# Response includes:
{
"issuer": "https://idp.self.empowernow.ai",
"authorization_endpoint": "https://idp.self.empowernow.ai/api/oidc/authorize",
"token_endpoint": "https://idp.self.empowernow.ai/api/oidc/token",
"userinfo_endpoint": "https://idp.self.empowernow.ai/api/oidc/userinfo",
"jwks_uri": "https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json",
...
}
Copy curl and run in your terminal to fetch the IdP's OIDC configuration. This document tells clients everything they need to know about the IdP—where to send users for login, where to exchange codes for tokens, and where to find the public keys.
token_endpoint— Where your app exchanges credentials for tokensjwks_uri— Where to find public keys for token validationscopes_supported— What permissions you can requestgrant_types_supported— Which OAuth flows are enabled
🎟️Token Types
━━━━━━━━━━
• Short-lived (1hr)
• API authorization
• Bearer in header"] RT["Refresh Token
━━━━━━━━━━
• Long-lived (days)
• Get new access tokens
• Backend only"] IT["ID Token
━━━━━━━━━━
• User identity
• Used once after login
• Contains claims"] end AT --> API["API Calls"] RT --> AT IT --> Client["Client App"] style AT fill:#0891b2,color:#fff style RT fill:#7c3aed,color:#fff style IT fill:#059669,color:#fff
1. Access Token
Used to access protected resources:
- Purpose: Authorize API requests
- Lifetime: Short (typically 1 hour)
- Contains: Subject, scopes, expiry, issuer
- Usage: Bearer token in Authorization header
2. Refresh Token
Used to obtain new access tokens:
- Purpose: Renew access without re-authentication
- Lifetime: Long (days to weeks)
- Storage: Must be stored securely (backend only)
- Usage: Exchange for new access token
3. ID Token
Contains user identity information (OIDC only):
- Purpose: Prove user authentication
- Lifetime: Short, used once after login
- Contains: User claims (sub, email, name)
- Usage: Verify user identity in client
Never send ID tokens to APIs! Use access tokens for API calls. ID tokens are for the client to verify user identity.
Copy curl and run in your terminal to get a real access token using the client credentials flow. This is how machine-to-machine services authenticate—no user involved, just the client proving its identity with a secret.
access_token— The JWT you'll send to APIs (copy this for the next exercise!)token_type— Always "Bearer" for OAuth2expires_in— Seconds until this token expiresscope— The permissions granted (may differ from what you requested)
📝JWT Structure
eyJhbGciOi.... But it's actually a cleverly packed envelope containing everything the recipient needs to verify your identity—without calling back to the IdP. In the next few minutes, you'll learn to decode any JWT by hand and understand why that exp claim just cost you an hour of debugging.
JSON Web Tokens (JWTs) have three parts separated by dots:
━━━━━━━
alg: RS256
typ: JWT
kid: key-id"] P["🔶 Payload
━━━━━━━
sub: user-id
exp: timestamp
scopes: [...]"] S["🔒 Signature
━━━━━━━
RSASHA256(
header + payload,
private_key
)"] end H --> P --> S style H fill:#3b82f6,color:#fff style P fill:#f59e0b,color:#fff style S fill:#10b981,color:#fff
1. Header
{
"alg": "RS256", // Algorithm: RSA with SHA-256
"typ": "JWT", // Token type
"kid": "key-id-123" // Key ID for verification
}
2. Payload (Claims)
{
// Standard claims
"iss": "https://idp.self.empowernow.ai", // Issuer
"sub": "user-uuid-123", // Subject (user ID)
"aud": "my-app-client-id", // Audience
"exp": 1699900000, // Expiration time
"iat": 1699896400, // Issued at
"nbf": 1699896400, // Not before
// OIDC claims
"email": "user@empowerid.com",
"name": "John Doe",
// Custom claims
"scope": "openid profile email",
"roles": ["admin", "user"]
}
3. Signature
Created by signing header + payload with private key. Verified using public key from JWKS endpoint.
Use jwt.io to decode and inspect JWT tokens during development. Never paste production tokens on public sites!
The JWKS (JSON Web Key Set) contains the public keys used to verify JWT signatures. Copy curl and run in your terminal to fetch the keys. The kid (Key ID) in the JWT header tells you which key to use.
kid— Key ID that matches the JWT header'skidkty— Key type (RSA or EC)use— "sig" for signature verificationnande— The RSA public key components (modulus and exponent)alg— Algorithm (RS256 = RSA with SHA-256)
Token introspection lets you ask the IdP: "Is this token still valid? What's in it?" Copy curl and run in your terminal; replace YOUR_ACCESS_TOKEN in the command with your token from the previous exercise.
You'll need a valid access token from the previous exercise. If you don't have one, run the token request first, then paste the access_token value here.
🏗️IdP Architecture
The EmpowerNow IdP is built with FastAPI and follows a modular architecture:
/api/oidc/*"] Admin["⚙️ Admin API
/api/admin/*"] MCP["🤖 MCP API
Port 8070"] end subgraph Core["Core Authentication Engine"] TokenGen["Token Generation"] Session["Session Management"] ClientReg["Client Registry"] UserAuth["User Authentication"] end subgraph Storage["Data Layer"] PG["🐘 PostgreSQL
Users & Clients"] Redis["⚡ Redis
Sessions & Cache"] JWKS["🔑 JWKS
Signing Keys"] end end OIDC --> Core Admin --> Core MCP --> Core Core --> PG Core --> Redis Core --> JWKS style IdP fill:#1a1a24,stroke:#2a2a3a style APIs fill:#0d0d14,stroke:#2a2a3a style Core fill:#0d0d14,stroke:#2a2a3a style Storage fill:#0d0d14,stroke:#2a2a3a
Key Components
- OIDC API: Standard OAuth2/OIDC endpoints
- Admin API: Client and user management
- MCP Server: AI agent integration (port 8070)
- Token Engine: JWT generation and validation
- Session Store: Redis-backed session management
🚀The Token Journey: From Request to Resource
Chapter 1: The Token Request
Our story begins when a service (let's call it the "Scheduler Service") needs to call the Calendar API. The Scheduler doesn't have a user—it's a background job. So it uses the client credentials flow:
client_id + client_secret IdP->>Redis: Check client cache Redis-->>IdP: Cache miss IdP->>DB: SELECT from oauth_clients DB-->>IdP: Client record + secret_hash IdP->>IdP: Verify secret (PBKDF2) IdP->>IdP: Generate JWT (sign with private key) IdP-->>Scheduler: {"access_token": "eyJ..."} Note over Scheduler,JWKS: Phase 2: API Call Scheduler->>API: GET /events
Authorization: Bearer eyJ... API->>API: Decode JWT header (get kid) API->>JWKS: GET /.well-known/jwks.json JWKS-->>API: Public keys API->>API: Verify signature API->>API: Check exp, iss, aud, scope API-->>Scheduler: 200 OK + events data Note over Scheduler,JWKS: Phase 3: Token Expires Scheduler->>API: GET /events (1 hour later) API->>API: Check exp claim API-->>Scheduler: 401 Token Expired Scheduler->>IdP: POST /token (repeat Phase 1)
Chapter 2: Inside the IdP
When the token request arrives, the IdP performs several critical steps:
- Client Lookup — Query the
oauth_clientstable (the hot-core we discussed) - Secret Verification — Hash the provided secret with PBKDF2-SHA256 and compare
- Scope Validation — Ensure the client is allowed to request these scopes
- JWT Generation — Create the token with claims (sub, iss, aud, exp, scope)
- Signing — Sign with the private key (RSA-SHA256)
This entire process takes 10-50ms typically. The hot-core table design ensures the database query is sub-millisecond. Most time is spent on cryptographic operations (hashing the secret, signing the JWT).
Chapter 3: At the API Gateway
When the Calendar API receives the request, it never calls back to the IdP. Instead:
- Extract Token — Parse the
Authorization: Bearerheader - Decode Header — Base64-decode the first segment to find the
kid - Fetch JWKS — Get public keys from the IdP (cached for hours)
- Verify Signature — Use the matching public key to verify the signature
- Validate Claims — Check
exp(not expired),iss(correct issuer),aud(intended for this API) - Check Scopes — Ensure the token has permission for this operation
Because the API validates tokens locally using public keys, it can handle thousands of requests per second without ever calling the IdP. This is why JWTs are so popular for microservices—they're self-contained proof of identity.
Chapter 4: When Things Go Wrong
Understanding the journey helps you debug failures:
- 401 at /token → Client credentials wrong (check client_id, secret)
- 401 at API "invalid_token" → Signature verification failed (kid mismatch? key rotation?)
- 401 at API "token_expired" → Check the
expclaim, token needs refresh - 403 at API → Token is valid but missing required scope
🌐Identity Federation
What is Identity Federation?
Federation means trusting another Identity Provider to authenticate users on your behalf. Instead of managing credentials locally, you delegate authentication to an external IdP (like Azure AD, Okta, or Google) and accept their tokens as proof of identity.
No Password Duplication
Users authenticate with their existing corporate credentials. No new passwords to remember or manage.
Enterprise Compliance
IT policies (MFA, conditional access, password rotation) are enforced by the corporate IdP—not your app.
Instant Deprovisioning
When IT disables an account in Azure AD, access to EmpowerNow is immediately revoked.
Unified Audit Trail
Authentication events are logged in both the corporate IdP and EmpowerNow for complete visibility.
Federation vs Local Authentication
| Aspect | Local Authentication | Federated Authentication |
|---|---|---|
| Credentials stored | In EmpowerNow IdP database | In external IdP (Azure AD, Okta) |
| Password policy | EmpowerNow controls | External IdP controls |
| MFA | EmpowerNow WebAuthn/TOTP | External IdP (Authenticator app, etc.) |
| User provisioning | Create account in EmpowerNow | No local account required* |
| Token issuer | EmpowerNow IdP | EmpowerNow IdP (after validation) |
*Federated users can access EmpowerNow without any entry in the local users table—PDP policies authorize access based on their ARN pattern.
How Federation Works
When a user clicks "Sign in with Azure AD", a multi-step dance happens between three parties:
Key Steps Explained
- Steps 1-3: User initiates login; EmpowerNow IdP redirects to Azure
- Steps 4-5: User authenticates with Azure (their IT policies apply—MFA, conditional access, etc.)
- Steps 6-8: Azure returns an authorization code; IdP exchanges it for Azure tokens
- Step 9: IdP validates Azure tokens using JWKS (JSON Web Key Set)—no introspection needed
- Step 10: IdP mints its own tokens with a provider-scoped ARN as the subject
- Steps 13-15: All authorization decisions use the ARN—PDP doesn't care if you're local or federated
Notice that Azure tokens stay server-side (steps 7-8). The browser only sees EmpowerNow session cookies. This is the BFF pattern at work—even federated tokens are protected.
Identity ARNs for Federated Users
Every identity in EmpowerNow—local or federated—gets a canonical ARN (Amazon Resource Name-style identifier):
auth:{type}:{provider}:{subject}
# Examples:
auth:account:empowernow:alice # Local user
auth:account:entra.contoso:ffe9b9f0-ec04-4b9c # Azure AD user (oid)
auth:account:okta:00u1a2b3c4d5e6f7g8 # Okta user
auth:account:google:108234567890123456789 # Google user
auth:agent:acme-corp:invoice-bot # AI agent
Why ARNs Matter
- Provider namespacing:
entra.contosovsentra.fabrikamprevents cross-tenant collisions - Stable identity: Azure
oid(Object ID) is used, not email (which can change) - Policy targeting: Write PDP policies that match ARN patterns:
auth:account:entra.*:* - Audit trails: Every action is attributed to a specific, traceable identity
JWKS Validation (No Introspection)
When the IdP receives an Azure token, it validates using JWKS (JSON Web Key Set)—a public endpoint where Azure publishes its signing keys:
# Azure JWKS endpoint (per-tenant)
https://login.microsoftonline.com/{tenant-id}/discovery/v2.0/keys
# Response contains public keys:
{
"keys": [
{
"kty": "RSA",
"kid": "abc123...", // Key ID (matches JWT header)
"n": "0vx7agoebG...", // RSA modulus
"e": "AQAB" // RSA exponent
}
]
}
Validation steps:
- Fetch JWKS from Azure (cached for ~24 hours)
- Find the key matching the JWT's
kid(key ID) header - Verify the signature using the public key
- Validate
iss(issuer),aud(audience),exp(expiry)
Azure doesn't support RFC 7662 introspection for user tokens. JWKS is stateless—once you have the public keys, you can validate tokens without calling Azure on every request. This is faster and more resilient.
Claims Mapping
Azure tokens have Azure-specific claims. The IdP maps them to EmpowerNow's format:
| Azure Claim | EmpowerNow Claim | Notes |
|---|---|---|
oid | sub (in ARN) | Stable Object ID—never changes |
preferred_username | email | Usually the UPN |
name | name | Display name |
roles | roles | App roles assigned in Azure |
groups | groups | Group memberships (if configured) |
scp | permissions | Delegated permissions (space-delimited) |
tid | (in provider name) | Tenant ID for namespacing |
Example: Federated Token
After federation, the EmpowerNow IdP mints a token like this:
{
"iss": "https://idp.self.empowernow.ai",
"sub": "auth:account:entra.contoso:ffe9b9f0-ec04-4b9c-bd48-30fdefd72a5e",
"aud": ["bff-client"],
"email": "john.doe@contoso.com",
"name": "John Doe",
"roles": ["User", "Admin"],
"idp": "entra.contoso",
"idp_sub": "ffe9b9f0-ec04-4b9c-bd48-30fdefd72a5e",
"federation": {
"orig_iss": "https://login.microsoftonline.com/contoso.com/v2.0",
"orig_sub": "ffe9b9f0-ec04-4b9c-bd48-30fdefd72a5e",
"stable_id_claim": "oid"
},
"exp": 1706000000,
"iat": 1705996400
}
For complete federation configuration—including federation.yaml setup, Azure app registration, claims mapping rules, and troubleshooting—see the official EmpowerNow documentation and your identity administrator.
🗄️Data Layer: Hot-Core + Extension Architecture
The IdP uses a two-table design that separates hot-path data from flexible configuration. This keeps token endpoint queries fast while allowing configuration changes without migrations.
• require_par
• require_dpop
• feature_flags"] E3["metadata (JSONB)
• description
• contacts"] end C1 -.->|"FK"| E1 Token["⚡ Token Endpoint
(sub-millisecond)"] --> Core AdminUI["🖥️ Admin UI
(config updates)"] --> Ext style Core fill:#059669,color:#fff style Ext fill:#7c3aed,color:#fff style Token fill:#0891b2,color:#fff style AdminUI fill:#f59e0b,color:#fff
Why Two Tables?
| Concern | Core Table | Extension Table |
|---|---|---|
| Purpose | Token endpoint hot path | Admin configuration & metadata |
| Query Pattern | Every /token request | Admin reads/writes only |
| Schema Changes | Requires migration | Just update JSONB, no migration |
| Cache Impact | Must be cached | Updates don't invalidate core cache |
Key Tables
- oauth_clients: Client registrations (client_id, secret_hash, scopes, grant_types)
- oauth_client_ext: Flexible client config and metadata (JSONB)
- accounts: User accounts (sub, name, email, status)
- account_ext: User preferences and custom attributes (JSONB)
- credentials: Passwords and WebAuthn passkeys
- identities: Email, phone, and federated identity links
The hot path reads only from oauth_clients:
SELECT client_id, secret_hash, grant_types, scopes, fapi_profile
FROM idp.oauth_clients WHERE client_id = $1;
No JOINs, no JSONB parsing — just a simple index lookup.
🔄Running Migrations
IdP uses Alembic with separate dev and prod migration chains (versions_shared + versions_dev vs versions_shared + versions_prod). The idp-alembic container picks alembic.ini or alembic-prod.ini from IDP_MIGRATION_PROFILE (add -f docker-compose.prod.yml for prod). See docs/idp-migration-devops-guide.html.
Automatic Setup (Default)
When you run docker compose --profile core up, migrations run automatically. From your project root (e.g. EmpowerNow):
# Migrations are automatic! Just start services:
cd ServiceConfigs/Deployment
docker compose --profile core up -d
# The idp-alembic container (default dev profile):
# 1. Creates the 'idp' schema (versions_shared 0001-0008)
# 2. Seeds infrastructure (0009 in versions_shared)
# 3. Runs dev chain (versions_dev): full clients/users from 0010, then 0011-0017
# 4. Prod chain (versions_prod p0010-p0012) only if IDP_MIGRATION_PROFILE=prod
# 5. IdP waits for alembic to complete before starting
Fresh Start (Reset Database)
If you need to completely reset the IdP database:
# Stop IdP first
docker stop idp-app
# Drop and recreate database
docker exec empowernow-db-1 psql -U postgres -c "DROP DATABASE IF EXISTS idp_db;"
docker exec empowernow-db-1 psql -U postgres -c "CREATE DATABASE idp_db WITH ENCODING 'UTF8';"
# Run migrations + seed data
docker compose -f docker-compose.yml -f docker-compose-build.yml \
--profile core up idp-alembic --build
# Restart IdP
docker start idp-app
Verify Seeded Data
# Check that clients were seeded
docker exec empowernow-db-1 psql -U postgres -d idp_db -c \
"SELECT client_id FROM idp.oauth_clients;"
# Check that users were seeded
docker exec empowernow-db-1 psql -U postgres -d idp_db -c \
"SELECT sub, name FROM idp.accounts;"
If migrations fail due to existing tables, drop the schema first:
docker exec empowernow-db-1 psql -U postgres -d idp_db \
-c "DROP SCHEMA IF EXISTS idp CASCADE;"
🔧Using pgAdmin to Inspect Database
pgAdmin provides a web UI for exploring the PostgreSQL database.
Start pgAdmin
# Add tools profile to your compose command:
docker compose --profile core --profile tools up -d pgadmin
# Or start tools with everything:
docker compose --profile core --profile monitoring --profile tools up -d
Access pgAdmin
| Setting | Value |
|---|---|
| URL | https://pgadmin.self.empowernow.ai or http://localhost:5050 |
admin@empowernow.ai | |
| Password | EmpowerNow2024! |
Add Server Connection
Right-click "Servers" → "Register" → "Server":
| Field | Value |
|---|---|
| Name | EmpowerNow |
| Host | db |
| Port | 5432 |
| Database | idp_db |
| Username | postgres |
| Password | empowernow |
Example Queries
-- List all OAuth clients
SELECT client_id, client_name, client_type, grant_types
FROM idp.oauth_clients;
-- List all user accounts
SELECT sub, name, status, mfa_enabled
FROM idp.accounts;
-- Join client with extension config
SELECT c.client_id, c.client_name, e.config, e.metadata
FROM idp.oauth_clients c
LEFT JOIN idp.oauth_client_ext e ON c.id = e.client_id;
Alternative: psql CLI
# Interactive psql session
docker exec -it empowernow-db-1 psql -U postgres -d idp_db
# Inside psql:
\dt idp.* -- List all tables
\d idp.oauth_clients -- Describe table
SELECT * FROM idp.oauth_clients LIMIT 5;
\q -- Quit
➕Adding New Clients & Users
New OAuth clients and users are added by editing the seed migration file.
Edit Seed Migration
Open IdP/migrations/versions_dev/0010_seed_clients_and_users.py
Confidential Client Template
# Add to CLIENTS dictionary:
"my-service-client": {
"client_type": "confidential",
"secret_hash": SECRET1_HASH, # Hash of "secret1"
"description": "My service description",
"grant_types": ["client_credentials"],
"scopes": ["openid", "profile"],
"default_audience": "api://my-api",
"token_endpoint_auth_method": "client_secret_post",
},
Public Client Template (SPA/Mobile)
"my-spa-client": {
"client_type": "public",
"description": "My SPA application",
"grant_types": ["authorization_code", "refresh_token"],
"scopes": ["openid", "profile", "email", "offline_access"],
"redirect_uris": [
"http://localhost:3000/callback",
"https://myapp.example.com/callback"
],
"post_logout_uris": ["http://localhost:3000"],
},
User Account Template
# Add to USERS dictionary:
"john.smith": {
"name": "John Smith",
"given_name": "John",
"family_name": "Smith",
"email": "john.smith@empowerid.com",
"roles": ["user"],
"password_hash": PASSWORD_HASH, # Hash of "password"
},
Generate Password Hash
# Generate a new password hash:
python -c "
from passlib.hash import pbkdf2_sha256
hash = pbkdf2_sha256.using(rounds=600000).hash('your-password')
print(hash)
"
The migration file includes constants you can reuse:
PASSWORD_HASH— Hash for password "password"SECRET1_HASH— Hash for client secret "secret1"
Apply Changes
# Reset and re-run migrations
docker exec empowernow-db-1 psql -U postgres -d idp_db \
-c "DROP SCHEMA IF EXISTS idp CASCADE;"
docker compose -f docker-compose.yml -f docker-compose-build.yml \
--profile core up idp-alembic --build
# Verify your new client/user
docker exec empowernow-db-1 psql -U postgres -d idp_db \
-c "SELECT client_id FROM idp.oauth_clients WHERE client_id = 'my-service-client';"
🔬Hands-on Labs
The OIDC Discovery document is like a restaurant menu—it tells clients everything the IdP offers. Let's explore it.
Fetch the discovery document:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq .
If you don't have jq, pipe to python -m json.tool instead.
Find the token endpoint URL:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq -r '.token_endpoint'
List all supported scopes:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq '.scopes_supported'
Find which grant types are enabled:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq '.grant_types_supported'
You should see endpoints like token_endpoint, authorization_endpoint, jwks_uri. The scopes should include openid, profile, email. Grant types should include authorization_code and client_credentials.
- What's the difference between
token_endpointandauthorization_endpoint? - Why is
offline_accesslisted as a scope?
Let's get a real token, decode it piece by piece, and verify it manually.
Request an access token using client credentials:
TOKEN_RESPONSE=$(curl -s -X POST https://idp.self.empowernow.ai/api/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=bff-client" \
-d "client_secret=secret1" \
-d "scope=openid profile")
echo $TOKEN_RESPONSE | jq .
Extract just the access token:
ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
echo "Token: ${ACCESS_TOKEN:0:50}..."
Split the JWT into its three parts:
# Split by dots
HEADER=$(echo $ACCESS_TOKEN | cut -d'.' -f1)
PAYLOAD=$(echo $ACCESS_TOKEN | cut -d'.' -f2)
SIGNATURE=$(echo $ACCESS_TOKEN | cut -d'.' -f3)
echo "Header: ${HEADER:0:30}..."
echo "Payload: ${PAYLOAD:0:30}..."
echo "Signature: ${SIGNATURE:0:30}..."
Decode the header (base64url → JSON):
# Add padding and decode
echo $HEADER | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
You should see alg (algorithm), typ (type), and kid (key ID).
Decode the payload to see claims:
echo $PAYLOAD | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
You should see iss, sub, aud, exp, iat, and scope.
Convert the exp timestamp to human-readable:
EXP=$(echo $PAYLOAD | tr '_-' '/+' | base64 -d 2>/dev/null | jq -r '.exp')
date -d @$EXP # Linux
# On macOS: date -r $EXP
The header should show RS256 algorithm. The payload should have an exp timestamp about 1 hour in the future and iss matching https://idp.self.empowernow.ai.
- What does the
kidin the header tell you? - How would you verify this token hasn't been tampered with?
- Why is
iat(issued at) useful for debugging?
Let's look at the actual data in PostgreSQL to understand what gets stored and why.
Connect to the PostgreSQL database:
docker exec -it empowernow-db-1 psql -U postgres -d idp_db
List all tables in the IdP schema:
\dt idp.*
You should see tables like oauth_clients, oauth_client_ext, accounts, etc.
Examine the oauth_clients table structure:
\d idp.oauth_clients
Notice the columns: client_id, secret_hash, grant_types, scopes—these are the hot-path fields.
List all registered OAuth clients:
SELECT client_id, client_type, grant_types
FROM idp.oauth_clients;
Find the client we used in Lab 2:
SELECT client_id, client_type, scopes, token_endpoint_auth_method
FROM idp.oauth_clients
WHERE client_id = 'bff-client';
Look at the extension table for flexible config:
SELECT c.client_id, e.config, e.metadata
FROM idp.oauth_clients c
LEFT JOIN idp.oauth_client_ext e ON c.id = e.client_id
WHERE c.client_id = 'bff-client';
Check the user accounts:
SELECT sub, name, email, status
FROM idp.accounts LIMIT 5;
Exit psql:
\q
You should see several pre-seeded clients (bff-client, idp-ui-client, etc.) and user accounts (alice, bob, etc.). The secret_hash column should contain PBKDF2 hashes, not plaintext secrets.
If you prefer visual tools over the command line, pgAdmin provides a web-based interface for database exploration:
- Start pgAdmin:
docker compose --profile tools up -d pgadmin - Open https://pgadmin.self.empowernow.ai (or
http://localhost:5050) - Login:
admin@empowernow.ai/EmpowerNow2024! - Add server: Host=
db, Port=5432, Database=idp_db, User=postgres, Password=empowernow
You can browse tables, run queries with syntax highlighting, and export results—all from your browser.
- Why are secrets stored as hashes rather than plaintext?
- What information is in the core table vs. the extension table?
- How would you add a new test client for development?
Let's connect the dots—match the kid from a token to the actual public key in JWKS.
Fetch the JWKS (public keys):
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json | jq .
List all key IDs:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json | jq '.keys[].kid'
Get the kid from your token (from Lab 2):
# If you still have $ACCESS_TOKEN from Lab 2:
TOKEN_KID=$(echo $ACCESS_TOKEN | cut -d'.' -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq -r '.kid')
echo "Token kid: $TOKEN_KID"
Find the matching key in JWKS:
curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json | \
jq --arg kid "$TOKEN_KID" '.keys[] | select(.kid == $kid)'
This should return the exact public key used to sign your token!
The kid from your token's header should match one of the keys in the JWKS. You should see the full RSA public key with n (modulus) and e (exponent) components.
- Why might there be multiple keys in JWKS?
- What happens if an API receives a token with a
kidthat's not in JWKS? - How often should APIs cache the JWKS?
🔧Troubleshooting: When Tokens Go Wrong
The API returns 401 Unauthorized with the message {"error": "invalid_token"}. The token was working fine yesterday!
First, decode the token and check these claims:
- exp — Is the token expired? Compare to current Unix timestamp
- iss — Does the issuer match what the API expects?
- aud — Is your API listed in the audience?
# Quick check: is the token expired?
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '.exp'
date +%s # Compare with current timestamp
- Clock skew: Your server's time is off by more than a few minutes
- Wrong issuer: The token was issued by staging IdP but API expects production
- Audience mismatch: Token was issued for a different API
- Token rotation: Using an old cached token after key rotation
- Sync your server clock:
ntpdate pool.ntp.org - Verify IdP configuration matches your environment
- Check if the client is requesting the correct audience
- Clear cached tokens and request fresh ones
The API returns 403 Forbidden even though the token is valid. You can access some endpoints but not others.
Check the scope claim in your token:
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '.scope'
# Output: "openid profile"
# Missing: "calendar:write" which the endpoint requires!
Then check what scopes the endpoint requires (usually in API docs or error response).
- Didn't request scope: The client didn't ask for the required scope during token request
- Client not allowed: The client registration doesn't permit that scope
- Scope granted vs requested: IdP may grant fewer scopes than requested
- Update your token request to include the missing scope
- If using client credentials, update the client's allowed scopes in the database
- Re-request the token with the correct scope parameter
# Example: request with additional scope
curl -X POST $TOKEN_ENDPOINT \
-d "grant_type=client_credentials" \
-d "client_id=my-client" \
-d "client_secret=secret" \
-d "scope=openid profile calendar:write" # Added calendar:write
Token validation fails intermittently. Some requests work, others fail with "invalid signature." The problem started after a deployment or at midnight.
This is likely a kid (Key ID) mismatch during key rotation:
# Get kid from a failing token
echo $FAILING_TOKEN | cut -d'.' -f1 | base64 -d 2>/dev/null | jq '.kid'
# Output: "key-2024-01"
# Check what keys are in JWKS
curl -s $JWKS_URI | jq '.keys[].kid'
# Output: "key-2024-02" ← The old key is gone!
- Key rotation without grace period: IdP rotated keys but old tokens are still in circulation
- Stale JWKS cache: API cached the old JWKS and hasn't refreshed
- Multiple IdP instances: Load balancer hitting different IdP instances with different keys
Immediate mitigation:
- Clear the API's JWKS cache to fetch fresh keys
- Force clients to request new tokens
Long-term prevention:
- Configure IdP to keep old keys in JWKS for a grace period (24-48 hours)
- Set API's JWKS cache TTL shorter than key rotation period
- Implement automatic JWKS refresh on
kidlookup failure
key-old → key-new Client->>IdP: Request token IdP-->>Client: Token (kid=key-new) Client->>API: Request with token API->>API: JWKS cache has key-old only! API-->>Client: 401 Invalid signature Note over API: API must refresh JWKS API->>IdP: GET /jwks.json IdP-->>API: keys: [key-new, key-old] Client->>API: Retry request API->>API: Found key-new! API-->>Client: 200 OK
❓Knowledge Check
OAuth2 is an authorization framework—it answers "What is this app allowed to do?" It gives apps limited access to resources without sharing passwords.
OIDC is an authentication layer built on top of OAuth2—it answers "Who is this user?" It adds the ID Token, which contains user identity information like name and email.
Think of it this way: OAuth2 is like a valet parking ticket (access to the car), while OIDC is like a driver's license (proof of identity).
client_credentials: Use when there's no user involved. Examples:
- A background job calling an API
- Service-to-service communication
- Scheduled tasks fetching data
authorization_code: Use when a user is logging in through a web app with a backend. Examples:
- User signing into a web application
- Apps that need to act on behalf of users
The authorization_code flow involves a browser redirect and user consent, while client_credentials is a simple server-to-server exchange.
ID Tokens are designed for client applications only—they prove to the app who just logged in. They're meant to be consumed once, right after authentication.
Access Tokens are designed for API calls—they contain scopes that define what operations are allowed.
Sending ID Tokens to APIs creates problems:
- ID Tokens may contain sensitive PII that the API shouldn't see
- ID Tokens don't have the scope claims that APIs need for authorization
- The audience (
aud) of an ID Token is the client app, not the API
- iss (Issuer): Who created and signed this token. Example:
https://idp.self.empowernow.ai. APIs verify this to ensure the token came from a trusted source. - sub (Subject): Who this token represents. For user tokens, it's the user ID. For client_credentials tokens, it's often the client_id.
- aud (Audience): Who this token is intended for. APIs should reject tokens that don't list them in the audience.
Together, these claims answer: "Who issued this? Who is it about? Who should accept it?"
JWTs are self-contained—all the information needed to validate them is either in the token itself or in a public endpoint:
- Fetch JWKS: The API fetches public keys from the IdP's JWKS endpoint (and caches them)
- Match kid: Look at the token header's
kidclaim and find the matching key in JWKS - Verify signature: Use the public key to verify the token's cryptographic signature
- Check claims: Validate
exp(not expired),iss(correct issuer),aud(correct audience)
This "offline validation" is why JWTs scale so well—the IdP only gets called once per key rotation, not once per request.
The Hot-Core + Extension split optimizes for two very different access patterns:
Hot-Core (oauth_clients):
- Hit on every token request
- Must be blazing fast (sub-millisecond)
- Contains only essential fields: client_id, secret_hash, scopes, grant_types
- Schema changes require migrations
Extension (oauth_client_ext):
- Hit only during admin operations
- Can be slower—admin doesn't need millisecond latency
- Uses JSONB for flexible config—no migrations needed for new fields
- Updates don't invalidate the hot path cache
This pattern keeps token endpoint latency low while allowing flexible configuration.
The most common post-deployment token failures:
- Key rotation: If the IdP rotated signing keys and the API's JWKS cache still has old keys, tokens signed with the new key will fail validation. The
kidin the token won't match any cached key. - Issuer URL change: If the IdP's URL changed (e.g., from staging to production), the
issclaim won't match what the API expects. - Clock skew: If server times drifted, tokens might appear expired (
exp) or not yet valid (nbf). - Configuration mismatch: Environment variables pointing to wrong IdP endpoints.
Quick debug steps:
- Decode the failing token and check all claims
- Compare
kidagainst current JWKS - Verify server time with
date - Check environment variables
📝Day 6 Checkpoint
- Can explain the difference between OAuth2 and OIDC
- Know when to use each OAuth2 grant type
- Understand the purpose of Access, Refresh, and ID tokens
- Can decode a JWT and identify its claims
- Understand the IdP architecture components
- Understand why IdP uses Hot-Core + Extension tables
- Can reset the IdP database and run migrations
- Can inspect IdP data using pgAdmin or psql CLI
- Know how to add new OAuth clients and users