This guide covers implementing OpenID Connect (OIDC) authentication flows with Zitadel, including authorization requests, token handling, and callback processing.
Prerequisites
- Zitadel Ruby SDK installed and configured
- An OIDC application created in Zitadel
- Understanding of OAuth2/OIDC concepts
- Required permissions:
session.read, session.link
OIDC Flow Overview
Zitadel supports standard OIDC flows:
- Authorization Code Flow: For web applications (most secure)
- Authorization Code + PKCE: For SPAs and mobile apps
- Device Authorization: For devices with limited input capabilities
Working with Auth Requests
Get Auth Request Details
When your application redirects users to Zitadel for authentication, they are redirected back with an auth request ID. You can retrieve the details:
require 'zitadel-client'
client = Zitadel::Client::Zitadel.with_access_token(
"https://example.zitadel.cloud",
"your_access_token"
)
# Get auth request details
request = Zitadel::Client::OIDCServiceGetAuthRequestRequest.new(
auth_request_id: "auth_request_id_from_redirect"
)
begin
response = client.oidc.get_auth_request(request)
puts "Auth Request Details:"
puts " Client ID: #{response.auth_request.client_id}"
puts " Redirect URI: #{response.auth_request.redirect_uri}"
puts " Scopes: #{response.auth_request.scope.join(', ')}"
puts " Prompt: #{response.auth_request.prompt}"
if response.auth_request.login_hint
puts " Login Hint: #{response.auth_request.login_hint}"
end
rescue Zitadel::Client::ApiError => e
puts "Error: #{e.message}"
end
Auth requests contain the parameters from the original OAuth2/OIDC authorization request made by your application.
Creating Callbacks
Complete Auth Request and Get Callback URL
After authenticating the user (via session), create a callback to finalize the OIDC flow:
# First, create or retrieve a session for the user
session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
checks: {
user: {
login_name: "[email protected]"
},
password: {
password: "user_password"
}
},
lifetime: "3600s"
)
session_response = client.sessions.create_session(session_request)
session_id = session_response.session_id
session_token = session_response.session_token
# Create callback for the auth request
callback_request = Zitadel::Client::OIDCServiceCreateCallbackRequest.new(
auth_request_id: "auth_request_id_from_redirect",
session: {
session_id: session_id,
session_token: session_token
}
)
callback_response = client.oidc.create_callback(callback_request)
puts "Callback URL: #{callback_response.callback_url}"
puts "Redirect user to this URL to complete authentication"
# The callback URL contains the authorization code
# User's browser should be redirected to this URL
# Example: https://myapp.com/callback?code=xyz&state=abc
Your app redirects to Zitadel’s authorization endpoint with client_id, redirect_uri, scope, etc.
Zitadel redirects to your app
Zitadel redirects back with an auth_request_id parameter.
Use get_auth_request to retrieve the authentication parameters.
Create a session with user credentials (or use existing session).
Use create_callback linking the auth request with the session.
Redirect the user’s browser to the callback URL.
Your app exchanges the authorization code for access/ID tokens via standard OAuth2 token endpoint.
Device Authorization Flow
Get Device Authorization Request
For devices with limited input (like smart TVs), use the device authorization flow:
# User enters a code on their device
# Device displays: "Go to https://example.zitadel.cloud/device"
# "Enter code: ABCD-EFGH"
# On your backend, retrieve the device auth request
request = Zitadel::Client::OIDCServiceGetDeviceAuthorizationRequestRequest.new(
user_code: "ABCD-EFGH" # Code entered by user
)
begin
response = client.oidc.get_device_authorization_request(request)
puts "Device Authorization Request:"
puts " Client ID: #{response.client_id}"
puts " Scopes: #{response.scope.join(', ')}"
puts " Device Authorization ID: #{response.device_authorization_id}"
rescue Zitadel::Client::ApiError => e
if e.code == 404
puts "Invalid user code"
else
puts "Error: #{e.message}"
end
end
Authorize or Deny Device Authorization
After the user authenticates on a separate device:
# Authorize the device request
authorize_request = Zitadel::Client::OIDCServiceAuthorizeOrDenyDeviceAuthorizationRequest.new(
device_authorization_id: "device_auth_id_from_previous_step",
session: {
session_id: "user_session_id",
session_token: "user_session_token"
},
authorized: true # Set to false to deny
)
begin
client.oidc.authorize_or_deny_device_authorization(authorize_request)
puts "Device authorized successfully"
# The device can now poll the token endpoint to get tokens
rescue Zitadel::Client::ApiError => e
puts "Authorization failed: #{e.message}"
end
After authorization, the device polls the token endpoint until it receives tokens or the request expires.
Complete OIDC Integration Example
Web Application Integration
require 'zitadel-client'
require 'sinatra'
require 'securerandom'
# Initialize Zitadel client
client = Zitadel::Client::Zitadel.with_client_credentials(
"https://example.zitadel.cloud",
"your_client_id",
"your_client_secret"
)
# Store in session or database
SESSION_STORE = {}
# Initiate OIDC login
get '/login' do
state = SecureRandom.hex(16)
nonce = SecureRandom.hex(16)
# Store state for CSRF protection
SESSION_STORE[state] = { nonce: nonce, created_at: Time.now }
# Redirect to Zitadel authorization endpoint
auth_params = {
client_id: ENV['ZITADEL_CLIENT_ID'],
redirect_uri: 'https://myapp.com/callback',
response_type: 'code',
scope: 'openid email profile',
state: state,
nonce: nonce
}
auth_url = "https://example.zitadel.cloud/oauth/v2/authorize?#{URI.encode_www_form(auth_params)}"
redirect auth_url
end
# Handle callback
get '/callback' do
auth_request_id = params['authRequest']
state = params['state']
# Verify state (CSRF protection)
unless SESSION_STORE[state]
halt 400, "Invalid state parameter"
end
begin
# Get auth request details
auth_request = Zitadel::Client::OIDCServiceGetAuthRequestRequest.new(
auth_request_id: auth_request_id
)
auth_response = client.oidc.get_auth_request(auth_request)
# In a real app, you would:
# 1. Show login form
# 2. Verify credentials
# 3. Create session
# For this example, assume user authenticated
session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
checks: {
user: {
login_name: params['username'] # From login form
},
password: {
password: params['password'] # From login form
}
},
lifetime: "3600s"
)
session_response = client.sessions.create_session(session_request)
# Create callback
callback_request = Zitadel::Client::OIDCServiceCreateCallbackRequest.new(
auth_request_id: auth_request_id,
session: {
session_id: session_response.session_id,
session_token: session_response.session_token
}
)
callback_response = client.oidc.create_callback(callback_request)
# Redirect to callback URL (contains authorization code)
redirect callback_response.callback_url
rescue Zitadel::Client::ApiError => e
halt 500, "Authentication failed: #{e.message}"
end
end
# After your app receives the code at its redirect_uri,
# exchange it for tokens using standard OAuth2 token endpoint
post '/token-exchange' do
require 'net/http'
require 'json'
code = params['code']
uri = URI('https://example.zitadel.cloud/oauth/v2/token')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV['ZITADEL_CLIENT_ID'], ENV['ZITADEL_CLIENT_SECRET'])
request.set_form_data(
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://myapp.com/callback'
)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
if response.is_a?(Net::HTTPSuccess)
tokens = JSON.parse(response.body)
# Store tokens securely
# tokens['access_token']
# tokens['id_token']
# tokens['refresh_token']
{ success: true, tokens: tokens }.to_json
else
halt 500, "Token exchange failed: #{response.body}"
end
end
Device Flow Integration
require 'zitadel-client'
require 'net/http'
require 'json'
client = Zitadel::Client::Zitadel.with_access_token(
"https://example.zitadel.cloud",
"your_access_token"
)
class DeviceFlow
def self.initiate_device_flow(client_id)
# Device makes request to device authorization endpoint
uri = URI('https://example.zitadel.cloud/oauth/v2/device_authorization')
request = Net::HTTP::Post.new(uri)
request.set_form_data(
client_id: client_id,
scope: 'openid email profile'
)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
if response.is_a?(Net::HTTPSuccess)
data = JSON.parse(response.body)
puts "Device Code Flow Initiated:"
puts " Visit: #{data['verification_uri']}"
puts " Enter code: #{data['user_code']}"
puts " Expires in: #{data['expires_in']} seconds"
{
device_code: data['device_code'],
user_code: data['user_code'],
verification_uri: data['verification_uri'],
interval: data['interval'] || 5
}
else
{ error: "Failed to initiate device flow: #{response.body}" }
end
end
def self.authorize_device(client, user_code, username, password)
# User visits verification URI and enters code
# Backend retrieves device authorization
get_request = Zitadel::Client::OIDCServiceGetDeviceAuthorizationRequestRequest.new(
user_code: user_code
)
device_auth = client.oidc.get_device_authorization_request(get_request)
# Create session for user
session_request = Zitadel::Client::SessionServiceCreateSessionRequest.new(
checks: {
user: { login_name: username },
password: { password: password }
},
lifetime: "3600s"
)
session_response = client.sessions.create_session(session_request)
# Authorize the device
authorize_request = Zitadel::Client::OIDCServiceAuthorizeOrDenyDeviceAuthorizationRequest.new(
device_authorization_id: device_auth.device_authorization_id,
session: {
session_id: session_response.session_id,
session_token: session_response.session_token
},
authorized: true
)
client.oidc.authorize_or_deny_device_authorization(authorize_request)
{ success: true, message: "Device authorized" }
end
def self.poll_for_token(client_id, device_code, interval)
# Device polls token endpoint
uri = URI('https://example.zitadel.cloud/oauth/v2/token')
loop do
request = Net::HTTP::Post.new(uri)
request.set_form_data(
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device_code,
client_id: client_id
)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
if response.is_a?(Net::HTTPSuccess)
tokens = JSON.parse(response.body)
return { success: true, tokens: tokens }
end
error = JSON.parse(response.body)
case error['error']
when 'authorization_pending'
# User hasn't authorized yet, keep polling
sleep interval
when 'slow_down'
# Increase polling interval
interval += 5
sleep interval
else
# Error occurred
return { success: false, error: error['error'] }
end
end
end
end
# Usage
device_data = DeviceFlow.initiate_device_flow(ENV['ZITADEL_CLIENT_ID'])
if device_data[:error]
puts device_data[:error]
else
# Display to user
puts "Go to #{device_data[:verification_uri]}"
puts "Enter code: #{device_data[:user_code]}"
# Meanwhile, user authorizes on another device:
# DeviceFlow.authorize_device(client, device_data[:user_code], '[email protected]', 'password')
# Device polls for tokens
result = DeviceFlow.poll_for_token(
ENV['ZITADEL_CLIENT_ID'],
device_data[:device_code],
device_data[:interval]
)
if result[:success]
puts "Tokens received:"
puts " Access Token: #{result[:tokens]['access_token']}"
puts " ID Token: #{result[:tokens]['id_token']}"
else
puts "Error: #{result[:error]}"
end
end
Token Management
Validating ID Tokens
require 'jwt'
require 'net/http'
require 'json'
def get_jwks(issuer)
# Fetch JWKS from Zitadel
uri = URI("#{issuer}/.well-known/openid-configuration")
response = Net::HTTP.get_response(uri)
config = JSON.parse(response.body)
jwks_uri = URI(config['jwks_uri'])
jwks_response = Net::HTTP.get_response(jwks_uri)
JSON.parse(jwks_response.body)
end
def validate_id_token(id_token, client_id, issuer)
jwks = get_jwks(issuer)
# Decode and verify token
decoded = JWT.decode(
id_token,
nil,
true,
{
algorithms: ['RS256'],
iss: issuer,
aud: client_id,
verify_iss: true,
verify_aud: true,
jwks: jwks
}
)
payload = decoded[0]
{
valid: true,
sub: payload['sub'],
email: payload['email'],
name: payload['name']
}
rescue JWT::DecodeError => e
{ valid: false, error: e.message }
end
# Usage
result = validate_id_token(
id_token,
ENV['ZITADEL_CLIENT_ID'],
'https://example.zitadel.cloud'
)
if result[:valid]
puts "User: #{result[:name]} (#{result[:email]})"
else
puts "Invalid token: #{result[:error]}"
end
Best Practices
Use PKCE for public clients
Always use PKCE for SPAs and mobile apps to prevent authorization code interception.
Always validate the state parameter to prevent CSRF attacks.
Validate ID token signatures and claims before trusting the token.
Use short-lived access tokens
Configure short lifetimes for access tokens and use refresh tokens for long-lived sessions.
Store tokens securely (HttpOnly cookies for web, secure storage for mobile).
Implement proper error handling for all OIDC flows.
Next Steps