Start implementing OIDC
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

This commit is contained in:
Dan Milne
2025-10-23 18:22:52 +11:00
parent 07e87dbaeb
commit d480d7dd0a
3 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token]
skip_before_action :verify_authenticity_token, only: [:token]
# GET /.well-known/openid-configuration
def discovery
base_url = OidcJwtService.issuer_url
config = {
issuer: base_url,
authorization_endpoint: "#{base_url}/oauth/authorize",
token_endpoint: "#{base_url}/oauth/token",
userinfo_endpoint: "#{base_url}/oauth/userinfo",
jwks_uri: "#{base_url}/.well-known/jwks.json",
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
}
render json: config
end
# GET /.well-known/jwks.json
def jwks
render json: OidcJwtService.jwks
end
# GET /oauth/authorize
def authorize
# Get parameters
client_id = params[:client_id]
redirect_uri = params[:redirect_uri]
state = params[:state]
nonce = params[:nonce]
scope = params[:scope] || "openid"
response_type = params[:response_type]
# Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code"
render plain: "Invalid request: missing required parameters", status: :bad_request
return
end
# Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application
render plain: "Invalid client_id", status: :bad_request
return
end
# Validate redirect URI
unless @application.parsed_redirect_uris.include?(redirect_uri)
render plain: "Invalid redirect_uri", status: :bad_request
return
end
# Check if user is authenticated
unless authenticated?
# Store OAuth parameters in session and redirect to sign in
session[:oauth_params] = {
client_id: client_id,
redirect_uri: redirect_uri,
state: state,
nonce: nonce,
scope: scope
}
redirect_to signin_path, alert: "Please sign in to continue"
return
end
# Get the authenticated user
user = Current.session.user
# Check if user is allowed to access this application
unless @application.user_allowed?(user)
render plain: "You do not have permission to access this application", status: :forbidden
return
end
# Store OAuth parameters for consent page
session[:oauth_params] = {
client_id: client_id,
redirect_uri: redirect_uri,
state: state,
nonce: nonce,
scope: scope
}
# Render consent page
@redirect_uri = redirect_uri
@scopes = scope.split(" ")
render :consent
end
# POST /oauth/authorize/consent
def consent
# Get OAuth params from session
oauth_params = session[:oauth_params]
unless oauth_params
redirect_to root_path, alert: "Session expired. Please try again."
return
end
# User denied consent
if params[:deny].present?
session.delete(:oauth_params)
error_uri = "#{oauth_params[:redirect_uri]}?error=access_denied"
error_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
redirect_to error_uri, allow_other_host: true
return
end
# Find the application
application = Application.find_by(client_id: oauth_params[:client_id])
user = Current.session.user
# Generate authorization code
code = SecureRandom.urlsafe_base64(32)
auth_code = OidcAuthorizationCode.create!(
application: application,
user: user,
code: code,
redirect_uri: oauth_params[:redirect_uri],
scope: oauth_params[:scope],
expires_at: 10.minutes.from_now
)
# Store nonce in the authorization code metadata if needed
# For now, we'll pass it through the code itself
# Clear OAuth params from session
session.delete(:oauth_params)
# Redirect back to client with authorization code
redirect_uri = "#{oauth_params[:redirect_uri]}?code=#{code}"
redirect_uri += "&state=#{oauth_params[:state]}" if oauth_params[:state]
redirect_to redirect_uri, allow_other_host: true
end
# POST /oauth/token
def token
grant_type = params[:grant_type]
unless grant_type == "authorization_code"
render json: { error: "unsupported_grant_type" }, status: :bad_request
return
end
# Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials
unless client_id && client_secret
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.client_secret == client_secret
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Get the authorization code
code = params[:code]
redirect_uri = params[:redirect_uri]
auth_code = OidcAuthorizationCode.find_by(
application: application,
code: code,
used: false
)
unless auth_code
render json: { error: "invalid_grant" }, status: :bad_request
return
end
# Check if code is expired
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
return
end
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
return
end
# Mark code as used
auth_code.update!(used: true)
# Get the user
user = auth_code.user
# Generate access token
access_token = SecureRandom.urlsafe_base64(32)
OidcAccessToken.create!(
application: application,
user: user,
token: access_token,
scope: auth_code.scope,
expires_at: 1.hour.from_now
)
# Generate ID token
id_token = OidcJwtService.generate_id_token(user, application)
# Return tokens
render json: {
access_token: access_token,
token_type: "Bearer",
expires_in: 3600,
id_token: id_token,
scope: auth_code.scope
}
end
# GET /oauth/userinfo
def userinfo
# Extract access token from Authorization header
auth_header = request.headers["Authorization"]
unless auth_header&.start_with?("Bearer ")
head :unauthorized
return
end
access_token = auth_header.sub("Bearer ", "")
# Find the access token
token_record = OidcAccessToken.find_by(token: access_token)
unless token_record
head :unauthorized
return
end
# Check if token is expired
if token_record.expires_at < Time.current
head :unauthorized
return
end
# Get the user
user = token_record.user
# Return user claims
claims = {
sub: user.id.to_s,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
name: user.email_address
}
# Add groups if user has any
if user.groups.any?
claims[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
claims[:admin] = true if user.admin?
render json: claims
end
private
def extract_client_credentials
# Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ")
encoded = request.headers["Authorization"].sub("Basic ", "")
decoded = Base64.decode64(encoded)
decoded.split(":", 2)
else
# Fall back to POST parameters
[params[:client_id], params[:client_secret]]
end
end
end

View File

@@ -0,0 +1,90 @@
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?
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 to load from Rails credentials first
key_pem = Rails.application.credentials.oidc_private_key
if key_pem.present?
OpenSSL::PKey::RSA.new(key_pem)
else
# Generate a new key for development
# In production, you should generate this once and store in credentials
Rails.logger.warn "OIDC: No private key found in credentials, generating new key (development only)"
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
end
end

View File

@@ -0,0 +1,71 @@
<div class="mx-auto max-w-md">
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
<p class="mt-2 text-sm text-gray-600">
<strong><%= @application.name %></strong> is requesting access to your account.
</p>
</div>
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-900 mb-3">This application will be able to:</h3>
<ul class="space-y-2">
<% if @scopes.include?("openid") %>
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Verify your identity</span>
</li>
<% end %>
<% if @scopes.include?("email") %>
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your email address (<%= Current.session.user.email_address %>)</span>
</li>
<% end %>
<% if @scopes.include?("profile") %>
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your profile information</span>
</li>
<% end %>
<% if @scopes.include?("groups") %>
<li class="flex items-start">
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="text-sm text-gray-700">Access your group memberships</span>
</li>
<% end %>
</ul>
</div>
<div class="rounded-md bg-blue-50 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-blue-700">
<p>You'll be redirected to:</p>
<p class="mt-1 font-mono text-xs break-all"><%= @redirect_uri %></p>
</div>
</div>
</div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3" do |form| %>
<%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<%= button_tag "Deny",
type: :submit,
name: :deny,
value: "1",
class: "w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<% end %>
</div>
</div>