Files
clinch/app/services/oidc_jwt_service.rb
Dan Milne 12e0ef66ed
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
OIDC app creation with encrypted secrets and application roles
2025-10-24 14:47:24 +11:00

143 lines
4.4 KiB
Ruby

class OidcJwtService
class << self
# Generate an ID token (JWT) for the user
def generate_id_token(user, application, nonce: nil)
now = Time.current.to_i
payload = {
iss: issuer_url,
sub: user.id.to_s,
aud: application.client_id,
exp: now + 3600, # 1 hour
iat: now,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
name: user.email_address
}
# Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present?
# Add groups if user has any
if user.groups.any?
payload[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
payload[:admin] = true if user.admin?
# Add role-based claims if role mapping is enabled
if application.role_mapping_enabled?
add_role_claims!(payload, user, application)
end
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end
# Decode and verify an ID token
def decode_id_token(token)
JWT.decode(token, public_key, true, { algorithm: "RS256" })
end
# Get the public key in JWK format for the JWKS endpoint
def jwks
{
keys: [
{
kty: "RSA",
kid: key_id,
use: "sig",
alg: "RS256",
n: Base64.urlsafe_encode64(public_key.n.to_s(2), padding: false),
e: Base64.urlsafe_encode64(public_key.e.to_s(2), padding: false)
}
]
}
end
# Get the issuer URL (base URL of this OIDC provider)
def issuer_url
# In production, this should come from ENV or config
# For now, we'll use a placeholder that can be overridden
ENV.fetch("CLINCH_HOST", "http://localhost:3000")
end
private
# Get or generate RSA private key
def private_key
@private_key ||= begin
# Try ENV variable first (best for Docker/Kamal)
if ENV["OIDC_PRIVATE_KEY"].present?
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
# Then try Rails credentials
elsif Rails.application.credentials.oidc_private_key.present?
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
else
# Generate a new key for development
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
OpenSSL::PKey::RSA.new(2048)
end
end
end
# Get the corresponding public key
def public_key
@public_key ||= private_key.public_key
end
# Key identifier (fingerprint of the public key)
def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end
# Add role-based claims to the JWT payload
def add_role_claims!(payload, user, application)
user_roles = application.user_roles(user)
return if user_roles.empty?
role_names = user_roles.pluck(:name)
# Filter roles by prefix if configured
if application.role_prefix.present?
role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
end
return if role_names.empty?
# Add roles using the configured claim name
claim_name = application.role_claim_name.presence || 'roles'
payload[claim_name] = role_names
# Add role permissions if configured
managed_permissions = application.parsed_managed_permissions
if managed_permissions['include_permissions'] == true
role_permissions = user_roles.map do |role|
{
name: role.name,
display_name: role.display_name,
permissions: role.permissions
}
end
payload['role_permissions'] = role_permissions
end
# Add role metadata if configured
if managed_permissions['include_metadata'] == true
role_metadata = user_roles.map do |role|
assignment = role.user_role_assignments.find_by(user: user)
{
name: role.name,
source: assignment&.source,
assigned_at: assignment&.created_at
}
end
payload['role_metadata'] = role_metadata
end
end
end
end