Adds three OAuth surfaces to the OIDC provider so CLIs, terminal agents, and MCP connectors can authenticate as real users instead of using static API keys. Device Authorization Grant (RFC 8628): - OidcDeviceCode model (HMAC device_code, short plaintext user_code, nullable user until approval, slow_down polling state), mirroring OidcAuthorizationCode - POST /oauth/device_authorization issues the code pair + verification URIs - device_code grant on /oauth/token returns authorization_pending / slow_down / access_denied / expired_token, then the standard token triple; single-use - Authenticated /device approval page, gated by Application#user_allowed? Token Introspection (RFC 7662): - POST /oauth/introspect: confidential-caller-authenticated; returns active, scope, and the user's groups so resource servers can authorize on membership Dynamic Client Registration (RFC 7591): - POST /oauth/register creates public/confidential clients (PKCE required) - Runtime toggle via a new Setting store + admin switch on the Applications page; off by default, env var CLINCH_DCR_ENABLED as bootstrap fallback - New clients are default-deny (no allowed_groups) until an admin grants access - RFC 8414 metadata alias at /.well-known/oauth-authorization-server; registration_endpoint advertised only while the window is open Discovery advertises all three grants/endpoints. Seeds add a clinch-cli public client and a c2a2-introspection confidential client. ADRs in docs/decisions record the opaque-vs-JWT, device-flow, and DCR-security decisions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
1550 lines
59 KiB
Ruby
1550 lines
59 KiB
Ruby
class OidcController < ApplicationController
|
|
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
|
|
|
|
# Discovery and JWKS endpoints are public
|
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization]
|
|
# Machine-to-machine endpoints (token/revoke/introspect/userinfo/device_authorization)
|
|
# and pure redirect handlers (logout/authorize) legitimately skip CSRF. The consent
|
|
# endpoint is browser-facing and state-changing (it grants OAuth scopes), so it MUST
|
|
# keep CSRF protection — the consent form already embeds the token via form_with.
|
|
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :introspect, :userinfo, :logout, :authorize, :device_authorization]
|
|
|
|
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
|
|
# other error can be reported via redirect. Failures here render a plain page.
|
|
before_action :set_application, only: :authorize
|
|
before_action :validate_redirect_uri, only: :authorize
|
|
|
|
# Rate limiting to prevent brute force and abuse
|
|
rate_limit to: 60, within: 1.minute, only: [:token, :revoke, :introspect, :device_authorization], with: -> {
|
|
render json: {error: "too_many_requests", error_description: "Rate limit exceeded. Try again later."}, status: :too_many_requests
|
|
}
|
|
rate_limit to: 30, within: 1.minute, only: [:authorize, :consent], with: -> {
|
|
render plain: "Too many authorization attempts. Try again later.", status: :too_many_requests
|
|
}
|
|
|
|
# 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",
|
|
revocation_endpoint: "#{base_url}/oauth/revoke",
|
|
introspection_endpoint: "#{base_url}/oauth/introspect",
|
|
userinfo_endpoint: "#{base_url}/oauth/userinfo",
|
|
device_authorization_endpoint: "#{base_url}/oauth/device_authorization",
|
|
jwks_uri: "#{base_url}/.well-known/jwks.json",
|
|
end_session_endpoint: "#{base_url}/logout",
|
|
response_types_supported: ["code"],
|
|
response_modes_supported: ["query"],
|
|
grant_types_supported: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
|
|
subject_types_supported: ["pairwise"],
|
|
id_token_signing_alg_values_supported: ["RS256"],
|
|
scopes_supported: SUPPORTED_SCOPES,
|
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
|
claims_supported: [
|
|
"sub", # Always included
|
|
"email", # email scope
|
|
"email_verified", # email scope
|
|
"name", # profile scope
|
|
"preferred_username", # profile scope
|
|
"updated_at", # profile scope
|
|
"groups" # groups scope
|
|
# Note: Custom claims are also supported but not listed here
|
|
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
|
],
|
|
code_challenge_methods_supported: ["S256"],
|
|
backchannel_logout_supported: true,
|
|
backchannel_logout_session_supported: true,
|
|
request_parameter_supported: false,
|
|
claims_parameter_supported: true
|
|
}
|
|
|
|
# Only advertise dynamic client registration when it is enabled (RFC 7591).
|
|
if Application.dynamic_registration_enabled?
|
|
config[:registration_endpoint] = "#{base_url}/oauth/register"
|
|
end
|
|
|
|
render json: config
|
|
end
|
|
|
|
# GET /.well-known/jwks.json
|
|
def jwks
|
|
render json: OidcJwtService.jwks
|
|
end
|
|
|
|
# POST /oauth/device_authorization
|
|
# RFC 8628 §3.1-3.2 — Device Authorization Request/Response.
|
|
# Public (PKCE) client presents its client_id and gets back a device_code the
|
|
# client polls with, plus a short user_code the human types on the /device page.
|
|
def device_authorization
|
|
client_id, _client_secret = extract_client_credentials
|
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
|
|
|
unless application&.active?
|
|
render json: {error: "invalid_client", error_description: "Unknown or inactive client"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Only accept scopes we support (mirrors the authorize endpoint).
|
|
requested_scope = (params[:scope].to_s.split & SUPPORTED_SCOPES).join(" ")
|
|
requested_scope = "openid" if requested_scope.blank?
|
|
|
|
# PKCE is optional but recommended for device flow (RFC 8628 §5.5). If the
|
|
# client sends a challenge here it must send the verifier at the token endpoint.
|
|
code_challenge = params[:code_challenge].presence
|
|
code_challenge_method = params[:code_challenge_method].presence
|
|
|
|
if code_challenge_method.present? && code_challenge_method != "S256"
|
|
render json: {error: "invalid_request", error_description: "Only S256 code_challenge_method is supported"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
device_code = OidcDeviceCode.create!(
|
|
application: application,
|
|
scope: requested_scope,
|
|
nonce: params[:nonce].presence,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: code_challenge.present? ? (code_challenge_method || "S256") : nil
|
|
)
|
|
|
|
base_url = OidcJwtService.issuer_url
|
|
verification_uri = "#{base_url}/device"
|
|
|
|
response.headers["Cache-Control"] = "no-store"
|
|
render json: {
|
|
device_code: device_code.plaintext_device_code,
|
|
user_code: device_code.user_code,
|
|
verification_uri: verification_uri,
|
|
verification_uri_complete: "#{verification_uri}?user_code=#{device_code.user_code}",
|
|
expires_in: (device_code.expires_at - Time.current).to_i,
|
|
interval: device_code.interval
|
|
}
|
|
end
|
|
|
|
# GET /oauth/authorize
|
|
def authorize
|
|
# @application and a validated redirect_uri are guaranteed by the before_actions.
|
|
# Read the remaining parameters (ignore forward auth tokens and other unknown params).
|
|
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]
|
|
code_challenge = params[:code_challenge]
|
|
code_challenge_method = params[:code_challenge_method] || "S256"
|
|
|
|
# ============================================================================
|
|
# client_id and redirect_uri are already validated (see before_actions).
|
|
# All subsequent errors should redirect back to the client with error parameters
|
|
# per OAuth2 RFC 6749 Section 4.1.2.1
|
|
# ============================================================================
|
|
|
|
# Reject request objects (JWT-encoded authorization parameters)
|
|
# Per OIDC Core §3.1.2.6: If request parameter is present and not supported,
|
|
# return request_not_supported error
|
|
if params[:request].present? || params[:request_uri].present?
|
|
Rails.logger.error "OAuth: Request object not supported"
|
|
error_uri = "#{redirect_uri}?error=request_not_supported"
|
|
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Validate response_type (now we can safely redirect with error)
|
|
unless response_type == "code"
|
|
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
|
|
error_uri = "#{redirect_uri}?error=unsupported_response_type"
|
|
error_uri += "&error_description=#{CGI.escape("Only 'code' response_type is supported")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Validate PKCE parameters if present (now we can safely redirect with error)
|
|
if code_challenge.present?
|
|
unless code_challenge_method == "S256"
|
|
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
|
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
|
Rails.logger.error "OAuth: Invalid code_challenge format"
|
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
|
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge format: must be 43-128 characters of base64url encoding")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
end
|
|
|
|
# Normalize requested scopes to the set we support. Needed here so claims
|
|
# validation below can check claim→scope coverage against what will actually
|
|
# be granted.
|
|
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES
|
|
scope = requested_scopes.join(" ")
|
|
|
|
# Parse claims parameter (JSON string) for OIDC claims request
|
|
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
|
|
# specific claims to be returned in the id_token and/or userinfo
|
|
claims_parameter = params[:claims]
|
|
parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present?
|
|
|
|
# Validate claims parameter format if present
|
|
if claims_parameter.present? && parsed_claims.nil?
|
|
Rails.logger.error "OAuth: Invalid claims parameter format"
|
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
|
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Validate that requested claims are covered by granted scopes
|
|
if parsed_claims.present?
|
|
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
|
|
unless validation_result[:valid]
|
|
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
|
|
error_uri = "#{redirect_uri}?error=invalid_scope"
|
|
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
end
|
|
|
|
# Check if application is active (now we can safely redirect with error)
|
|
unless @application.active?
|
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
|
error_uri = "#{redirect_uri}?error=unauthorized_client&error_description=Application+is+not+active"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Check if user is authenticated
|
|
unless authenticated?
|
|
# Handle prompt=none - no UI allowed, return error immediately
|
|
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
|
|
# return login_required error without showing any UI
|
|
if params[:prompt] == "none"
|
|
error_uri = "#{redirect_uri}?error=login_required"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Normal flow: store OAuth parameters and redirect to sign in
|
|
session[:oauth_params] = {
|
|
client_id: client_id,
|
|
redirect_uri: redirect_uri,
|
|
state: state,
|
|
nonce: nonce,
|
|
scope: scope,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: code_challenge_method,
|
|
claims_requests: parsed_claims&.to_json
|
|
}
|
|
# Store the current URL (with all OAuth params) for redirect after authentication
|
|
session[:return_to_after_authenticating] = request.url
|
|
redirect_to signin_path, alert: "Please sign in to continue"
|
|
return
|
|
end
|
|
|
|
# Handle prompt=login - force re-authentication
|
|
# Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt
|
|
# the End-User for reauthentication, even if the End-User is currently authenticated
|
|
if params[:prompt] == "login"
|
|
# Destroy current session to force re-authentication
|
|
# This creates a fresh authentication event with a new auth_time
|
|
Current.session&.destroy!
|
|
|
|
# Clear the session cookie so the user is truly logged out
|
|
cookies.delete(:session_id)
|
|
|
|
# Store the current URL (which contains all OAuth params) for redirect after login
|
|
# Remove prompt=login to prevent infinite re-auth loop
|
|
return_url = remove_query_param(request.url, "prompt")
|
|
session[:return_to_after_authenticating] = return_url
|
|
|
|
redirect_to signin_path, alert: "Please sign in to continue"
|
|
return
|
|
end
|
|
|
|
# Handle max_age - require re-authentication if session is too old
|
|
# Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older,
|
|
# the Authorization Server MUST prompt for reauthentication
|
|
if params[:max_age].present?
|
|
max_age_seconds = params[:max_age].to_i
|
|
# Calculate session age
|
|
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
|
|
|
|
if session_age_seconds >= max_age_seconds
|
|
# Session is too old - require re-authentication
|
|
# Store the return URL in Rails session, then destroy the Session record
|
|
|
|
# Store return URL before destroying anything
|
|
# Remove max_age from return URL to prevent infinite re-auth loop
|
|
return_url = remove_query_param(request.url, "max_age")
|
|
session[:return_to_after_authenticating] = return_url
|
|
|
|
# Destroy the Session record and clear its cookie
|
|
Current.session&.destroy!
|
|
cookies.delete(:session_id)
|
|
Current.session = nil
|
|
|
|
redirect_to signin_path, alert: "Please sign in to continue"
|
|
return
|
|
end
|
|
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
|
|
|
|
unless requested_scopes.include?("openid")
|
|
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Check if application is configured to skip consent
|
|
# If so, automatically create consent and proceed without showing consent screen
|
|
if @application.skip_consent?
|
|
# Create or update consent record automatically for trusted applications
|
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: @application)
|
|
consent.scopes_granted = requested_scopes.join(" ")
|
|
consent.claims_requests = parsed_claims || {}
|
|
consent.granted_at = Time.current
|
|
consent.save!
|
|
|
|
# Generate authorization code directly
|
|
auth_code = OidcAuthorizationCode.create!(
|
|
application: @application,
|
|
user: user,
|
|
redirect_uri: redirect_uri,
|
|
scope: scope,
|
|
nonce: nonce,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: code_challenge_method,
|
|
claims_requests: parsed_claims || {},
|
|
auth_time: Current.session.created_at.to_i,
|
|
acr: Current.session.acr,
|
|
expires_at: 10.minutes.from_now
|
|
)
|
|
|
|
# Redirect back to client with authorization code (plaintext)
|
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to redirect_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Check if user has already granted consent for these scopes
|
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
|
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
|
|
# User has already consented, generate authorization code directly
|
|
auth_code = OidcAuthorizationCode.create!(
|
|
application: @application,
|
|
user: user,
|
|
redirect_uri: redirect_uri,
|
|
scope: scope,
|
|
nonce: nonce,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: code_challenge_method,
|
|
claims_requests: parsed_claims || {},
|
|
auth_time: Current.session.created_at.to_i,
|
|
acr: Current.session.acr,
|
|
expires_at: 10.minutes.from_now
|
|
)
|
|
|
|
# Redirect back to client with authorization code (plaintext)
|
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to redirect_uri, allow_other_host: true
|
|
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,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: code_challenge_method,
|
|
claims_requests: parsed_claims&.to_json
|
|
}
|
|
|
|
# Render consent page with dynamic CSP for OAuth redirect
|
|
@redirect_uri = redirect_uri
|
|
@scopes = requested_scopes
|
|
|
|
# Add the redirect URI to CSP form-action for this specific request
|
|
# This allows the OAuth redirect to work while maintaining security
|
|
# CSP must allow the OAuth client's redirect_uri as a form submission target
|
|
if redirect_uri.present?
|
|
begin
|
|
redirect_host = URI.parse(redirect_uri).host
|
|
csp = request.content_security_policy
|
|
if csp && redirect_host
|
|
# Only modify if form_action is available and mutable
|
|
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
|
csp.form_action << "https://#{redirect_host}"
|
|
end
|
|
end
|
|
rescue => e
|
|
# Log CSP modification errors but don't fail the request
|
|
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
|
|
end
|
|
end
|
|
|
|
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=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
# Find the application
|
|
client_id = oauth_params["client_id"]
|
|
application = Application.find_by(client_id: client_id, app_type: "oidc")
|
|
|
|
# Check if application is active (redirect with OAuth error)
|
|
unless application&.active?
|
|
Rails.logger.error "OAuth: Application is not active: #{application&.name || client_id}"
|
|
session.delete(:oauth_params)
|
|
error_uri = "#{oauth_params["redirect_uri"]}?error=unauthorized_client&error_description=Application+is+not+active"
|
|
error_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"].present?
|
|
redirect_to error_uri, allow_other_host: true
|
|
return
|
|
end
|
|
|
|
user = Current.session.user
|
|
|
|
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
|
|
parsed_claims = begin
|
|
JSON.parse(oauth_params["claims_requests"])
|
|
rescue
|
|
{}
|
|
end
|
|
|
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
|
consent.scopes_granted = requested_scopes.join(" ")
|
|
consent.claims_requests = parsed_claims
|
|
consent.granted_at = Time.current
|
|
consent.save!
|
|
|
|
# Generate authorization code
|
|
auth_code = OidcAuthorizationCode.create!(
|
|
application: application,
|
|
user: user,
|
|
redirect_uri: oauth_params["redirect_uri"],
|
|
scope: oauth_params["scope"],
|
|
nonce: oauth_params["nonce"],
|
|
code_challenge: oauth_params["code_challenge"],
|
|
code_challenge_method: oauth_params["code_challenge_method"],
|
|
claims_requests: parsed_claims,
|
|
auth_time: Current.session.created_at.to_i,
|
|
acr: Current.session.acr,
|
|
expires_at: 10.minutes.from_now
|
|
)
|
|
|
|
# Clear OAuth params from session
|
|
session.delete(:oauth_params)
|
|
|
|
# Redirect back to client with authorization code (plaintext)
|
|
redirect_uri = "#{oauth_params["redirect_uri"]}?code=#{auth_code.plaintext_code}"
|
|
redirect_uri += "&state=#{CGI.escape(oauth_params["state"])}" if oauth_params["state"]
|
|
|
|
redirect_to redirect_uri, allow_other_host: true
|
|
end
|
|
|
|
# POST /oauth/token
|
|
def token
|
|
# Reject claims parameter - per OIDC security, claims parameter is only valid
|
|
# in authorization requests, not at the token endpoint
|
|
if params[:claims].present?
|
|
render json: {
|
|
error: "invalid_request",
|
|
error_description: "claims parameter is not allowed at the token endpoint"
|
|
}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
grant_type = params[:grant_type]
|
|
|
|
case grant_type
|
|
when "authorization_code"
|
|
handle_authorization_code_grant
|
|
when "refresh_token"
|
|
handle_refresh_token_grant
|
|
when "urn:ietf:params:oauth:grant-type:device_code"
|
|
handle_device_code_grant
|
|
else
|
|
render json: {error: "unsupported_grant_type"}, status: :bad_request
|
|
end
|
|
end
|
|
|
|
# RFC 8628 §3.4-3.5 — the CLI/agent polls here with its device_code until the
|
|
# user approves on the /device page, then receives the standard token triple.
|
|
def handle_device_code_grant
|
|
client_id, client_secret = extract_client_credentials
|
|
|
|
unless client_id
|
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
application = Application.find_by(client_id: client_id)
|
|
unless application
|
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Public clients authenticate with the device_code (+ optional PKCE); a
|
|
# confidential client using device flow must still present its secret.
|
|
if application.confidential_client?
|
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
|
return
|
|
end
|
|
end
|
|
|
|
unless application.active?
|
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
|
return
|
|
end
|
|
|
|
device_code = OidcDeviceCode.find_by_plaintext_device_code(params[:device_code])
|
|
unless device_code && device_code.application_id == application.id
|
|
render json: {error: "invalid_grant", error_description: "Invalid device_code"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
OidcDeviceCode.transaction do
|
|
# Lock so concurrent polls / a poll racing with approval can't double-issue.
|
|
device_code.lock!
|
|
|
|
if device_code.expired?
|
|
render json: {error: "expired_token", error_description: "The device_code has expired"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
if device_code.denied?
|
|
render json: {error: "access_denied", error_description: "The authorization request was denied"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
if device_code.pending?
|
|
# Enforce the polling interval; too-frequent polls get slow_down, and the
|
|
# client is expected to add 5s to its interval (RFC 8628 §3.5).
|
|
if device_code.last_polled_at && (Time.current - device_code.last_polled_at) < device_code.interval
|
|
device_code.update!(interval: device_code.interval + 5, last_polled_at: Time.current)
|
|
render json: {error: "slow_down"}, status: :bad_request
|
|
else
|
|
device_code.update!(last_polled_at: Time.current)
|
|
render json: {error: "authorization_pending"}, status: :bad_request
|
|
end
|
|
return
|
|
end
|
|
|
|
# Approved: mint tokens via the same path as the authorization code grant.
|
|
user = device_code.user
|
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
|
unless consent
|
|
Rails.logger.error "OIDC Security: Device token requested without consent record (user: #{user&.id}, app: #{application.id})"
|
|
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# PKCE is optional for device flow: only enforced when the device
|
|
# authorization request supplied a code_challenge.
|
|
if device_code.uses_pkce?
|
|
pkce_result = validate_pkce(application, device_code, params[:code_verifier])
|
|
unless pkce_result[:valid]
|
|
render json: {error: pkce_result[:error], error_description: pkce_result[:error_description]}, status: pkce_result[:status]
|
|
return
|
|
end
|
|
end
|
|
|
|
granted_scope = device_code.scope
|
|
|
|
access_token_record = OidcAccessToken.create!(
|
|
application: application,
|
|
user: user,
|
|
scope: granted_scope
|
|
)
|
|
|
|
refresh_token_record = OidcRefreshToken.create!(
|
|
application: application,
|
|
user: user,
|
|
oidc_access_token: access_token_record,
|
|
scope: granted_scope,
|
|
auth_time: device_code.auth_time,
|
|
acr: device_code.acr
|
|
)
|
|
|
|
id_token = OidcJwtService.generate_id_token(
|
|
user,
|
|
application,
|
|
consent: consent,
|
|
nonce: device_code.nonce,
|
|
access_token: access_token_record.plaintext_token,
|
|
auth_time: device_code.auth_time,
|
|
acr: device_code.acr,
|
|
scopes: granted_scope,
|
|
claims_requests: {}
|
|
)
|
|
|
|
# Single-use: destroy the code so an approved device_code can't be replayed.
|
|
device_code.destroy!
|
|
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
render json: {
|
|
access_token: access_token_record.plaintext_token,
|
|
token_type: "Bearer",
|
|
expires_in: application.access_token_ttl || 3600,
|
|
id_token: id_token,
|
|
refresh_token: refresh_token_record.token,
|
|
scope: granted_scope
|
|
}
|
|
end
|
|
end
|
|
|
|
def handle_authorization_code_grant
|
|
# Get client credentials from Authorization header or params
|
|
client_id, client_secret = extract_client_credentials
|
|
|
|
unless client_id
|
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Find the application
|
|
application = Application.find_by(client_id: client_id)
|
|
unless application
|
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Validate client credentials based on client type
|
|
if application.public_client?
|
|
# Public clients don't have a secret - they MUST use PKCE (checked later)
|
|
Rails.logger.info "OAuth: Public client authentication for #{application.name}"
|
|
else
|
|
# Confidential clients MUST provide valid client_secret
|
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
|
return
|
|
end
|
|
end
|
|
|
|
# Check if application is active
|
|
unless application.active?
|
|
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
|
return
|
|
end
|
|
|
|
# Get the authorization code
|
|
code = params[:code]
|
|
redirect_uri = params[:redirect_uri]
|
|
code_verifier = params[:code_verifier]
|
|
|
|
# Find authorization code using HMAC verification
|
|
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
|
|
|
unless auth_code && auth_code.application == application
|
|
render json: {error: "invalid_grant"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Use a transaction with pessimistic locking to prevent code reuse
|
|
begin
|
|
OidcAuthorizationCode.transaction do
|
|
# Lock the record to prevent concurrent access
|
|
auth_code.lock!
|
|
|
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
|
if auth_code.used?
|
|
# Per OAuth 2.0 spec, if an auth code is reused, revoke every token
|
|
# descended from it (both generations across any rotations).
|
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
|
now = Time.current
|
|
auth_code.oidc_access_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
|
auth_code.oidc_refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
|
|
|
|
render json: {
|
|
error: "invalid_grant",
|
|
error_description: "Authorization code has already been used"
|
|
}, 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
|
|
|
|
# Validate PKCE - required for public clients and optionally for confidential clients
|
|
pkce_result = validate_pkce(application, auth_code, code_verifier)
|
|
unless pkce_result[:valid]
|
|
render json: {
|
|
error: pkce_result[:error],
|
|
error_description: pkce_result[:error_description]
|
|
}, status: pkce_result[:status]
|
|
return
|
|
end
|
|
|
|
# Mark code as used BEFORE generating tokens (prevents reuse)
|
|
auth_code.update!(used: true)
|
|
|
|
# Get the user
|
|
user = auth_code.user
|
|
|
|
# Generate access token record (opaque token with BCrypt hashing)
|
|
access_token_record = OidcAccessToken.create!(
|
|
application: application,
|
|
user: user,
|
|
scope: auth_code.scope,
|
|
oidc_authorization_code: auth_code
|
|
)
|
|
|
|
# Generate refresh token (opaque, with hashing)
|
|
refresh_token_record = OidcRefreshToken.create!(
|
|
application: application,
|
|
user: user,
|
|
oidc_access_token: access_token_record,
|
|
oidc_authorization_code: auth_code,
|
|
scope: auth_code.scope,
|
|
auth_time: auth_code.auth_time,
|
|
acr: auth_code.acr
|
|
)
|
|
|
|
# Find user consent for this application
|
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
|
|
|
unless consent
|
|
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
|
|
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
|
# scopes determine which claims are included (per OIDC Core spec)
|
|
# claims_requests parameter filters which claims are included
|
|
id_token = OidcJwtService.generate_id_token(
|
|
user,
|
|
application,
|
|
consent: consent,
|
|
nonce: auth_code.nonce,
|
|
access_token: access_token_record.plaintext_token,
|
|
auth_time: auth_code.auth_time,
|
|
acr: auth_code.acr,
|
|
scopes: auth_code.scope,
|
|
claims_requests: auth_code.parsed_claims_requests
|
|
)
|
|
|
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
# Return tokens
|
|
render json: {
|
|
access_token: access_token_record.plaintext_token, # Opaque token
|
|
token_type: "Bearer",
|
|
expires_in: application.access_token_ttl || 3600,
|
|
id_token: id_token, # JWT
|
|
refresh_token: refresh_token_record.token, # Opaque token
|
|
scope: auth_code.scope
|
|
}
|
|
end
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {error: "invalid_grant"}, status: :bad_request
|
|
end
|
|
end
|
|
|
|
def handle_refresh_token_grant
|
|
# Get client credentials from Authorization header or params
|
|
client_id, client_secret = extract_client_credentials
|
|
|
|
unless client_id
|
|
render json: {error: "invalid_client", error_description: "client_id is required"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Find the application
|
|
application = Application.find_by(client_id: client_id)
|
|
unless application
|
|
render json: {error: "invalid_client", error_description: "Unknown client"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
# Validate client credentials based on client type
|
|
if application.public_client?
|
|
# Public clients don't have a secret
|
|
Rails.logger.info "OAuth: Public client refresh token request for #{application.name}"
|
|
else
|
|
# Confidential clients MUST provide valid client_secret
|
|
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
|
render json: {error: "invalid_client", error_description: "Invalid client credentials"}, status: :unauthorized
|
|
return
|
|
end
|
|
end
|
|
|
|
# Check if application is active
|
|
unless application.active?
|
|
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
|
render json: {error: "invalid_client", error_description: "Application is not active"}, status: :forbidden
|
|
return
|
|
end
|
|
|
|
# Get the refresh token
|
|
refresh_token = params[:refresh_token]
|
|
unless refresh_token.present?
|
|
render json: {error: "invalid_request", error_description: "refresh_token is required"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Find the refresh token record using indexed token prefix lookup
|
|
refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
|
|
|
|
# Verify the token belongs to the correct application
|
|
unless refresh_token_record && refresh_token_record.application == application
|
|
render json: {error: "invalid_grant", error_description: "Invalid refresh token"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Check if refresh token is expired
|
|
if refresh_token_record.expired?
|
|
render json: {error: "invalid_grant", error_description: "Refresh token expired"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Check if refresh token is revoked
|
|
if refresh_token_record.revoked?
|
|
# If a revoked refresh token is used, it's a security issue
|
|
# Revoke all tokens in the family (token rotation attack detection)
|
|
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
|
|
refresh_token_record.revoke_family!
|
|
|
|
render json: {error: "invalid_grant", error_description: "Refresh token has been revoked"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Get the user
|
|
user = refresh_token_record.user
|
|
|
|
# Revoke the old refresh token (token rotation)
|
|
refresh_token_record.revoke!
|
|
|
|
# Generate new access token record (opaque token with BCrypt hashing)
|
|
# Carry the authorization-code FK forward across rotations so replay
|
|
# revocation reaches every descendant token in the chain.
|
|
issuing_auth_code = refresh_token_record.oidc_authorization_code
|
|
|
|
new_access_token = OidcAccessToken.create!(
|
|
application: application,
|
|
user: user,
|
|
scope: refresh_token_record.scope,
|
|
oidc_authorization_code: issuing_auth_code
|
|
)
|
|
|
|
# Generate new refresh token (token rotation)
|
|
new_refresh_token = OidcRefreshToken.create!(
|
|
application: application,
|
|
user: user,
|
|
oidc_access_token: new_access_token,
|
|
oidc_authorization_code: issuing_auth_code,
|
|
scope: refresh_token_record.scope,
|
|
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking
|
|
auth_time: refresh_token_record.auth_time, # Carry over original auth_time
|
|
acr: refresh_token_record.acr # Carry over original acr
|
|
)
|
|
|
|
# Find user consent for this application
|
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
|
|
|
unless consent
|
|
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
|
|
render json: {error: "invalid_grant", error_description: "Authorization consent not found"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
|
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
|
# scopes determine which claims are included (per OIDC Core spec)
|
|
# claims_requests parameter filters which claims are included (from original consent)
|
|
id_token = OidcJwtService.generate_id_token(
|
|
user,
|
|
application,
|
|
consent: consent,
|
|
access_token: new_access_token.plaintext_token,
|
|
auth_time: refresh_token_record.auth_time,
|
|
acr: refresh_token_record.acr,
|
|
scopes: refresh_token_record.scope,
|
|
claims_requests: consent.parsed_claims_requests
|
|
)
|
|
|
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
# Return new tokens
|
|
render json: {
|
|
access_token: new_access_token.plaintext_token, # Opaque token
|
|
token_type: "Bearer",
|
|
expires_in: application.access_token_ttl || 3600,
|
|
id_token: id_token, # JWT
|
|
refresh_token: new_refresh_token.token, # Opaque token
|
|
scope: refresh_token_record.scope
|
|
}
|
|
rescue ActiveRecord::RecordNotFound
|
|
render json: {error: "invalid_grant"}, status: :bad_request
|
|
end
|
|
|
|
# GET/POST /oauth/userinfo
|
|
# OIDC Core spec: UserInfo endpoint MUST support GET, SHOULD support POST
|
|
def userinfo
|
|
# Extract access token from Authorization header or POST body
|
|
# RFC 6750: Bearer token can be in Authorization header, request body, or query string
|
|
token = if request.headers["Authorization"]&.start_with?("Bearer ")
|
|
request.headers["Authorization"].sub("Bearer ", "")
|
|
elsif request.params["access_token"].present?
|
|
request.params["access_token"]
|
|
end
|
|
|
|
unless token
|
|
head :unauthorized
|
|
return
|
|
end
|
|
|
|
# Find and validate access token (opaque token with BCrypt hashing)
|
|
access_token = OidcAccessToken.find_by_token(token)
|
|
unless access_token&.active?
|
|
head :unauthorized
|
|
return
|
|
end
|
|
|
|
# Check if application is active (immediate cutoff when app is disabled)
|
|
unless access_token.application&.active?
|
|
Rails.logger.warn "OAuth: Userinfo request for inactive application: #{access_token.application&.name}"
|
|
head :forbidden
|
|
return
|
|
end
|
|
|
|
# Get the user (with fresh data from database)
|
|
user = access_token.user
|
|
unless user
|
|
head :unauthorized
|
|
return
|
|
end
|
|
|
|
# Find user consent for this application to get pairwise SID
|
|
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
|
|
subject = consent&.sid || user.id.to_s
|
|
|
|
# Parse scopes from access token (space-separated string)
|
|
requested_scopes = access_token.scope.to_s.split
|
|
|
|
# Get claims_requests from consent (if available) for UserInfo context
|
|
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
|
|
|
|
# Return user claims (filter by scope per OIDC Core spec)
|
|
# Required claims (always included - cannot be filtered by claims parameter)
|
|
claims = {
|
|
sub: subject
|
|
}
|
|
|
|
# Email claims (only if 'email' scope requested AND requested in claims parameter)
|
|
if requested_scopes.include?("email")
|
|
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
|
claims[:email] = user.email_address
|
|
end
|
|
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
|
claims[:email_verified] = true
|
|
end
|
|
end
|
|
|
|
# Profile claims (only if 'profile' scope requested)
|
|
# Per OIDC Core spec section 5.4, include available profile claims
|
|
# Only include claims we have data for - omit unknown claims rather than returning null
|
|
if requested_scopes.include?("profile")
|
|
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
|
claims[:preferred_username] = user.username.presence || user.email_address
|
|
end
|
|
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
|
claims[:name] = user.name.presence || user.email_address
|
|
end
|
|
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
|
claims[:updated_at] = user.updated_at.to_i
|
|
end
|
|
end
|
|
|
|
# Groups claim (only if 'groups' scope requested AND requested in claims parameter)
|
|
if requested_scopes.include?("groups") && user.groups.any?
|
|
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
|
claims[:groups] = user.groups.pluck(:name)
|
|
end
|
|
end
|
|
|
|
# Merge custom claims from groups
|
|
user.groups.each do |group|
|
|
claims.merge!(group.parsed_custom_claims)
|
|
end
|
|
|
|
# Merge custom claims from user (overrides group claims)
|
|
claims.merge!(user.parsed_custom_claims)
|
|
|
|
# Merge app-specific custom claims (highest priority)
|
|
application = access_token.application
|
|
claims.merge!(application.custom_claims_for_user(user))
|
|
|
|
# Filter custom claims based on claims parameter
|
|
# If claims parameter is present, only include requested custom claims
|
|
if userinfo_claims.any?
|
|
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
|
end
|
|
|
|
# Security: Don't cache user data responses
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
render json: claims
|
|
end
|
|
|
|
# POST /oauth/introspect
|
|
# RFC 7662 - OAuth 2.0 Token Introspection.
|
|
# A resource server (e.g. c2a2) presents an opaque access token and its own
|
|
# client credentials; we reply whether the token is active and, as an extension,
|
|
# the user's groups so the resource server can authorize on group membership.
|
|
def introspect
|
|
# RFC 7662 §2.1: the caller (resource server) MUST authenticate. Only a
|
|
# registered confidential client may introspect.
|
|
caller_id, caller_secret = extract_client_credentials
|
|
caller = Application.find_by(client_id: caller_id) if caller_id.present?
|
|
|
|
unless caller&.confidential_client? && caller.active? &&
|
|
caller_secret.present? && caller.authenticate_client_secret(caller_secret)
|
|
render json: {error: "invalid_client", error_description: "Caller authentication failed"}, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
token_value = params[:token]
|
|
if token_value.blank?
|
|
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
response.headers["Cache-Control"] = "no-store"
|
|
response.headers["Pragma"] = "no-cache"
|
|
|
|
access_token = OidcAccessToken.find_by_token(token_value)
|
|
|
|
# Inactive/unknown/expired/revoked tokens (or those for a disabled app) are
|
|
# reported as simply inactive per RFC 7662 §2.2 — never an error.
|
|
unless access_token&.active? && access_token.application&.active? && access_token.user
|
|
render json: {active: false}
|
|
return
|
|
end
|
|
|
|
user = access_token.user
|
|
application = access_token.application
|
|
consent = OidcUserConsent.find_by(user: user, application: application)
|
|
|
|
render json: {
|
|
active: true,
|
|
scope: access_token.scope,
|
|
client_id: application.client_id,
|
|
token_type: "Bearer",
|
|
exp: access_token.expires_at.to_i,
|
|
iat: access_token.created_at.to_i,
|
|
sub: consent&.sid || user.id.to_s,
|
|
aud: application.client_id,
|
|
username: user.email_address,
|
|
groups: user.groups.pluck(:name)
|
|
}
|
|
end
|
|
|
|
# POST /oauth/revoke
|
|
# RFC 7009 - Token Revocation
|
|
def revoke
|
|
# Get client credentials
|
|
client_id, client_secret = extract_client_credentials
|
|
|
|
unless client_id && client_secret
|
|
# RFC 7009 says we should return 200 OK even for invalid client
|
|
# But log the attempt for security monitoring
|
|
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
|
|
head :ok
|
|
return
|
|
end
|
|
|
|
# Find and validate the application
|
|
application = Application.find_by(client_id: client_id)
|
|
unless application&.authenticate_client_secret(client_secret)
|
|
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
|
|
head :ok
|
|
return
|
|
end
|
|
|
|
# Check if application is active (RFC 7009: still return 200 OK for privacy)
|
|
unless application.active?
|
|
Rails.logger.warn "OAuth: Token revocation attempted for inactive application: #{application.name}"
|
|
head :ok
|
|
return
|
|
end
|
|
|
|
# Get the token to revoke
|
|
token = params[:token]
|
|
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
|
|
|
|
unless token.present?
|
|
# RFC 7009: Missing token parameter is an error
|
|
render json: {error: "invalid_request", error_description: "token parameter is required"}, status: :bad_request
|
|
return
|
|
end
|
|
|
|
# Try to find and revoke the token
|
|
# Check token type hint first for efficiency, otherwise try both
|
|
revoked = false
|
|
|
|
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
|
# Try to find as refresh token
|
|
refresh_token_record = OidcRefreshToken.find_by_token(token)
|
|
|
|
if refresh_token_record
|
|
refresh_token_record.revoke!
|
|
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
|
|
revoked = true
|
|
end
|
|
end
|
|
|
|
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
|
# Try to find as access token
|
|
access_token_record = OidcAccessToken.find_by_token(token)
|
|
|
|
if access_token_record
|
|
access_token_record.revoke!
|
|
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
|
|
true
|
|
end
|
|
end
|
|
|
|
# RFC 7009: Always return 200 OK, even if token was not found
|
|
# This prevents token scanning attacks
|
|
head :ok
|
|
end
|
|
|
|
# GET /logout
|
|
def logout
|
|
# OpenID Connect RP-Initiated Logout
|
|
# Handle id_token_hint and post_logout_redirect_uri parameters
|
|
|
|
params[:id_token_hint]
|
|
post_logout_redirect_uri = params[:post_logout_redirect_uri]
|
|
state = params[:state]
|
|
|
|
# If user is authenticated, log them out
|
|
if authenticated?
|
|
user = Current.session.user
|
|
|
|
# Send backchannel logout notifications to all connected applications
|
|
send_backchannel_logout_notifications(user)
|
|
|
|
# Invalidate the current session
|
|
Current.session&.destroy
|
|
reset_session
|
|
end
|
|
|
|
# If post_logout_redirect_uri is provided, validate and redirect
|
|
if post_logout_redirect_uri.present?
|
|
validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
|
|
|
|
if validated_uri
|
|
redirect_uri = validated_uri
|
|
redirect_uri += "?state=#{CGI.escape(state)}" if state.present?
|
|
redirect_to redirect_uri, allow_other_host: true
|
|
else
|
|
# Invalid redirect URI - log warning and go to default
|
|
Rails.logger.warn "OIDC Logout: Invalid post_logout_redirect_uri attempted: #{post_logout_redirect_uri}"
|
|
redirect_to root_path
|
|
end
|
|
else
|
|
# Default redirect to home page
|
|
redirect_to root_path
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
# Look up @application from client_id. RFC 6749 §4.1.2.1 requires that an
|
|
# invalid client_id be reported on-page, not via redirect.
|
|
def set_application
|
|
client_id = params[:client_id]
|
|
|
|
unless client_id.present?
|
|
render plain: "Invalid request: client_id is required", status: :bad_request
|
|
return
|
|
end
|
|
|
|
@application = Application.find_by(client_id: client_id, app_type: "oidc")
|
|
return if @application
|
|
|
|
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
|
|
|
|
error_msg = if Rails.env.development?
|
|
all_oidc_apps = Application.where(app_type: "oidc")
|
|
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
|
|
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(", ")}"
|
|
else
|
|
"Invalid request: Application not found"
|
|
end
|
|
|
|
render plain: error_msg, status: :bad_request
|
|
end
|
|
|
|
# Confirm the redirect_uri param is present and registered on @application.
|
|
# Must run after set_application. Errors render on-page per RFC 6749 §4.1.2.1.
|
|
def validate_redirect_uri
|
|
redirect_uri = params[:redirect_uri]
|
|
|
|
unless redirect_uri.present?
|
|
render plain: "Invalid request: redirect_uri is required", status: :bad_request
|
|
return
|
|
end
|
|
|
|
return if @application.parsed_redirect_uris.include?(redirect_uri)
|
|
|
|
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
|
|
|
|
error_msg = if Rails.env.development?
|
|
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(", ")}, but received: #{redirect_uri}"
|
|
else
|
|
"Invalid request: Redirect URI not registered for this application"
|
|
end
|
|
|
|
render plain: error_msg, status: :bad_request
|
|
end
|
|
|
|
def validate_pkce(application, auth_code, code_verifier)
|
|
# Check if PKCE is required for this application
|
|
pkce_required = application.requires_pkce?
|
|
pkce_provided = auth_code.code_challenge.present?
|
|
|
|
# If PKCE is required but wasn't provided during authorization
|
|
if pkce_required && !pkce_provided
|
|
client_type = application.public_client? ? "public clients" : "this application"
|
|
return {
|
|
valid: false,
|
|
error: "invalid_request",
|
|
error_description: "PKCE is required for #{client_type}. code_challenge must be provided during authorization.",
|
|
status: :bad_request
|
|
}
|
|
end
|
|
|
|
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
|
return {valid: true} unless pkce_provided
|
|
|
|
# PKCE was provided during authorization but no verifier sent with token request
|
|
unless code_verifier.present?
|
|
return {
|
|
valid: false,
|
|
error: "invalid_request",
|
|
error_description: "code_verifier is required when code_challenge was provided",
|
|
status: :bad_request
|
|
}
|
|
end
|
|
|
|
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
|
|
unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
|
|
return {
|
|
valid: false,
|
|
error: "invalid_request",
|
|
error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
|
|
status: :bad_request
|
|
}
|
|
end
|
|
|
|
# Recreate code challenge based on method
|
|
expected_challenge = case auth_code.code_challenge_method
|
|
when "S256"
|
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
else
|
|
return {
|
|
valid: false,
|
|
error: "invalid_request",
|
|
error_description: "Unsupported code challenge method: only 'S256' is supported",
|
|
status: :bad_request
|
|
}
|
|
end
|
|
|
|
# Validate the code challenge
|
|
unless auth_code.code_challenge == expected_challenge
|
|
return {
|
|
valid: false,
|
|
error: "invalid_grant",
|
|
error_description: "Invalid code verifier",
|
|
status: :bad_request
|
|
}
|
|
end
|
|
|
|
{valid: true}
|
|
end
|
|
|
|
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
|
|
|
|
def validate_logout_redirect_uri(uri)
|
|
return nil unless uri.present?
|
|
|
|
begin
|
|
parsed_uri = URI.parse(uri)
|
|
|
|
# Only allow HTTP/HTTPS schemes (prevent javascript:, data:, etc.)
|
|
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
|
|
|
# Only allow HTTPS in production
|
|
return nil if Rails.env.production? && parsed_uri.scheme != "https"
|
|
|
|
# Check if URI matches any registered OIDC application's redirect URIs
|
|
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
|
|
Application.oidc.active.find_each do |app|
|
|
# Check if this URI matches any of the app's registered redirect URIs
|
|
if app.parsed_redirect_uris.any? { |registered_uri| logout_uri_matches?(uri, registered_uri) }
|
|
return uri
|
|
end
|
|
end
|
|
|
|
# No matching application found
|
|
nil
|
|
rescue URI::InvalidURIError
|
|
nil
|
|
end
|
|
end
|
|
|
|
# Check if logout URI matches a registered redirect URI
|
|
# More lenient than exact match - allows same host/path with different query params
|
|
def logout_uri_matches?(provided, registered)
|
|
# Exact match is always valid
|
|
return true if provided == registered
|
|
|
|
# Parse both URIs to compare components
|
|
begin
|
|
provided_parsed = URI.parse(provided)
|
|
registered_parsed = URI.parse(registered)
|
|
|
|
# Match if scheme, host, port, and path are the same
|
|
# (allows different query params which is common for logout redirects)
|
|
provided_parsed.scheme == registered_parsed.scheme &&
|
|
provided_parsed.host == registered_parsed.host &&
|
|
provided_parsed.port == registered_parsed.port &&
|
|
provided_parsed.path == registered_parsed.path
|
|
rescue URI::InvalidURIError
|
|
false
|
|
end
|
|
end
|
|
|
|
def send_backchannel_logout_notifications(user)
|
|
# Find all active OIDC consents for this user
|
|
consents = OidcUserConsent.where(user: user).includes(:application)
|
|
|
|
consents.each do |consent|
|
|
# Skip if application doesn't support backchannel logout
|
|
next unless consent.application.supports_backchannel_logout?
|
|
|
|
# Enqueue background job to send logout notification
|
|
BackchannelLogoutJob.perform_later(
|
|
user_id: user.id,
|
|
application_id: consent.application.id,
|
|
consent_sid: consent.sid
|
|
)
|
|
end
|
|
|
|
Rails.logger.info "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
|
rescue => e
|
|
# Log error but don't block logout
|
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
|
end
|
|
|
|
# Parse claims parameter JSON string
|
|
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
|
|
# id_token and/or userinfo keys, each mapping to claim requests
|
|
def parse_claims_parameter(claims_string)
|
|
return {} if claims_string.blank?
|
|
return nil if claims_string.length > 4096
|
|
|
|
parsed = JSON.parse(claims_string)
|
|
return nil unless parsed.is_a?(Hash)
|
|
|
|
# Validate structure: can have id_token, userinfo, or both
|
|
valid_keys = parsed.keys & ["id_token", "userinfo"]
|
|
return nil if valid_keys.empty?
|
|
|
|
# Validate each claim request has proper structure
|
|
valid_keys.each do |key|
|
|
next unless parsed[key].is_a?(Hash)
|
|
|
|
parsed[key].each do |_claim_name, claim_spec|
|
|
# Claim spec can be null (requested), true (essential), or a hash with specific keys
|
|
next if claim_spec.nil? || claim_spec == true || claim_spec == false
|
|
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
|
|
|
|
# Invalid claim specification
|
|
return nil
|
|
end
|
|
end
|
|
|
|
parsed
|
|
rescue JSON::ParserError
|
|
nil
|
|
end
|
|
|
|
# Validate that requested claims are covered by granted scopes
|
|
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
|
|
def validate_claims_against_scopes(parsed_claims, granted_scopes)
|
|
granted = Array(granted_scopes).map(&:to_s)
|
|
errors = []
|
|
|
|
# Standard claim-to-scope mapping
|
|
claim_scope_mapping = {
|
|
"email" => "email",
|
|
"email_verified" => "email",
|
|
"preferred_username" => "profile",
|
|
"name" => "profile",
|
|
"updated_at" => "profile",
|
|
"groups" => "groups"
|
|
}
|
|
|
|
# Check both id_token and userinfo claims
|
|
["id_token", "userinfo"].each do |context|
|
|
next unless parsed_claims[context]&.is_a?(Hash)
|
|
|
|
parsed_claims[context].each do |claim_name, _claim_spec|
|
|
# Skip custom claims (not in standard mapping)
|
|
# Custom claims are allowed since they're configured in the IdP
|
|
next unless claim_scope_mapping.key?(claim_name)
|
|
|
|
required_scope = claim_scope_mapping[claim_name]
|
|
unless granted.include?(required_scope)
|
|
errors << "#{claim_name} requires #{required_scope} scope"
|
|
end
|
|
end
|
|
end
|
|
|
|
if errors.any?
|
|
{valid: false, errors: errors}
|
|
else
|
|
{valid: true}
|
|
end
|
|
end
|
|
|
|
# Check if claims match existing consent
|
|
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
|
|
def claims_match_consent?(parsed_claims, consent)
|
|
return true if parsed_claims.nil? || parsed_claims.empty?
|
|
|
|
# If consent has no claims stored, this is a new claims request
|
|
# Require fresh consent
|
|
return false if consent.parsed_claims_requests.empty?
|
|
|
|
# If both have claims, they must match exactly
|
|
consent.parsed_claims_requests == parsed_claims
|
|
end
|
|
|
|
# Check if a claim should be included in UserInfo response
|
|
# Returns true if no claims filtering or claim is explicitly requested
|
|
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
|
|
return true if userinfo_claims.empty?
|
|
userinfo_claims.key?(claim_name)
|
|
end
|
|
|
|
# Filter custom claims for UserInfo endpoint
|
|
# Removes claims not explicitly requested
|
|
# Applies value/values filtering if specified
|
|
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
|
# Get all claim names that are NOT standard OIDC claims
|
|
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
|
|
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
|
|
|
|
filtered = claims.dup
|
|
|
|
custom_claim_names.each do |claim_name|
|
|
claim_sym = claim_name.to_sym
|
|
|
|
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
|
|
filtered.delete(claim_sym)
|
|
next
|
|
end
|
|
|
|
# Apply value/values filtering if specified
|
|
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
|
|
next unless claim_spec.is_a?(Hash)
|
|
|
|
current_value = filtered[claim_sym]
|
|
|
|
# Check value constraint
|
|
if claim_spec["value"].present?
|
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
|
end
|
|
|
|
# Check values constraint (array of allowed values)
|
|
if claim_spec["values"].is_a?(Array)
|
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
|
end
|
|
end
|
|
|
|
filtered
|
|
end
|
|
end
|