Identity Provider (IdP) - Hands-on
Create OIDC clients, perform OAuth2 flows with curl/Postman, and introspect tokens. This is where theory becomes muscle memory.
📋Learning Objectives
- Create an OIDC client via the Admin UI
- Perform Authorization Code flow
- Perform Client Credentials flow
- Introspect and validate tokens
🗺️OAuth Flows: The Big Picture
Flow 1: Client Credentials (Machine-to-Machine)
The simplest flow—no user involved. Your service proves its identity and gets a token.
client_id + client_secret
grant_type=client_credentials IdP->>IdP: Validate client credentials IdP-->>Service: Access Token (JWT) Service->>API: GET /api/data
Authorization: Bearer [token] API-->>Service: 200 OK + Data
- Background jobs and cron tasks
- Service-to-service communication
- APIs calling other APIs
- Any scenario without a human user
Flow 2: Authorization Code (User Login)
The standard flow for user-facing apps. Involves browser redirects and user consent.
response_type=code IdP->>User: Show Login Page User->>IdP: Enter credentials IdP->>User: Show Consent Screen User->>IdP: Grant permissions IdP->>App: Redirect with code App->>IdP: POST /token
code + client_secret IdP-->>App: Access Token + ID Token + Refresh Token App->>API: GET /api/data
Authorization: Bearer [token] API-->>App: 200 OK + Data App->>User: Show data
- Web applications with user login
- Mobile apps (with PKCE)
- Any scenario where a human user is present
- When you need to act on behalf of a user
Key Differences at a Glance
Client Credentials
- Single HTTP request
- No browser involved
- No user consent
- Access token only
- Token represents the client
Authorization Code
- Multiple HTTP requests
- Browser redirects required
- User consent screen
- Access + ID + Refresh tokens
- Token represents the user
🔧Exercise 1: Create an OIDC Client
Option A: Using the Experience App (OAuth Client Center)
The Experience App provides a user-friendly interface for managing OAuth clients:
Creating a New Client
- Open https://experience.self.empowernow.ai
- Log in with your credentials
- Navigate to Identity → OAuth Client Center in the navigation menu
- Click Register New Client
- Fill in the registration form:
- Client Name: Training Test Client
- Application Type: Confidential (for server-side apps)
- Redirect URIs: http://localhost:3000/callback
- Grant Types: authorization_code, client_credentials, refresh_token
- Scopes: openid, profile, email
- Click Register and copy the
client_idandclient_secret
Editing an Existing Client
The same form UI is used for editing existing clients:
- From the OAuth Client Center, click on any client to open its detail view
- You'll see the full client configuration including:
- CLIENT ID - Copy button for easy use in your application
- Client configuration JSON - Shows grant types, scopes, redirect URIs, etc.
- Modify any settings using the same form interface
- Use the action buttons:
- Export - Download client configuration
- Clone - Create a copy of this client
- Rotate Secret - Generate a new client secret
- Delete - Remove the client
- Save Changes - Persist your modifications
You can navigate directly to a client's edit page using: https://experience.self.empowernow.ai/idp/clients-center/{client_id}
Option B: Using Postman (Recommended for API Testing)
Postman is excellent for testing OAuth flows. You can register clients directly via the DCR (Dynamic Client Registration) API:
Install the EmpowerNow OAuth Client Postman collection from the team shared workspace. It includes pre-configured requests for client registration, token flows, and introspection.
- Open Postman and create a new POST request
- Set the URL to:
https://idp.self.empowernow.ai/api/oidc/register - Set Content-Type header to
application/json - Add this JSON body:
{
"client_name": "Training Test Client",
"application_type": "web",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "client_credentials", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile email",
"token_endpoint_auth_method": "client_secret_post"
}
Click Send. The response contains your new client credentials:
{
"client_id": "generated-client-id",
"client_secret": "generated-secret",
"client_name": "Training Test Client",
"registration_access_token": "...",
...
}
Option C: Use Pre-seeded Clients (Quick Start)
For training purposes, you can use clients already seeded in the database:
Confidential Client (for client_credentials):
client_id:bff-clientclient_secret:secret1
Public Client (for authorization_code):
client_id:idp-ui-client- No secret required (public client)
If you register a new client, copy the client_secret immediately! It's only shown once during registration. Store it securely for the exercises below.
🔄Exercise 2: Client Credentials Flow
This flow is for machine-to-machine authentication (no user involved):
# Replace with your client_id and client_secret
CLIENT_ID="your-client-id"
CLIENT_SECRET="your-client-secret"
# Request access token
curl -k -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=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=openid"
Expected Response
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid"
}
$body = @{
grant_type = "client_credentials"
client_id = "your-client-id"
client_secret = "your-client-secret"
scope = "openid"
}
Invoke-RestMethod -Uri "https://idp.self.empowernow.ai/api/oidc/token" `
-Method POST -Body $body -SkipCertificateCheck
Copy curl and run in your terminal to execute the client credentials flow using the pre-seeded bff-client. This will get you a real access token!
A JSON response with access_token, token_type, expires_in, and scope. Copy the access_token value—you'll need it for the introspection exercise!
👤Exercise 3: Authorization Code Flow
This flow involves user authentication. It's a multi-step process:
Step 1: Generate Authorization URL
# Build authorization URL
CLIENT_ID="your-client-id"
REDIRECT_URI="http://localhost:3000/callback"
STATE="random-state-string"
NONCE="random-nonce-string"
# Open this URL in browser
echo "https://idp.self.empowernow.ai/api/oidc/authorize?\
response_type=code&\
client_id=$CLIENT_ID&\
redirect_uri=$REDIRECT_URI&\
scope=openid%20profile%20email&\
state=$STATE&\
nonce=$NONCE"
Step 2: User Logs In
- Open the URL in browser
- Log in with test credentials
- Consent to requested scopes
- Browser redirects to
redirect_uriwithcodeparameter
Step 3: Exchange Code for Tokens
# Extract code from redirect URL query params
CODE="authorization-code-from-redirect"
# Exchange code for tokens
curl -k -X POST https://idp.self.empowernow.ai/api/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "code=$CODE" \
-d "redirect_uri=$REDIRECT_URI"
Response with All Token Types
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJl...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email"
}
🔍Exercise 4: Token Introspection
Introspection allows you to verify if a token is valid and get its claims:
# Introspect an access token
ACCESS_TOKEN="your-access-token"
curl -k -X POST https://idp.self.empowernow.ai/api/oidc/token/introspect \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=$ACCESS_TOKEN" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
Active Token Response
{
"active": true,
"sub": "user-uuid-123",
"client_id": "your-client-id",
"scope": "openid profile email",
"exp": 1699900000,
"iat": 1699896400,
"iss": "https://idp.self.empowernow.ai"
}
Expired/Invalid Token Response
{
"active": false
}
Copy curl and run in your terminal. Replace YOUR_ACCESS_TOKEN in the curl command with the access token from the Client Credentials exercise. The IdP will return the token details.
If the token is valid, you'll see "active": true along with all claims: sub, client_id, scope, exp, iss. If the token is expired or invalid, you'll see "active": false.
🔄Exercise 5: Refresh Tokens
Use a refresh token to get a new access token:
# Use refresh token to get new access token
REFRESH_TOKEN="your-refresh-token"
curl -k -X POST https://idp.self.empowernow.ai/api/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "refresh_token=$REFRESH_TOKEN"
EmpowerNow IdP supports refresh token rotation. Each refresh returns a new refresh token, and the old one is invalidated. This improves security.
👁️Exercise 6: UserInfo Endpoint
Get user profile information using an access token:
# Get user info
ACCESS_TOKEN="your-access-token"
curl -k -X GET https://idp.self.empowernow.ai/api/oidc/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response
{
"sub": "user-uuid-123",
"name": "John Doe",
"email": "john.doe@empowerid.com",
"email_verified": true,
"preferred_username": "johndoe"
}
The UserInfo endpoint requires a token obtained via the Authorization Code flow (with a user login). Client credentials tokens don't have user information because there's no user involved. You'll need to complete Exercise 3 first to test this endpoint.
🔬Hands-on Labs
Let's trace through the entire client credentials flow, from token request to API call.
Request an access token:
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 the access token:
ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
echo "Token: ${ACCESS_TOKEN:0:50}..."
Decode the token header to see the algorithm:
echo $ACCESS_TOKEN | cut -d'.' -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
Decode the payload to see the claims:
echo $ACCESS_TOKEN | cut -d'.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
Introspect the token to verify it's active:
curl -s -X POST https://idp.self.empowernow.ai/api/oidc/token/introspect \
-d "token=$ACCESS_TOKEN" \
-d "client_id=bff-client" \
-d "client_secret=secret1" | jq .
The introspection should show "active": true with claims matching the decoded payload. Note that sub in a client credentials token is typically the client_id, not a user ID.
- What's the difference between decoding and introspecting?
- Why doesn't this token have a
refresh_token? - What happens when this token expires?
This lab walks through the authorization code flow manually, so you understand every step.
Build the authorization URL:
# Set variables (use your client from Exercise 1, or the pre-seeded one)
CLIENT_ID="experience-app"
REDIRECT_URI="https://experience.self.empowernow.ai/oauth/callback"
STATE="my-random-state-$(date +%s)"
NONCE="my-random-nonce-$(date +%s)"
# Build URL
AUTH_URL="https://idp.self.empowernow.ai/api/oidc/authorize?\
response_type=code&\
client_id=$CLIENT_ID&\
redirect_uri=$REDIRECT_URI&\
scope=openid%20profile%20email&\
state=$STATE&\
nonce=$NONCE"
echo "Open this URL in your browser:"
echo $AUTH_URL
Open the URL in browser and log in:
- Use test credentials:
alice/password - Review and accept the consent screen
- Watch the browser redirect to the callback URL
Extract the authorization code from the URL:
After redirect, the URL looks like:
https://experience.self.empowernow.ai/oauth/callback?code=AUTH_CODE_HERE&state=my-random-state-...
Copy the code parameter value.
Exchange the code for tokens:
CODE="paste-your-code-here"
curl -s -X POST https://idp.self.empowernow.ai/api/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=$CLIENT_ID" \
-d "code=$CODE" \
-d "redirect_uri=$REDIRECT_URI" | jq .
You should receive three tokens: access_token, id_token, and refresh_token. The id_token contains user information because a user logged in!
- Why is
stateimportant for security? - What's the difference between this response and client credentials?
- Decode the
id_token—what user information do you see?
Using the refresh token from Lab 2, get a fresh access token.
Use the refresh token from Lab 2:
REFRESH_TOKEN="paste-refresh-token-from-lab-2"
curl -s -X POST https://idp.self.empowernow.ai/api/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=experience-app" \
-d "refresh_token=$REFRESH_TOKEN" | jq .
Compare the tokens:
- Is the new
access_tokendifferent from the original? - Is there a new
refresh_token? (rotation) - What about
expires_in?
A new access token with a fresh expiration. If refresh token rotation is enabled, you'll also get a new refresh token—the old one is now invalid.
🔧Troubleshooting OAuth Flows
Token request returns: {"error": "invalid_client", "error_description": "Client authentication failed"}
- Double-check
client_idspelling (case-sensitive!) - Verify
client_secretis correct (was it rotated?) - Check if using Basic auth vs POST body for credentials
- Ensure client exists: query the database directly
docker exec empowernow-db-1 psql -U postgres -d idp_db \
-c "SELECT client_id FROM idp.oauth_clients WHERE client_id = 'your-client-id';"
- Copy credentials directly from the Admin UI or seed file
- For confidential clients, ensure you're sending the secret
- Check
token_endpoint_auth_methodmatches your request format
Authorization request fails with: {"error": "invalid_request", "error_description": "redirect_uri does not match registered URIs"}
The redirect URI must match exactly—including:
- Protocol:
httpvshttps - Port:
localhost:3000vslocalhost - Path:
/callbackvs/callback/(trailing slash!) - Query parameters must not be present in registered URI
# Check registered URIs
docker exec empowernow-db-1 psql -U postgres -d idp_db \
-c "SELECT redirect_uris FROM idp.oauth_clients WHERE client_id = 'your-client-id';"
- Use URL-encoded redirect_uri in the authorize request
- Register all variations you might use (dev, staging, prod)
- For local development, register
http://localhost:PORT/callback
Token exchange fails with: {"error": "invalid_grant", "error_description": "Authorization code has expired"}
Authorization codes are short-lived (usually 60-300 seconds) and single-use:
- Did you wait too long between authorization and token exchange?
- Did you try to use the code twice? (replay attack protection)
- Is the server time in sync?
- Exchange the code immediately after receiving it
- Never store authorization codes—they're meant to be ephemeral
- If the code expired, restart the authorization flow from the beginning
🌐Federation: Hands-On Labs
Lab: Inspect a Federated Token
In this lab, you'll decode a federated token and identify its unique characteristics.
Step 1: Get a Federated Token (Simulated)
If you don't have Azure AD federation configured, use this example token for analysis:
# Example federated token payload (decoded)
{
"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",
"mapped_at": "2025-01-20T10:30:00Z"
},
"emp_account_arn": "auth:account:entra.contoso:ffe9b9f0-ec04-4b9c-bd48-30fdefd72a5e",
"exp": 1737456600,
"iat": 1737453000
}
Step 2: Identify Federation Markers
Look for these claims that indicate a federated identity:
| Claim | Value Pattern | Meaning |
|---|---|---|
sub | auth:account:entra.*:... | Provider-scoped ARN (not empowernow) |
idp | entra.contoso | External IdP identifier |
idp_sub | UUID or external ID | Original subject from external IdP |
federation | Object with provenance | Tracks original issuer and mapping |
emp_account_arn | Full ARN | Canonical account identifier |
A local user has sub: auth:account:empowernow:alice with no federation claim. A federated user has a provider like entra.contoso in the ARN and includes the federation provenance block.
Step 3: Decode Using jwt.io
- Go to jwt.io
- Paste the complete JWT (header.payload.signature)
- Look at the decoded payload
- Find the
federation.orig_issto see which external IdP authenticated the user
Lab: Trace Federation in Logs
When troubleshooting federation issues, IdP logs contain valuable information.
View Federation Logs
# Filter IdP logs for federation events
docker logs idp-app 2>&1 | grep -i "federation\|external\|jwks"
# Expected log entries for successful federation:
# [INFO] Federation: Validating token from issuer https://login.microsoftonline.com/...
# [INFO] Federation: JWKS cache hit for entra-id
# [INFO] Federation: Token signature valid, mapping claims
# [INFO] Federation: Minted ARN auth:account:entra.contoso:ffe9b9f0...
Common Federation Errors
Log shows: Federation: Signature verification failed - no matching kid
- The JWT's
kid(key ID) doesn't match any key in the cached JWKS - Azure may have rotated its signing keys
- JWKS cache may be stale
- Clear the JWKS cache:
docker exec idp-app curl -X POST localhost:8002/admin/cache/clear/jwks - Verify the JWKS URL is correct in
federation.yaml - Check that the Azure tenant ID matches the configured issuer
Log shows: Federation: Invalid audience - expected api://xxx, got yyy
- The Azure token's
audclaim doesn't match what's configured infederation.yaml - Azure tokens can use either
audorazpfor audience
- Check the Azure app registration's "Application ID URI"
- Update
federation.yamlaudience array to include both formats:audience: ["api://your-client-id", "your-client-id"]
Lab: Test PDP Authorization for Federated Users
PDP policies work the same for local and federated users—they just target different ARN patterns.
# AuthZEN request for a federated user
curl -X POST https://pdp.self.empowernow.ai/access/v1/evaluation \
-H "Content-Type: application/json" \
-d '{
"subject": {
"type": "user",
"id": "auth:account:entra.contoso:ffe9b9f0-ec04-4b9c-bd48-30fdefd72a5e"
},
"action": {
"name": "document.view"
},
"resource": {
"type": "document",
"id": "doc-123"
}
}'
# Example policy that permits all Azure AD users from contoso
# Policy subjects:
# - { type: user, id: "auth:account:entra.contoso:*" }
For step-by-step Azure AD app registration, complete federation.yaml configuration, claims mapping rules, and advanced troubleshooting, see the official EmpowerNow documentation and your identity administrator.
❓Knowledge Check
Client Credentials: Use when there's no user—background jobs, service-to-service calls, scheduled tasks. The token represents the application itself.
Authorization Code: Use when a user is logging in. Involves browser redirects and consent. The token represents the user and can access their data.
The authorization code travels through the browser (in the URL), making it visible in browser history, server logs, and potentially to malicious scripts. Keeping it short-lived (60-300 seconds) and single-use minimizes the window for theft and prevents replay attacks.
The real secrets (access token, refresh token) are exchanged server-to-server via HTTPS POST, never exposed in URLs.
The state parameter prevents CSRF (Cross-Site Request Forgery) attacks. Your app generates a random string, includes it in the authorization request, and verifies it matches when the callback arrives.
Without state validation, an attacker could trick a user into authorizing a malicious app using the victim's session.
JWT Decoding: Local operation—you base64-decode the token to see its claims. Works offline but can't tell you if the token was revoked.
Introspection: Calls the IdP to verify the token. Returns real-time validity (active: true/false) and can detect revocation. Required for opaque tokens or when real-time revocation checks are needed.
Short-lived access tokens limit damage from theft. If an attacker steals an access token, it's only valid for an hour. Long-lived tokens would give attackers extended access.
Refresh tokens are stored securely on the backend, never sent to browsers or included in API calls. They're harder to steal than access tokens that travel with every request.
Client Credentials: Access token only. No refresh token (why would a machine need to refresh silently?) and no ID token (no user to identify).
Authorization Code: Access token, refresh token, and ID token. The ID token contains user claims, and the refresh token enables long sessions without re-prompting for login.
The redirect_uri in your authorization request doesn't exactly match any URI registered for that client. OAuth requires exact matching for security—this prevents attackers from redirecting authorization codes to their own servers.
Check for: protocol mismatch (http vs https), port differences, trailing slashes, typos. The URI must match character-for-character.
📝Day 7 Checkpoint
- Created an OIDC client via Admin UI
- Successfully performed Client Credentials flow
- Completed Authorization Code flow
- Introspected a token and verified it's active
- Refreshed an access token using refresh token
- Retrieved user info from UserInfo endpoint