diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb new file mode 100644 index 0000000..dfb4aa4 --- /dev/null +++ b/app/controllers/oidc_controller.rb @@ -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 diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb new file mode 100644 index 0000000..6fce9ea --- /dev/null +++ b/app/services/oidc_jwt_service.rb @@ -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 diff --git a/app/views/oidc/consent.html.erb b/app/views/oidc/consent.html.erb new file mode 100644 index 0000000..d993a41 --- /dev/null +++ b/app/views/oidc/consent.html.erb @@ -0,0 +1,71 @@ +
+
+
+

Authorize Application

+

+ <%= @application.name %> is requesting access to your account. +

+
+ +
+

This application will be able to:

+
    + <% if @scopes.include?("openid") %> +
  • + + + + Verify your identity +
  • + <% end %> + <% if @scopes.include?("email") %> +
  • + + + + Access your email address (<%= Current.session.user.email_address %>) +
  • + <% end %> + <% if @scopes.include?("profile") %> +
  • + + + + Access your profile information +
  • + <% end %> + <% if @scopes.include?("groups") %> +
  • + + + + Access your group memberships +
  • + <% end %> +
+
+ +
+
+ + + +
+

You'll be redirected to:

+

<%= @redirect_uri %>

+
+
+
+ + <%= 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 %> +
+