DAY 06 WEEK 2: CORE SERVICES

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.

Every time you log into an app, a carefully orchestrated dance happens behind the scenes. Your password never travels to that third-party calendar app you're granting access to. Instead, a trusted intermediary—the Identity Provider—issues cryptographic proof of who you are. Today, we'll lift the hood on this magic and understand every piece of the puzzle. By the end, you'll be able to decode any token, trace any authentication flow, and debug identity issues like a pro.

📋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

Imagine you're building a new productivity app that needs to access a user's Google Calendar. You could ask for their Google password... but that's a terrible idea. They'd have to trust you completely, you'd be liable for their entire Google account, and if they changed their password, your app would break. OAuth2 solves this elegantly—it lets users grant your app limited access without ever sharing their password. Let's see how this works.

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 TypeUse CaseFlow
authorization_codeWeb apps with backendUser redirected to IdP, exchanges code for tokens
client_credentialsMachine-to-machineNo user, app authenticates directly
refresh_tokenToken renewalExchange refresh token for new access token
passwordLegacy apps (avoid)Direct username/password exchange
OAuth2 Authorization Code Flow
sequenceDiagram participant User participant App as Client App participant IdP as Identity Provider participant API as Resource Server User->>App: 1. Click Login App->>IdP: 2. Redirect to /authorize IdP->>User: 3. Show Login Form User->>IdP: 4. Enter Credentials IdP->>App: 5. Redirect with Auth Code App->>IdP: 6. Exchange Code for Tokens IdP->>App: 7. Access Token + Refresh Token App->>API: 8. API Request + Access Token API->>App: 9. Protected Resource

🪪OpenID Connect (OIDC)

OAuth2 tells you "this app is allowed to do X", but it doesn't tell you "who this user is." That's a problem if you need to greet users by name or show them their profile. OpenID Connect (OIDC) adds an identity layer on top of OAuth2—it answers the question "Who just logged in?" with a standardized ID Token. Think of OAuth2 as the bouncer checking your ticket, and OIDC as the concierge who knows your name and preferences.

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",
  ...
}
🧪 Try It: Fetch the Discovery Document Interactive

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.

💡 What to Look For
  • token_endpoint — Where your app exchanges credentials for tokens
  • jwks_uri — Where to find public keys for token validation
  • scopes_supported — What permissions you can request
  • grant_types_supported — Which OAuth flows are enabled

🎟️Token Types

If the IdP is a nightclub, tokens are the wristbands. But not all wristbands are equal—some let you into the VIP area, some are only valid for one hour, and some prove you're actually John from accounting. Understanding when to use each token type is crucial. Use the wrong one, and you'll either get denied at the door or accidentally expose sensitive data.
Token Types Overview
flowchart LR subgraph Tokens["🎟️ Token Types"] AT["Access Token
━━━━━━━━━━
• 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
⚠️ Important

Never send ID tokens to APIs! Use access tokens for API calls. ID tokens are for the client to verify user identity.

🎟️ Try It: Get a Token (Client Credentials) Interactive

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.

📋 Understanding the Response
  • access_token — The JWT you'll send to APIs (copy this for the next exercise!)
  • token_type — Always "Bearer" for OAuth2
  • expires_in — Seconds until this token expires
  • scope — The permissions granted (may differ from what you requested)

📝JWT Structure

A JWT looks like gibberish at first glance: 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:

JWT Structure
flowchart LR subgraph JWT["eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"] H["🔷 Header
━━━━━━━
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.

💡 Debugging Tip

Use jwt.io to decode and inspect JWT tokens during development. Never paste production tokens on public sites!

🔑 Try It: Fetch the Public Keys (JWKS) Interactive

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.

🔍 Key Fields Explained
  • kid — Key ID that matches the JWT header's kid
  • kty — Key type (RSA or EC)
  • use — "sig" for signature verification
  • n and e — The RSA public key components (modulus and exponent)
  • alg — Algorithm (RS256 = RSA with SHA-256)
🔬 Try It: Introspect a Token Interactive

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.

⚠️ Note

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

Now that you understand tokens conceptually, let's see where they're born. The EmpowerNow IdP isn't a monolithic black box—it's a FastAPI application with distinct layers for APIs, authentication logic, and storage. Understanding this architecture helps you know where to look when something goes wrong: Is the token endpoint failing? Check the OIDC API layer. Session mysteriously disappearing? Look at Redis.

The EmpowerNow IdP is built with FastAPI and follows a modular architecture:

IdP Service Architecture
flowchart TB subgraph IdP["IdP Service"] subgraph APIs["API Layer"] OIDC["🔐 OIDC API
/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

Let's follow a token through its entire lifecycle. A service needs to call an API. It can't just knock on the door—it needs proof of identity. Watch as the token is born, travels across the network, gets validated, and eventually expires. Understanding this journey is the key to debugging any "401 Unauthorized" error you'll ever encounter.

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:

The Complete Token Journey
sequenceDiagram participant Scheduler as 📅 Scheduler Service participant IdP as 🔐 Identity Provider participant DB as 🐘 PostgreSQL participant Redis as ⚡ Redis Cache participant API as 📆 Calendar API participant JWKS as 🔑 JWKS Endpoint Note over Scheduler,JWKS: Phase 1: Token Request Scheduler->>IdP: POST /token
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:

  1. Client Lookup — Query the oauth_clients table (the hot-core we discussed)
  2. Secret Verification — Hash the provided secret with PBKDF2-SHA256 and compare
  3. Scope Validation — Ensure the client is allowed to request these scopes
  4. JWT Generation — Create the token with claims (sub, iss, aud, exp, scope)
  5. Signing — Sign with the private key (RSA-SHA256)
⏱️ Performance Note

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:

  1. Extract Token — Parse the Authorization: Bearer header
  2. Decode Header — Base64-decode the first segment to find the kid
  3. Fetch JWKS — Get public keys from the IdP (cached for hours)
  4. Verify Signature — Use the matching public key to verify the signature
  5. Validate Claims — Check exp (not expired), iss (correct issuer), aud (intended for this API)
  6. Check Scopes — Ensure the token has permission for this operation
✅ Why This Matters

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 exp claim, token needs refresh
  • 403 at API → Token is valid but missing required scope

🌐Identity Federation

What happens when your enterprise customer says "We use Azure AD—our employees shouldn't need another password"? You could ask them to create local accounts in EmpowerNow... but that defeats the purpose of Single Sign-On (SSO). Instead, EmpowerNow supports identity federation—users authenticate with their corporate Identity Provider, and we trust those credentials. No new passwords, no account duplication, and IT admins can disable access instantly when someone leaves the company.

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

AspectLocal AuthenticationFederated Authentication
Credentials storedIn EmpowerNow IdP databaseIn external IdP (Azure AD, Okta)
Password policyEmpowerNow controlsExternal IdP controls
MFAEmpowerNow WebAuthn/TOTPExternal IdP (Authenticator app, etc.)
User provisioningCreate account in EmpowerNowNo local account required*
Token issuerEmpowerNow IdPEmpowerNow 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:

Federation Authentication Flow
sequenceDiagram participant User as User Browser participant BFF as BFF participant IdP as EmpowerNow IdP participant Entra as Azure Entra ID participant PDP as PDP User->>BFF: 1. Click "Sign in with Azure AD" BFF->>IdP: 2. Start OIDC flow (PKCE) IdP-->>User: 3. Redirect to Azure /authorize User->>Entra: 4. Authenticate (password + MFA) Entra-->>User: 5. Redirect with auth code User->>IdP: 6. Callback with code IdP->>Entra: 7. Exchange code for tokens Entra-->>IdP: 8. Azure tokens (id_token, access_token) IdP->>IdP: 9. Validate via JWKS, map claims IdP->>IdP: 10. Mint EmpowerNow tokens (ARN sub) IdP-->>BFF: 11. Session established BFF-->>User: 12. Logged in! User->>PDP: 13. Access resource PDP->>PDP: 14. Evaluate policy against ARN PDP-->>User: 15. PERMIT/DENY

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
💡 Tokens Never Reach the Browser

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.contoso vs entra.fabrikam prevents 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:

  1. Fetch JWKS from Azure (cached for ~24 hours)
  2. Find the key matching the JWT's kid (key ID) header
  3. Verify the signature using the public key
  4. Validate iss (issuer), aud (audience), exp (expiry)
✅ Why JWKS Over Introspection?

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 ClaimEmpowerNow ClaimNotes
oidsub (in ARN)Stable Object ID—never changes
preferred_usernameemailUsually the UPN
namenameDisplay name
rolesrolesApp roles assigned in Azure
groupsgroupsGroup memberships (if configured)
scppermissionsDelegated 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
}
🔗 Deep Dive Available

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

Here's a design decision that will make you appreciate good architecture. Every single token request hits the database to validate the client. If that query is slow, every login in your entire platform feels sluggish. The solution? Split the data into two tables: one tiny, heavily-cached "core" table for the hot path, and one flexible "extension" table for everything else. This is real-world performance engineering.

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.

Hot-Core + Extension Split
flowchart LR subgraph Core["🔥 oauth_clients (CORE)"] C1["client_id"] C2["secret_hash"] C3["redirect_uris"] C4["grant_types"] C5["scopes"] C6["token_endpoint_auth"] end subgraph Ext["📦 oauth_client_ext (EXTENSION)"] E1["client_id (FK)"] E2["config (JSONB)
• 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?

ConcernCore TableExtension Table
PurposeToken endpoint hot pathAdmin configuration & metadata
Query PatternEvery /token requestAdmin reads/writes only
Schema ChangesRequires migrationJust update JSONB, no migration
Cache ImpactMust be cachedUpdates 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
💡 Token Endpoint Query

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

Sometimes you need a fresh start—maybe your database got corrupted during development, or you want to test the migration process itself. The IdP uses Alembic for database migrations, and the good news is: migrations run automatically when you start the core profile. But knowing how to reset manually will save you hours of frustration when "it works on my machine" becomes "why is my schema different?"

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;"
⚠️ Existing Schema Conflict

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

Theory is great, but nothing beats seeing real data. When a token request fails with "invalid client," you want to look directly at the database and see what's actually stored. Is the client_id misspelled? Is the secret_hash missing? pgAdmin gives you a visual way to explore the IdP's PostgreSQL database—think of it as a microscope for your identity data.

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

SettingValue
URLhttps://pgadmin.self.empowernow.ai or http://localhost:5050
Emailadmin@empowernow.ai
PasswordEmpowerNow2024!

Add Server Connection

Right-click "Servers" → "Register" → "Server":

FieldValue
NameEmpowerNow
Hostdb
Port5432
Databaseidp_db
Usernamepostgres
Passwordempowernow

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

Your new microservice needs to call the API. Your test user needs to exist before the demo. In production, you'd use Dynamic Client Registration (DCR)—but in development, the fastest path is editing the seed migration. This gives you repeatable, version-controlled identity data that every developer on your team can use.

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)
"
💡 Pre-defined Hashes

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

Theory without practice is like reading about swimming without getting in the pool. These four labs will cement your understanding through real commands and real data. Take your time—the goal isn't speed, it's understanding.
LAB 1 Explore OIDC Discovery ⏱️ 5 minutes

The OIDC Discovery document is like a restaurant menu—it tells clients everything the IdP offers. Let's explore it.

1

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.

2

Find the token endpoint URL:

curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq -r '.token_endpoint'
3

List all supported scopes:

curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq '.scopes_supported'
4

Find which grant types are enabled:

curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/openid-configuration | jq '.grant_types_supported'
✅ Expected Output

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.

🤔 Self-Check
  • What's the difference between token_endpoint and authorization_endpoint?
  • Why is offline_access listed as a scope?
LAB 2 The Complete Token Flow ⏱️ 10 minutes

Let's get a real token, decode it piece by piece, and verify it manually.

1

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

Extract just the access token:

ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
echo "Token: ${ACCESS_TOKEN:0:50}..."
3

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}..."
4

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

5

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.

6

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
✅ Expected Output

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.

🤔 Self-Check
  • What does the kid in the header tell you?
  • How would you verify this token hasn't been tampered with?
  • Why is iat (issued at) useful for debugging?
LAB 3 Database Inspection ⏱️ 10 minutes

Let's look at the actual data in PostgreSQL to understand what gets stored and why.

1

Connect to the PostgreSQL database:

docker exec -it empowernow-db-1 psql -U postgres -d idp_db
2

List all tables in the IdP schema:

\dt idp.*

You should see tables like oauth_clients, oauth_client_ext, accounts, etc.

3

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.

4

List all registered OAuth clients:

SELECT client_id, client_type, grant_types 
FROM idp.oauth_clients;
5

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';
6

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';
7

Check the user accounts:

SELECT sub, name, email, status 
FROM idp.accounts LIMIT 5;
8

Exit psql:

\q
✅ Expected Output

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.

💡 Prefer a GUI? Use pgAdmin

If you prefer visual tools over the command line, pgAdmin provides a web-based interface for database exploration:

  1. Start pgAdmin: docker compose --profile tools up -d pgadmin
  2. Open https://pgadmin.self.empowernow.ai (or http://localhost:5050)
  3. Login: admin@empowernow.ai / EmpowerNow2024!
  4. 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.

🤔 Self-Check
  • 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?
LAB 4 JWKS and Key Matching ⏱️ 5 minutes

Let's connect the dots—match the kid from a token to the actual public key in JWKS.

1

Fetch the JWKS (public keys):

curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json | jq .
2

List all key IDs:

curl -s https://idp.self.empowernow.ai/api/oidc/.well-known/jwks.json | jq '.keys[].kid'
3

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"
4

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!

✅ Expected Output

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.

🤔 Self-Check
  • Why might there be multiple keys in JWKS?
  • What happens if an API receives a token with a kid that's not in JWKS?
  • How often should APIs cache the JWKS?

🔧Troubleshooting: When Tokens Go Wrong

You've learned how tokens work when everything goes right. But real life is messier. Here are three scenarios you'll definitely encounter—learn to diagnose them now, and you'll save hours of frustration later.
🚫 The Invalid Token Mystery Case #1
📋 Symptom

The API returns 401 Unauthorized with the message {"error": "invalid_token"}. The token was working fine yesterday!

🔍 Investigation

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
💡 Common Culprits
  • 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
✅ Fix
  • 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 Forbidden Scope Case #2
📋 Symptom

The API returns 403 Forbidden even though the token is valid. You can access some endpoints but not others.

🔍 Investigation

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

💡 Common Culprits
  • 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
✅ Fix
  1. Update your token request to include the missing scope
  2. If using client credentials, update the client's allowed scopes in the database
  3. 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
🔄 The Key ID Nightmare Case #3
📋 Symptom

Token validation fails intermittently. Some requests work, others fail with "invalid signature." The problem started after a deployment or at midnight.

🔍 Investigation

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!
💡 Common Culprits
  • 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
✅ Fix

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 kid lookup failure
Key Rotation Timeline
sequenceDiagram participant Client participant IdP participant API Note over IdP: T=0: Rotate keys
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

Before you move on, test your understanding. Click each question to reveal the answer. Don't worry if you don't get them all right—that just means you know what to review!
1 What is the key difference between OAuth2 and OpenID Connect (OIDC)?

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

2 When would you use the client_credentials grant type vs authorization_code?

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.

3 Why should you never send an ID Token to an API?

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
4 What do the iss, sub, and aud claims in a JWT represent?
  • 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?"

5 How does an API validate a JWT without calling the IdP?

JWTs are self-contained—all the information needed to validate them is either in the token itself or in a public endpoint:

  1. Fetch JWKS: The API fetches public keys from the IdP's JWKS endpoint (and caches them)
  2. Match kid: Look at the token header's kid claim and find the matching key in JWKS
  3. Verify signature: Use the public key to verify the token's cryptographic signature
  4. 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.

6 Why does the IdP use a "Hot-Core + Extension" database design?

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.

7 What would cause a token to suddenly fail validation after a deployment?

The most common post-deployment token failures:

  1. 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 kid in the token won't match any cached key.
  2. Issuer URL change: If the IdP's URL changed (e.g., from staging to production), the iss claim won't match what the API expects.
  3. Clock skew: If server times drifted, tokens might appear expired (exp) or not yet valid (nbf).
  4. Configuration mismatch: Environment variables pointing to wrong IdP endpoints.

Quick debug steps:

  • Decode the failing token and check all claims
  • Compare kid against 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