diff --git a/Gemfile b/Gemfile index 809b87c257f..5b88d5b8119 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,10 @@ gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'jwt', '>= 2.2.2', '< 3.0' gem 'graphql', '~> 1.13.0' gem 'graphql-batch' +gem 'omniauth' +gem 'openid_connect', '~> 1.3.0' # Pin to 1.3.x for faraday 1.x compatibility with Katello +gem 'omniauth_openid_connect', '~> 0.6.0' # Pin to 0.6.x for openid_connect 1.x compatibility +gem 'omniauth-rails_csrf_protection' # A bundled gem since Ruby 3.0 gem 'rss' diff --git a/OIDC_POC_README.md b/OIDC_POC_README.md new file mode 100644 index 00000000000..b712b0fab4d --- /dev/null +++ b/OIDC_POC_README.md @@ -0,0 +1,390 @@ +# Native OpenID Connect Support - Proof of Concept + +## Overview + +This Proof of Concept implements native OpenID Connect (OIDC) authentication in Foreman, eliminating the dependency on Apache's `mod_auth_openidc`. This implementation was developed with assistance from AI (Claude/Cursor). + +## Key Features + +### 1. **Multiple OIDC Providers** +- Support for multiple OIDC identity providers simultaneously +- Each provider is configured as an `AuthSourceOidc` record +- Users can choose which provider to authenticate with from the login page + +### 2. **Native Application Authentication** +- OIDC logic entirely within the Rails application +- No dependency on Apache or external web server modules + +### 3. **Vendor Agnostic** +- Should work with any OIDC-compliant provider. + +### 4. **Automatic Discovery** +- Uses OIDC Discovery (.well-known/openid-configuration) by default +- Discovery happens automatically at authentication time +- Manual endpoint configuration available for IdPs without discovery support + +### 5. **Secure Token Validation** +- JWT signature verification using JWKS +- Audience (aud) claim validation +- Issuer (iss) claim validation +- Nonce and state validation for replay protection +- Single trust boundary (application layer only) + +### 6. **Flexible User Provisioning** +- Auto-provision users on first login (per provider) +- Link existing users by email (per provider) +- Configurable role mapping from IdP groups (per provider) + +### 7. **Full API & UI Support** +- RESTful API for CRUD operations on OIDC providers +- Web UI for managing OIDC providers (Administer → Authentication Sources) +- Connection testing via API + +## Architecture + +### Components Created + +``` +app/ +├── controllers/ +│ ├── users_controller.rb # OIDC authentication callbacks and login +│ ├── auth_source_oidcs_controller.rb # UI CRUD for OIDC providers +│ ├── concerns/ +│ │ └── foreman/controller/parameters/ +│ │ └── auth_source_oidc.rb # Strong parameters +│ └── api/ +│ └── v2/ +│ └── auth_source_oidcs_controller.rb # API CRUD for OIDC providers +├── models/ +│ ├── auth_sources/ +│ │ └── auth_source_oidc.rb # OIDC Auth Source model +│ └── concerns/ +│ └── user_oidc.rb # User model extensions for OIDC +├── helpers/ +│ ├── auth_source_oidc_helper.rb # UI helpers +│ ├── auth_sources_helper.rb # Extended for OIDC +│ └── login_helper.rb # OIDC providers on login page +└── views/ + ├── auth_source_oidcs/ + │ ├── new.html.erb + │ ├── edit.html.erb + │ ├── _form.html.erb + │ └── _oidc_card_kebab.html.erb + ├── auth_sources/ + │ ├── index.html.erb # Extended with "Create OIDC Source" button + │ └── _auth_source_card.html.erb # Extended for OIDC cards + └── api/v2/auth_source_oidcs/ + ├── index.json.rabl + ├── show.json.rabl + ├── main.json.rabl + ├── create.json.rabl + └── update.json.rabl + +config/ +├── initializers/ +│ └── omniauth.rb # OmniAuth configuration +└── routes/ + └── api/ + └── v2.rb # API v2 routes + +db/ +└── migrate/ + ├── 20251209124615_add_oidc_columns_to_auth_sources.rb + └── 20251209125851_add_oidc_fields_to_users.rb + +webpack/ +└── assets/javascripts/react_app/components/ + └── LoginPage/ + ├── LoginPage.js # Extended with OIDC provider buttons + └── LoginPage.scss # OIDC button styles +``` + +### Authentication Flow + +``` +1. User visits login page (/) + ↓ +2. User clicks "Login with " button (POST to /users/auth/oidc_) + ↓ +3. OmniAuth redirects to IdP authorization endpoint + ↓ +4. User authenticates at IdP + ↓ +5. IdP redirects back to Foreman callback (/users/auth/oidc_/callback) + ↓ +6. OmniAuth exchanges authorization code for tokens + ↓ +7. Foreman validates ID Token (signature, claims, etc.) + ↓ +8. User.from_omniauth finds or creates user + ↓ +9. Session established, user logged in +``` + +## Discovery vs Manual Endpoints + +### Automatic Discovery (Default) + +By default, Foreman uses OIDC Discovery to automatically fetch endpoint URLs from the IdP's `.well-known/openid-configuration` document at authentication time. This is the recommended approach because: + +- **Safer**: IdPs can change their endpoints; discovery ensures you always use current values +- **Simpler**: No need to manually configure endpoint URLs +- **Standard**: Most OIDC providers support discovery + +Just configure the **Issuer URL** and credentials - endpoints are fetched automatically. + +### Manual Endpoints (Fallback) + +Some older or non-standard IdPs don't support the discovery endpoint. For these providers, you can manually configure all required endpoints: + +- Authorization Endpoint +- Token Endpoint +- JWKS URI +- UserInfo Endpoint (optional) +- End Session Endpoint (optional) + +**Important**: If all three required endpoints (authorization, token, jwks_uri) are configured, Foreman will use them instead of discovery. Leave them blank to use automatic discovery. + +## Database Schema + +### AuthSourceOidc Fields (added to auth_sources table) + +| Field | Type | Description | +|-------|------|-------------| +| oidc_issuer | string | OIDC Issuer URL (must be HTTPS) | +| oidc_client_id | string | Client ID | +| oidc_client_secret | text (encrypted) | Client Secret | +| oidc_scopes | string | Scopes (default: "openid email profile") | +| oidc_redirect_uri | string | Callback URL (auto-generated from foreman_url) | +| oidc_authorization_endpoint | string | Authorization endpoint (only for non-discovery IdPs) | +| oidc_token_endpoint | string | Token endpoint (only for non-discovery IdPs) | +| oidc_userinfo_endpoint | string | UserInfo endpoint (only for non-discovery IdPs) | +| oidc_jwks_uri | string | JWKS URI (only for non-discovery IdPs) | +| oidc_end_session_endpoint | string | Logout endpoint (optional) | +| oidc_auto_provision | boolean | Auto-create users (default: false) | +| oidc_email_autolink | boolean | Link users by email (default: false) | +| oidc_groups_claim | string | Groups claim name (default: "groups") | +| oidc_role_mappings | text | YAML role mappings | + +### User Model Extensions + +| Field | Type | Description | +|-------|------|-------------| +| oidc_subject | string | OIDC subject identifier (sub claim) | +| oidc_issuer | string | OIDC issuer that authenticated this user | +| oidc_email | string | Email from OIDC | +| oidc_provider | string | Provider name (oidc_) | + +## Web UI + +### Managing OIDC Providers + +1. Navigate to **Administer → Authentication Sources** +2. Click **Create OIDC Source** +3. Fill in the required fields: + - **Name**: Descriptive name (e.g., "Google", "Keycloak") + - **Issuer URL**: OIDC issuer URL (must be HTTPS) + - **Client ID**: From your IdP + - **Client Secret**: From your IdP +4. (Optional) Configure manual endpoints if your IdP doesn't support discovery +5. Configure options in the **Options** tab: + - Auto-provision users + - Link by email + - Groups claim +6. Submit the form +7. **Restart Foreman** to activate the new provider + +### Login Page + +Once OIDC providers are configured and Foreman is restarted: +- The login page will show "Login with " buttons +- Clicking a button initiates OIDC authentication via POST request +- After successful authentication, the user is logged in + +## API Reference + +### List OIDC Providers +```bash +GET /api/v2/auth_source_oidcs + +curl -u admin:password http://foreman.example.com/api/v2/auth_source_oidcs +``` + +### Show OIDC Provider +```bash +GET /api/v2/auth_source_oidcs/:id + +curl -u admin:password http://foreman.example.com/api/v2/auth_source_oidcs/1 +``` + +### Create OIDC Provider +```bash +POST /api/v2/auth_source_oidcs + +# Standard provider (uses automatic discovery) +curl -u admin:password \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "auth_source_oidc": { + "name": "Google", + "oidc_issuer": "https://accounts.google.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "oidc_auto_provision": true, + "oidc_email_autolink": true + } + }' \ + http://foreman.example.com/api/v2/auth_source_oidcs + +# Provider without discovery support (manual endpoints) +curl -u admin:password \ + -H "Content-Type: application/json" \ + -X POST \ + -d '{ + "auth_source_oidc": { + "name": "Legacy IdP", + "oidc_issuer": "https://legacy-idp.example.com", + "oidc_client_id": "your-client-id", + "oidc_client_secret": "your-client-secret", + "oidc_authorization_endpoint": "https://legacy-idp.example.com/authorize", + "oidc_token_endpoint": "https://legacy-idp.example.com/token", + "oidc_jwks_uri": "https://legacy-idp.example.com/jwks", + "oidc_userinfo_endpoint": "https://legacy-idp.example.com/userinfo" + } + }' \ + http://foreman.example.com/api/v2/auth_source_oidcs +``` + +### Update OIDC Provider +```bash +PUT /api/v2/auth_source_oidcs/:id + +curl -u admin:password \ + -H "Content-Type: application/json" \ + -X PUT \ + -d '{ + "auth_source_oidc": { + "oidc_auto_provision": false + } + }' \ + http://foreman.example.com/api/v2/auth_source_oidcs/1 +``` + +Note: If `oidc_client_secret` is omitted or blank in an update, the existing secret is preserved. + +### Delete OIDC Provider +```bash +DELETE /api/v2/auth_source_oidcs/:id + +curl -u admin:password -X DELETE http://foreman.example.com/api/v2/auth_source_oidcs/1 +``` + +### Check Configuration Status +Returns the current configuration status and whether discovery or manual endpoints are being used: +```bash +GET /api/v2/auth_source_oidcs/:id/status + +curl -u admin:password http://foreman.example.com/api/v2/auth_source_oidcs/1/status +``` + +### Test Connection +Tests connectivity to the OIDC provider by fetching the discovery document: +```bash +GET /api/v2/auth_source_oidcs/:id/test_connection + +curl -u admin:password http://foreman.example.com/api/v2/auth_source_oidcs/1/test_connection +``` + +## Testing + + +1. Navigate to the Foreman login page +2. Click "Login with " button +3. Authenticate at the IdP +4. You should be redirected back and logged in + +## Troubleshooting + +### "No OIDC providers configured" +Create an OIDC provider via the API or UI. + +### "OIDC provider configured but not properly initialized" +Restart Foreman after creating/modifying OIDC providers. OmniAuth configures providers at boot time. + +### "Discovery not available" +If the `/test_connection` endpoint reports discovery is not available: +- The IdP may not support the `.well-known/openid-configuration` endpoint +- Configure manual endpoints (authorization, token, jwks_uri) for this provider + +### CSRF Errors / "Invalid state parameter" +- Ensure cookies are enabled in the browser +- Check that the session is properly maintained between request and callback +- Verify the redirect URI matches exactly what's configured in the IdP +- The application uses `SameSite=Lax` cookies to allow cross-site redirects from IdPs + +### SSL Errors +- The OIDC provider must be accessible over HTTPS +- Ensure SSL certificates are valid and trusted +- Self-signed certificates may cause issues + +### "User already exists" errors +If a user with the same email or OIDC subject already exists: +- Enable `oidc_email_autolink` to automatically link existing users by email +- Manually update the existing user's `oidc_subject` and `oidc_issuer` fields + +### Redirect URI Mismatch +- Check the `redirect_uri` field in the auth source (via API status endpoint or UI) +- Ensure it matches exactly what's configured in your IdP +- The redirect URI is auto-generated based on `Setting[:foreman_url]` + +## Important Notes + +1. **Restart Required**: After adding or modifying OIDC providers, you must restart Foreman for changes to take effect. OmniAuth providers are configured at boot time. + +2. **HTTPS Required**: The `omniauth_openid_connect` gem requires HTTPS for OIDC providers. Self-signed certificates may cause issues. + +3. **Redirect URIs**: The redirect URI format is `/users/auth/oidc_/callback` where `` is the database ID of the AuthSourceOidc record. The redirect URI is auto-generated from `Setting[:foreman_url]` when the auth source is created. + +4. **Provider Names**: Each provider gets a unique name in the format `oidc_` (e.g., `oidc_1`, `oidc_2`). + +5. **Client Secret on Update**: When updating an auth source, if `oidc_client_secret` is left blank, the existing secret is preserved. + +6. **POST-only Login**: Login initiation uses POST requests (not GET) for security - this prevents CSRF attacks via malicious links. + +7. **Discovery vs Manual**: Discovery is used by default and is recommended. Only configure manual endpoints if your IdP doesn't support the `.well-known/openid-configuration` endpoint. + +## Dependencies + +The following gems are required (add to Gemfile): + +```ruby +# Native OIDC support +gem 'omniauth', '~> 2.1' +gem 'omniauth_openid_connect' +gem 'omniauth-rails_csrf_protection', '~> 1.0' +``` + +## Implemented Features + +- [x] Multiple OIDC providers support +- [x] API for managing OIDC providers (CRUD + status + test) +- [x] UI for managing OIDC providers +- [x] OIDC provider buttons on login page +- [x] Automatic OIDC discovery (default) +- [x] Manual endpoint configuration (fallback for non-discovery IdPs) +- [x] User auto-provisioning +- [x] User linking by email +- [x] Secure token validation +- [x] POST-only login initiation (CSRF protection) + +## TODO + +- [ ] Configure permissions needed to manipulate AuthSourceOidc +- [ ] OIDC Authentication via API/hammer +- [ ] Update of redirect uri when the foreman url setting changes. +- [ ] RP-Initiated Logout support (single sign-out) +- [ ] Token refresh handling +- [ ] Group sync/mapping automation +- [ ] Import/export of OIDC configurations +- [ ] Migration from Apache mod_auth_openidc diff --git a/app/controllers/api/v2/auth_source_oidcs_controller.rb b/app/controllers/api/v2/auth_source_oidcs_controller.rb new file mode 100644 index 00000000000..dea20af008d --- /dev/null +++ b/app/controllers/api/v2/auth_source_oidcs_controller.rb @@ -0,0 +1,171 @@ +module Api + module V2 + class AuthSourceOidcsController < V2::BaseController + include Api::Version2 + include Foreman::Controller::Parameters::AuthSourceOidc + + before_action :find_resource, only: [:show, :update, :destroy, :test_connection, :status] + + api :GET, "/auth_source_oidcs/", N_("List all OIDC authentication sources") + param_group :taxonomy_scope, ::Api::V2::BaseController + param_group :search_and_pagination, ::Api::V2::BaseController + add_scoped_search_description_for(AuthSourceOidc) + def index + @auth_source_oidcs = resource_scope_for_index + end + + api :GET, "/auth_source_oidcs/:id/", N_("Show an OIDC authentication source") + param :id, :identifier, required: true + def show + end + + def_param_group :auth_source_oidc do + param :auth_source_oidc, Hash, required: true, action_aware: true do + param :name, String, required: true, desc: N_("Name of the authentication source") + param :oidc_issuer, String, required: true, desc: N_("OIDC Issuer URL (e.g., https://accounts.google.com)") + param :oidc_client_id, String, required: true, desc: N_("OIDC Client ID") + param :oidc_client_secret, String, required: true, desc: N_("OIDC Client Secret") + param :oidc_scopes, String, desc: N_("OIDC scopes (space-separated, default: 'openid email profile')") + param :oidc_authorization_endpoint, String, desc: N_("Authorization endpoint URL (only for IdPs without discovery support)") + param :oidc_token_endpoint, String, desc: N_("Token endpoint URL (only for IdPs without discovery support)") + param :oidc_userinfo_endpoint, String, desc: N_("UserInfo endpoint URL (only for IdPs without discovery support)") + param :oidc_jwks_uri, String, desc: N_("JWKS URI (only for IdPs without discovery support)") + param :oidc_end_session_endpoint, String, desc: N_("End session endpoint for logout (optional)") + param :oidc_auto_provision, :bool, desc: N_("Automatically create users on first login") + param :oidc_email_autolink, :bool, desc: N_("Automatically link existing users by email") + param :oidc_groups_claim, String, desc: N_("Name of the claim containing user groups") + param :oidc_role_mappings, String, desc: N_("YAML mapping of OIDC groups to Foreman roles") + param :onthefly_register, :bool, desc: N_("Enable on-the-fly user creation") + param :location_ids, Array, desc: N_("Replace locations with given ids") + param :organization_ids, Array, desc: N_("Replace organizations with given ids") + end + end + + api :POST, "/auth_source_oidcs/", N_("Create an OIDC authentication source") + param_group :auth_source_oidc, as: :create + def create + @auth_source_oidc = AuthSourceOidc.new(auth_source_oidc_params) + process_response @auth_source_oidc.save + end + + api :PUT, "/auth_source_oidcs/:id/", N_("Update an OIDC authentication source") + param :id, :identifier, required: true + param_group :auth_source_oidc + def update + process_response @auth_source_oidc.update(auth_source_oidc_params) + end + + api :DELETE, "/auth_source_oidcs/:id/", N_("Delete an OIDC authentication source") + param :id, :identifier, required: true + def destroy + process_response @auth_source_oidc.destroy + end + + api :GET, "/auth_source_oidcs/:id/status", N_("Check OIDC configuration status for an authentication source") + param :id, :identifier, required: true + def status + has_manual_endpoints = [ + @auth_source_oidc.oidc_authorization_endpoint, + @auth_source_oidc.oidc_token_endpoint, + @auth_source_oidc.oidc_jwks_uri, + ].all?(&:present?) + + render json: { + id: @auth_source_oidc.id, + name: @auth_source_oidc.name, + issuer: @auth_source_oidc.oidc_issuer, + redirect_uri: @auth_source_oidc.oidc_redirect_uri, + using_discovery: !has_manual_endpoints, + client_id: @auth_source_oidc.oidc_client_id.present? ? "[configured]" : nil, + client_secret: @auth_source_oidc.oidc_client_secret.present? ? "[configured]" : nil, + endpoints: { + authorization: @auth_source_oidc.oidc_authorization_endpoint, + token: @auth_source_oidc.oidc_token_endpoint, + userinfo: @auth_source_oidc.oidc_userinfo_endpoint, + jwks: @auth_source_oidc.oidc_jwks_uri, + end_session: @auth_source_oidc.oidc_end_session_endpoint, + }, + options: { + auto_provision: @auth_source_oidc.oidc_auto_provision, + email_autolink: @auth_source_oidc.oidc_email_autolink, + groups_claim: @auth_source_oidc.oidc_groups_claim, + scopes: @auth_source_oidc.oidc_scopes, + }, + message: has_manual_endpoints ? _("Using manually configured endpoints") : _("Using OIDC discovery"), + }, status: :ok + end + + api :GET, "/auth_source_oidcs/:id/test_connection", N_("Test OIDC connection by fetching discovery document") + param :id, :identifier, required: true + def test_connection + config = fetch_discovery_document(@auth_source_oidc.oidc_issuer) + render json: { + success: true, + message: _("Connection to OIDC provider successful - discovery is supported"), + provider_info: { + issuer: config['issuer'], + scopes_supported: config['scopes_supported'], + response_types_supported: config['response_types_supported'], + grant_types_supported: config['grant_types_supported'], + }, + } + rescue OidcDiscoveryError => e + render json: { + success: false, + message: _("Discovery not available: %s. Manual endpoint configuration required.") % e.message, + }, status: :unprocessable_entity + end + + private + + class OidcDiscoveryError < StandardError; end + + def resource_class + AuthSourceOidc + end + + def action_permission + case params[:action] + when 'test_connection', 'status' + :view + else + super + end + end + + def controller_permission + 'authenticators' + end + + def fetch_discovery_document(issuer_url) + raise OidcDiscoveryError, _("Issuer URL is blank") if issuer_url.blank? + + discovery_url = "#{issuer_url.chomp('/')}/.well-known/openid-configuration" + uri = URI.parse(discovery_url) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + http.open_timeout = 10 + http.read_timeout = 10 + + request = Net::HTTP::Get.new(uri.request_uri) + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) + raise OidcDiscoveryError, _("HTTP %{code} %{message}") % { code: response.code, message: response.message } + end + + JSON.parse(response.body) + rescue URI::InvalidURIError => e + raise OidcDiscoveryError, _("Invalid issuer URL: %{error}") % { error: e.message } + rescue JSON::ParserError => e + raise OidcDiscoveryError, _("Invalid JSON response: %{error}") % { error: e.message } + rescue SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e + raise OidcDiscoveryError, _("Connection failed: %{error}") % { error: e.message } + rescue OpenSSL::SSL::SSLError => e + raise OidcDiscoveryError, _("SSL error: %{error}") % { error: e.message } + end + end + end +end diff --git a/app/controllers/auth_source_oidcs_controller.rb b/app/controllers/auth_source_oidcs_controller.rb new file mode 100644 index 00000000000..d6189daef60 --- /dev/null +++ b/app/controllers/auth_source_oidcs_controller.rb @@ -0,0 +1,49 @@ +class AuthSourceOidcsController < ApplicationController + include Foreman::Controller::Parameters::AuthSourceOidc + + before_action :find_resource, only: [:edit, :update, :destroy] + + def new + @auth_source_oidc = AuthSourceOidc.new + end + + def create + @auth_source_oidc = AuthSourceOidc.new(auth_source_oidc_params) + if @auth_source_oidc.save + process_success success_redirect: auth_sources_path, + success_msg: _("Successfully created OIDC authentication source %s. Please restart Foreman to activate it.") % @auth_source_oidc.name + else + process_error + end + end + + def edit + end + + def update + update_params = auth_source_oidc_params + update_params = update_params.except(:oidc_client_secret) if update_params[:oidc_client_secret].blank? + + if @auth_source_oidc.update(update_params) + process_success success_redirect: auth_sources_path, + success_msg: _("Successfully updated OIDC authentication source %s. Please restart Foreman to apply changes.") % @auth_source_oidc.name + else + process_error + end + end + + def destroy + if @auth_source_oidc.destroy + process_success success_redirect: auth_sources_path, + success_msg: _("Successfully deleted OIDC authentication source. Please restart Foreman to apply changes.") + else + process_error redirect: auth_sources_path + end + end + + private + + def controller_permission + 'authenticators' + end +end diff --git a/app/controllers/concerns/foreman/controller/parameters/auth_source_oidc.rb b/app/controllers/concerns/foreman/controller/parameters/auth_source_oidc.rb new file mode 100644 index 00000000000..31b13c1df5e --- /dev/null +++ b/app/controllers/concerns/foreman/controller/parameters/auth_source_oidc.rb @@ -0,0 +1,32 @@ +module Foreman::Controller::Parameters::AuthSourceOidc + extend ActiveSupport::Concern + include Foreman::Controller::Parameters::Taxonomix + + class_methods do + def auth_source_oidc_params_filter + Foreman::ParameterFilter.new(::AuthSourceOidc).tap do |filter| + filter.permit :name, + :oidc_issuer, + :oidc_client_id, + :oidc_client_secret, + :oidc_scopes, + :oidc_authorization_endpoint, + :oidc_token_endpoint, + :oidc_userinfo_endpoint, + :oidc_jwks_uri, + :oidc_end_session_endpoint, + :oidc_auto_provision, + :oidc_email_autolink, + :oidc_groups_claim, + :oidc_role_mappings, + :onthefly_register + + add_taxonomix_params_filter(filter) + end + end + end + + def auth_source_oidc_params + self.class.auth_source_oidc_params_filter.filter_params(params, parameter_filter_context) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f2b5d81b75e..6c119c653b3 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -7,8 +7,10 @@ class UsersController < ApplicationController rescue_from ActionController::InvalidAuthenticityToken, with: :login_token_reload skip_before_action :require_mail, :only => [:edit, :update, :logout, :stop_impersonation] - skip_before_action :require_login, :check_user_enabled, :authorize, :session_expiry, :update_activity_time, :set_taxonomy, :set_gettext_locale_db, :only => [:login, :logout, :extlogout] + skip_before_action :require_login, :check_user_enabled, :authorize, :session_expiry, :update_activity_time, :set_taxonomy, :set_gettext_locale_db, + :only => [:login, :logout, :extlogout, :oidc_passthru, :oidc_callback, :oidc_failure] skip_before_action :authorize, :only => [:extlogin, :impersonate, :stop_impersonation] + skip_before_action :verify_authenticity_token, :only => [:oidc_passthru, :oidc_callback, :oidc_failure] before_action :require_admin, :only => :impersonate after_action :update_activity_time, :only => :login before_action :verify_active_session, :only => :login @@ -209,6 +211,121 @@ def extlogout render :extlogout, :layout => 'login' end + # ========== OIDC Authentication Methods ========== + + # GET/POST /users/auth/:provider + # Initiates the OIDC authentication flow + # This is handled by OmniAuth middleware, but we need this action as fallback + def oidc_passthru + unless AuthSourceOidc.any? + render_oidc_error( + :service_unavailable, + "OIDC authentication is not available", + "No OIDC authentication providers are configured. Please contact your administrator." + ) + return + end + + provider_name = params[:provider] + auth_source = AuthSourceOidc.find_by_provider_name(provider_name) + + unless auth_source + render_oidc_error( + :not_found, + "Unknown authentication provider", + "The requested authentication provider '#{provider_name}' is not configured." + ) + return + end + + # If OmniAuth didn't intercept, something is wrong with the configuration + render_oidc_error( + :internal_server_error, + "OIDC configuration error", + "OIDC provider '#{auth_source.name}' is configured but not properly initialized. Please restart Foreman." + ) + end + + # GET/POST /users/auth/:provider/callback + # Handles the OIDC callback from the identity provider + def oidc_callback + auth_hash = request.env['omniauth.auth'] + + unless auth_hash + Rails.logger.error "OIDC: No auth hash present in callback" + render_oidc_error( + :unauthorized, + "Authentication failed", + "No authentication data received from identity provider" + ) + return + end + + provider_name = auth_hash.provider + Rails.logger.info "OIDC: Processing authentication callback for provider: #{provider_name}" + + auth_source = AuthSourceOidc.find_by_provider_name(provider_name) + + unless auth_source + Rails.logger.error "OIDC: Unknown provider in callback: #{provider_name}" + render_oidc_error( + :unauthorized, + "Authentication failed", + "Unknown authentication provider" + ) + return + end + + unless validate_oidc_token(auth_hash, auth_source) + render_oidc_error( + :unauthorized, + "Invalid authentication token", + "Token validation failed" + ) + return + end + + user = User.from_omniauth(auth_hash, auth_source) + + if user + Rails.logger.info "OIDC: Successfully authenticated user: #{user.login} via #{auth_source.name}" + + login_user(user) + else + Rails.logger.error "OIDC: User creation/lookup failed for provider #{auth_source.name}" + render_oidc_error( + :forbidden, + "User not authorized", + "Your account could not be provisioned or found. Please contact your administrator." + ) + end + rescue => e + Foreman::Logging.exception("OIDC: Error during authentication", e) + + render_oidc_error( + :internal_server_error, + "Authentication error", + "An error occurred during authentication. Please try again or contact your administrator." + ) + end + + # GET/POST /users/auth/failure + # Handles authentication failures from OmniAuth + def oidc_failure + error_message = params[:message] + error_type = params[:strategy] + + Rails.logger.error "OIDC: Authentication failure - #{error_type}: #{error_message}" + + render_oidc_error( + :unauthorized, + "Authentication failed", + error_message || "An error occurred during authentication" + ) + end + + # ========== End OIDC Methods ========== + def test_mail begin user = find_resource @@ -265,4 +382,52 @@ def login_token_reload(exception) inline_warning _("CSRF protection token expired, please log in again") redirect_to login_users_path end + + # ========== OIDC Private Helpers ========== + + def validate_oidc_token(auth_hash, auth_source) + # The omniauth-openid-connect gem already validates: + # 1. Token signature using JWKS + # 2. Token expiration (exp claim) + # 3. Token not-before (nbf claim) + # 4. Issuer (iss claim) + # 5. Audience (aud claim) - matches client_id + # 6. Nonce to prevent replay attacks + + # Additional validation + id_token = auth_hash.credentials&.id_token + + unless id_token + Rails.logger.error "OIDC: No ID token present" + return false + end + + unless auth_hash.uid.present? + Rails.logger.error "OIDC: No subject (sub) claim in token" + return false + end + + token_issuer = auth_hash.extra&.raw_info&.iss || auth_hash.info&.issuer + + unless token_issuer == auth_source.oidc_issuer + Rails.logger.error "OIDC: Issuer mismatch - expected #{auth_source.oidc_issuer}, got #{token_issuer}" + return false + end + + Rails.logger.info "OIDC: Token validation successful for #{auth_source.name}" + true + rescue => e + Rails.logger.error "OIDC: Token validation error: #{e.message}" + false + end + + def render_oidc_error(status, title, message) + @error_title = title + @error_message = message + + respond_to do |format| + format.html { render 'users/oidc_error', status: status, layout: 'login' } + format.json { render json: { error: title, message: message }, status: status } + end + end end diff --git a/app/helpers/auth_source_oidc_helper.rb b/app/helpers/auth_source_oidc_helper.rb new file mode 100644 index 00000000000..601ee8f8946 --- /dev/null +++ b/app/helpers/auth_source_oidc_helper.rb @@ -0,0 +1,9 @@ +module AuthSourceOidcHelper + def tab_classes_for_auth_source_oidc + { + oidc: 'active', + locations: '', + organizations: '', + } + end +end diff --git a/app/helpers/auth_sources_helper.rb b/app/helpers/auth_sources_helper.rb index 7b0e45f9754..5689f258cea 100644 --- a/app/helpers/auth_sources_helper.rb +++ b/app/helpers/auth_sources_helper.rb @@ -17,6 +17,8 @@ def type_of_auth_source(auth_source) type = "Internal" when "AuthSourceExternal" type = "External" + when "AuthSourceOidc" + type = "OIDC" end type end diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb index 7ff3e4dd531..8e9017b2dc4 100644 --- a/app/helpers/login_helper.rb +++ b/app/helpers/login_helper.rb @@ -5,10 +5,21 @@ def login_props caption: Setting.replace_keywords(Setting[:login_text]), alerts: flash_inline, logoSrc: image_path("login_logo.png"), + oidcProviders: oidc_providers_for_login, } end def mount_login render('common/login', props: login_props) end + + def oidc_providers_for_login + AuthSourceOidc.all.map do |provider| + { + id: provider.id, + name: provider.name, + loginUrl: "/users/auth/#{provider.provider_name}", + } + end + end end diff --git a/app/models/auth_sources/auth_source_oidc.rb b/app/models/auth_sources/auth_source_oidc.rb new file mode 100644 index 00000000000..9eb70ce25ad --- /dev/null +++ b/app/models/auth_sources/auth_source_oidc.rb @@ -0,0 +1,141 @@ +# OpenID Connect Authentication Source +# Allows configuration of multiple OIDC identity providers +class AuthSourceOidc < AuthSource + include Encryptable + include Taxonomix + + encrypts :oidc_client_secret + + validates :oidc_issuer, presence: true, uniqueness: true + validates :oidc_client_id, presence: true + validates :oidc_client_secret, presence: true, on: :create + validates :oidc_scopes, presence: true + validate :validate_oidc_issuer_url + + scoped_search on: :oidc_issuer, complete_value: true + + after_initialize :set_defaults, if: :new_record? + after_create :generate_redirect_uri + + # Update all OIDC redirect URIs when foreman_url changes + def self.update_all_redirect_uris + foreman_url = Setting[:foreman_url] + return if foreman_url.blank? + + find_each do |auth_source| + new_uri = "#{foreman_url.chomp('/')}/users/auth/#{auth_source.provider_name}/callback" + auth_source.update_column(:oidc_redirect_uri, new_uri) + Rails.logger.info "OIDC: Updated redirect_uri for '#{auth_source.name}': #{new_uri}" + end + end + + def authenticate(login, password) + # OIDC doesn't use password authentication + # Authentication is handled via OmniAuth callbacks + nil + end + + def auth_method_name + "OIDC" + end + + alias_method :to_label, :auth_method_name + + # assumes every user is valid as authentication is handled by the OIDC provider + def valid_user?(name) + name.present? + end + + def supports_refresh? + false + end + + def provider_name + "oidc_#{id}" + end + + def self.find_by_provider_name(provider_name) + return nil if provider_name.blank? + id = provider_name.delete_prefix('oidc_') + find_by(id: id) + end + + def scopes_array + (oidc_scopes || 'openid email profile').split(/[\s,]+/).map(&:to_sym) + end + + def role_mappings_hash + return {} if oidc_role_mappings.blank? + YAML.safe_load(oidc_role_mappings, permitted_classes: [Symbol]) rescue {} + end + + def role_mappings_hash=(mappings) + self.oidc_role_mappings = mappings.to_yaml + end + + def uses_manual_endpoints? + oidc_authorization_endpoint.present? && + oidc_token_endpoint.present? && + oidc_jwks_uri.present? + end + + def issuer_uri + @issuer_uri ||= URI.parse(oidc_issuer) if oidc_issuer.present? + end + + def redirect_uri + oidc_redirect_uri + end + + def login_url + "/users/auth/#{provider_name}" + end + + private + + def set_defaults + self.oidc_scopes ||= 'openid email profile' + self.oidc_groups_claim ||= 'groups' + self.oidc_auto_provision ||= false + self.oidc_email_autolink ||= false + end + + def generate_redirect_uri + foreman_url = Setting[:foreman_url] + if foreman_url.blank? + logger.warn "OIDC: Setting[:foreman_url] is not configured. Configure the setting to generate the redirect URI." + return + end + + callback_path = "/users/auth/#{provider_name}/callback" + self.oidc_redirect_uri = "#{foreman_url.chomp('/')}#{callback_path}" + + update_column(:oidc_redirect_uri, oidc_redirect_uri) + logger.info "OIDC: Generated redirect_uri for '#{name}': #{oidc_redirect_uri}" + end + + def validate_oidc_issuer_url + return if oidc_issuer.blank? + + begin + uri = URI.parse(oidc_issuer) + + if uri.scheme.blank? + errors.add(:oidc_issuer, "must be a valid URL with https:// scheme (e.g., https://accounts.google.com)") + return + end + + unless uri.scheme == 'https' + errors.add(:oidc_issuer, "must use https:// scheme for security (got #{uri.scheme}://)") + return + end + + if uri.host.blank? + errors.add(:oidc_issuer, "must include a valid hostname") + nil + end + rescue URI::InvalidURIError => e + errors.add(:oidc_issuer, "is not a valid URL: #{e.message}") + end + end +end diff --git a/app/models/concerns/user_oidc.rb b/app/models/concerns/user_oidc.rb new file mode 100644 index 00000000000..fd3f7bc4eed --- /dev/null +++ b/app/models/concerns/user_oidc.rb @@ -0,0 +1,147 @@ +# Concern for OIDC authentication support +module UserOidc + extend ActiveSupport::Concern + + included do + validates :oidc_subject, uniqueness: { scope: :oidc_issuer }, allow_nil: true + end + + class_methods do + # Find or create a user from OIDC authentication data + # @param auth_hash [OmniAuth::AuthHash] The authentication hash from OmniAuth + # @param auth_source [AuthSourceOidc] The OIDC auth source that authenticated this user + # @return [User, nil] The user object or nil if creation fails + def from_omniauth(auth_hash, auth_source = nil) + # Get issuer from auth source (most reliable) or auth_hash + issuer = auth_source&.oidc_issuer || auth_hash.extra&.raw_info&.iss || auth_hash.info&.issuer + subject = auth_hash.uid + email = auth_hash.info&.email + name = auth_hash.info&.name + + Rails.logger.info "OIDC: Attempting to authenticate user with subject: #{subject}, issuer: #{issuer}" + + # FIRST: Try to find by subject and issuer - this is the most reliable lookup + user = User.unscoped.find_by(oidc_subject: subject, oidc_issuer: issuer) + if user + Rails.logger.info "OIDC: Found existing user #{user.login} by OIDC subject" + update_oidc_user(user, auth_hash) + return user + end + + Rails.logger.info "OIDC: No user found with subject #{subject}, checking other methods..." + + # Try to find by email if OIDC linking is enabled + email_autolink = auth_source&.oidc_email_autolink + if email_autolink && email.present? + user = User.unscoped.find_by(mail: email) + if user + Rails.logger.info "OIDC: Linking existing user #{user.login} to OIDC identity" + link_oidc_identity(user, subject, issuer, email, auth_source) + return user + end + end + + # Create new user if auto-provisioning is enabled + auto_provision = auth_source&.oidc_auto_provision + if auto_provision + Rails.logger.info "OIDC: Auto-provisioning new user" + create_from_oidc(auth_hash, subject, issuer, email, name, auth_source) + else + Rails.logger.warn "OIDC: Auto-provisioning disabled, rejecting user" + nil + end + end + + private + + def update_oidc_user(user, auth_hash) + user.update( + oidc_email: auth_hash.info&.email, + last_login_on: Time.current + ) + end + + def link_oidc_identity(user, subject, issuer, email, auth_source) + attrs = { + oidc_subject: subject, + oidc_issuer: issuer, + oidc_email: email, + oidc_provider: auth_source&.provider_name || 'openid_connect', + } + attrs[:auth_source] = auth_source if auth_source + user.update!(attrs) + end + + def create_from_oidc(auth_hash, subject, issuer, email, name, auth_source) + firstname, lastname = parse_name(name) + login = generate_login(email, subject) + + groups_claim = auth_source&.oidc_groups_claim || 'groups' + groups = extract_groups(auth_hash, groups_claim) + + # Create user with admin privileges to bypass authorization checks + user = nil + User.as_anonymous_admin do + user = new( + login: login, + mail: email, + firstname: firstname, + lastname: lastname, + oidc_subject: subject, + oidc_issuer: issuer, + oidc_email: email, + oidc_provider: auth_source.provider_name, + auth_source: auth_source, + last_login_on: Time.current + ) + + if user.save + Rails.logger.info "OIDC: Created new user #{user.login}" + # Inherit locations and organizations from auth source + user.locations = auth_source.locations + user.organizations = auth_source.organizations + assign_oidc_roles(user, groups, auth_source) + else + Rails.logger.error "OIDC: Failed to create user: #{user.errors.full_messages.join(', ')}" + user = nil + end + end + + user + end + + def parse_name(name) + return ['', ''] if name.blank? + parts = name.split(' ', 2) + [parts[0] || '', parts[1] || ''] + end + + def generate_login(email, subject) + if email.present? + email.split('@').first + else + "oidc_#{subject}".gsub(/[^a-zA-Z0-9_-]/, '_')[0..99] + end + end + + def extract_groups(auth_hash, groups_claim) + auth_hash.extra&.raw_info&.[](groups_claim) || [] + end + + def assign_oidc_roles(user, groups, auth_source) + return unless groups.any? + + role_mappings = auth_source&.role_mappings_hash || {} + + groups.each do |group| + role_names = role_mappings[group] + next unless role_names + + Array(role_names).each do |role_name| + role = Role.find_by(name: role_name) + user.roles << role if role && !user.roles.include?(role) + end + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index bbf2c60dd3c..58f8fc4918e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord include TopbarCacheExpiry include JwtAuth include Foreman::ObservableModel + include UserOidc ANONYMOUS_ADMIN = 'foreman_admin' ANONYMOUS_API_ADMIN = 'foreman_api_admin' diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb index 2e13ac99a6d..713e7aa9819 100644 --- a/app/validators/email_validator.rb +++ b/app/validators/email_validator.rb @@ -11,6 +11,6 @@ def validate_each(record, attribute, value) Foreman::Logging.exception("Email address is invalid", exception) r = false end - record.errors.add(attribute, (options[:message] || N_("is invalid"))) unless r + record.errors.add(attribute, (options[:message] || N_("is not a valid email address"))) unless r end end diff --git a/app/views/api/v2/auth_source_oidcs/create.json.rabl b/app/views/api/v2/auth_source_oidcs/create.json.rabl new file mode 100644 index 00000000000..a40e15f0ad4 --- /dev/null +++ b/app/views/api/v2/auth_source_oidcs/create.json.rabl @@ -0,0 +1,3 @@ +object @auth_source_oidc + +extends "api/v2/auth_source_oidcs/show" diff --git a/app/views/api/v2/auth_source_oidcs/index.json.rabl b/app/views/api/v2/auth_source_oidcs/index.json.rabl new file mode 100644 index 00000000000..ee102354e3a --- /dev/null +++ b/app/views/api/v2/auth_source_oidcs/index.json.rabl @@ -0,0 +1,3 @@ +collection @auth_source_oidcs + +extends "api/v2/auth_source_oidcs/main" diff --git a/app/views/api/v2/auth_source_oidcs/main.json.rabl b/app/views/api/v2/auth_source_oidcs/main.json.rabl new file mode 100644 index 00000000000..aa21e985bd5 --- /dev/null +++ b/app/views/api/v2/auth_source_oidcs/main.json.rabl @@ -0,0 +1,3 @@ +object @auth_source_oidc + +attributes :id, :type, :name, :oidc_issuer, :oidc_client_id, :oidc_scopes, :oidc_redirect_uri diff --git a/app/views/api/v2/auth_source_oidcs/show.json.rabl b/app/views/api/v2/auth_source_oidcs/show.json.rabl new file mode 100644 index 00000000000..d1ffcfa2061 --- /dev/null +++ b/app/views/api/v2/auth_source_oidcs/show.json.rabl @@ -0,0 +1,19 @@ +object @auth_source_oidc + +extends "api/v2/auth_source_oidcs/main" + +attributes :oidc_authorization_endpoint, :oidc_token_endpoint, :oidc_userinfo_endpoint, + :oidc_jwks_uri, :oidc_end_session_endpoint, :oidc_auto_provision, + :oidc_email_autolink, :oidc_groups_claim, :oidc_role_mappings, + :onthefly_register, :created_at, :updated_at + +node(:login_url) { |auth_source| auth_source.login_url } +node(:redirect_uri) { |auth_source| auth_source.redirect_uri } + +child :locations do + extends "api/v2/taxonomies/base" +end + +child :organizations do + extends "api/v2/taxonomies/base" +end diff --git a/app/views/api/v2/auth_source_oidcs/update.json.rabl b/app/views/api/v2/auth_source_oidcs/update.json.rabl new file mode 100644 index 00000000000..a40e15f0ad4 --- /dev/null +++ b/app/views/api/v2/auth_source_oidcs/update.json.rabl @@ -0,0 +1,3 @@ +object @auth_source_oidc + +extends "api/v2/auth_source_oidcs/show" diff --git a/app/views/auth_source_oidcs/_form.html.erb b/app/views/auth_source_oidcs/_form.html.erb new file mode 100644 index 00000000000..42aaf1ecee4 --- /dev/null +++ b/app/views/auth_source_oidcs/_form.html.erb @@ -0,0 +1,100 @@ +<%= form_for @auth_source_oidc do |f| %> + <%= base_errors_for @auth_source_oidc %> + + + +
+
+ <%= text_f f, :name, help_inline: _("A descriptive name for this OIDC provider (e.g., 'Google', 'Keycloak')") %> + + <%= text_f f, :oidc_issuer, + label: _("Issuer URL"), + help_inline: _("The OIDC issuer URL (must be HTTPS, e.g., https://accounts.google.com)"), + required: true %> + + <%= text_f f, :oidc_client_id, + label: _("Client ID"), + help_inline: _("The client ID registered with your identity provider"), + required: true %> + <%= password_f f, :oidc_client_secret, + label: _("Client Secret"), + help_inline: @auth_source_oidc.persisted? ? _("Leave blank to keep existing secret") : _("The client secret from your identity provider"), + required: !@auth_source_oidc.persisted? %> + <%= text_f f, :oidc_scopes, + label: _("Scopes"), + help_inline: _("Space-separated list of OIDC scopes (default: openid email profile)") %> + + <% if @auth_source_oidc.persisted? && @auth_source_oidc.oidc_redirect_uri.present? %> +
+ +
+

+ <%= @auth_source_oidc.oidc_redirect_uri %> +
+ <%= _("Configure this URL as an authorized redirect URI in your identity provider") %> +

+
+
+ <% end %> + +
+

<%= _("Manual Endpoints") %>

+
+ + <%= _("Most OIDC providers support automatic discovery. Only configure these endpoints manually if your identity provider does not support the .well-known/openid-configuration endpoint.") %> +
+ + <%= text_f f, :oidc_authorization_endpoint, + label: _("Authorization Endpoint"), + help_inline: _("URL for user authorization (leave blank to use discovery)") %> + <%= text_f f, :oidc_token_endpoint, + label: _("Token Endpoint"), + help_inline: _("URL to exchange authorization code for tokens (leave blank to use discovery)") %> + <%= text_f f, :oidc_userinfo_endpoint, + label: _("UserInfo Endpoint"), + help_inline: _("URL to fetch user information (leave blank to use discovery)") %> + <%= text_f f, :oidc_jwks_uri, + label: _("JWKS URI"), + help_inline: _("URL to fetch JSON Web Key Set for token verification (leave blank to use discovery)") %> + <%= text_f f, :oidc_end_session_endpoint, + label: _("End Session Endpoint"), + help_inline: _("URL for logout (optional, enables single sign-out)") %> +
+ +
+ <%= checkbox_f f, :oidc_auto_provision, + label: _("Auto-provision users"), + help_inline: _("Automatically create new user accounts on first OIDC login") %> + <%= checkbox_f f, :oidc_email_autolink, + label: _("Link by email"), + help_inline: _("Automatically link existing users by matching email address") %> + <%= text_f f, :oidc_groups_claim, + label: _("Groups claim"), + help_inline: _("Name of the claim in the ID token that contains user groups (default: groups)") %> + <%= checkbox_f f, :onthefly_register, + label: _("On-the-fly registration"), + help_inline: _("Enable on-the-fly user creation") %> +
+ + <%= render 'taxonomies/loc_org_tabs', f: f, obj: @auth_source_oidc %> +
+ + <%= submit_or_cancel f, false, cancel_path: auth_sources_path %> +<% end %> diff --git a/app/views/auth_source_oidcs/_oidc_card_kebab.html.erb b/app/views/auth_source_oidcs/_oidc_card_kebab.html.erb new file mode 100644 index 00000000000..d3124b84067 --- /dev/null +++ b/app/views/auth_source_oidcs/_oidc_card_kebab.html.erb @@ -0,0 +1,13 @@ + diff --git a/app/views/auth_source_oidcs/edit.html.erb b/app/views/auth_source_oidcs/edit.html.erb new file mode 100644 index 00000000000..057423e82c1 --- /dev/null +++ b/app/views/auth_source_oidcs/edit.html.erb @@ -0,0 +1,15 @@ +<%= breadcrumbs( + items: [ + { + caption: _("Authentication Sources"), + url: auth_sources_path + }, + { + caption: _("Edit OIDC Authentication Source"), + } + ], + switchable: false, +) +%> +<% title _("Edit OIDC Authentication Source") %> +<%= render partial: 'form' %> diff --git a/app/views/auth_source_oidcs/new.html.erb b/app/views/auth_source_oidcs/new.html.erb new file mode 100644 index 00000000000..f1d3845a462 --- /dev/null +++ b/app/views/auth_source_oidcs/new.html.erb @@ -0,0 +1,15 @@ +<%= breadcrumbs( + items: [ + { + caption: _("Authentication Sources"), + url: auth_sources_path + }, + { + caption: _("Create OIDC Authentication Source"), + } + ], + switchable: false, +) +%> +<% title _("Create OIDC Authentication Source") %> +<%= render partial: 'form' %> diff --git a/app/views/auth_sources/_auth_source_card.html.erb b/app/views/auth_sources/_auth_source_card.html.erb index c01476bdd1d..1b40583bff3 100644 --- a/app/views/auth_sources/_auth_source_card.html.erb +++ b/app/views/auth_sources/_auth_source_card.html.erb @@ -3,12 +3,16 @@
<% if auth_source.type == 'AuthSourceLdap' %> <%= render :partial => 'auth_source_ldaps/ldap_card_kebab', :locals => {:auth_source => auth_source, :users => users} %> - <% end %> - <% if auth_source.type == 'AuthSourceExternal' %> + <% elsif auth_source.type == 'AuthSourceExternal' %> <%= render :partial => 'auth_source_externals/external_card_kebab', :locals => {:auth_source => auth_source, :users => users} %> + <% elsif auth_source.type == 'AuthSourceOidc' %> + <%= render :partial => 'auth_source_oidcs/oidc_card_kebab', :locals => {:auth_source => auth_source, :users => users} %> <% end %>

<%= type_of_auth_source(auth_source) %> + <% if auth_source.type == 'AuthSourceOidc' %> +
<%= auth_source.name %> + <% end %>

diff --git a/app/views/auth_sources/index.html.erb b/app/views/auth_sources/index.html.erb index 61783155d91..813a9bda459 100644 --- a/app/views/auth_sources/index.html.erb +++ b/app/views/auth_sources/index.html.erb @@ -1,4 +1,9 @@ <% title _("Authentication Sources") %> +<% title_actions( + new_link(_("Create LDAP Source"), hash_for_new_auth_source_ldap_path, { title: _('Create a new LDAP authentication source'), class: 'btn btn-default' }), + new_link(_("Create OIDC Source"), hash_for_new_auth_source_oidc_path, { title: _('Create a new OIDC authentication source'), class: 'btn btn-default' }) +) %> +
<% @auth_sources.each do |auth_source| %> diff --git a/app/views/users/oidc_error.html.erb b/app/views/users/oidc_error.html.erb new file mode 100644 index 00000000000..367c795799f --- /dev/null +++ b/app/views/users/oidc_error.html.erb @@ -0,0 +1,9 @@ +<% content_for(:title, @error_title) %> +
+
+ <%= icon_text("exclamation-triangle", "", :kind => "fa") %> +
+

<%= @error_title %>

+

<%= @error_message %>

+

<%= link_to(_('Back to login'), login_users_path, class: 'btn btn-default') %>

+
diff --git a/config/application.rb b/config/application.rb index cec32b03be6..850e91a56b3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -335,7 +335,14 @@ class Application < Rails::Application end # Use the database for sessions instead of the cookie-based default - config.session_store :active_record_store, :secure => !!SETTINGS[:require_ssl] + # SameSite=Lax allows session cookie to be sent on OIDC redirects from IdPs + # key: explicit session cookie name + # domain: :all allows the cookie to work across subdomains + config.session_store :active_record_store, + key: '_foreman_session', + secure: !!SETTINGS[:require_ssl], + same_site: :lax, + domain: :all # We need to mount the sprockets engine before we use the routes_reloader initializer(:mount_sprocket_env, :before => :sooner_routes_load) do diff --git a/config/initializers/email_validator_override.rb b/config/initializers/email_validator_override.rb new file mode 100644 index 00000000000..f8c68574ed1 --- /dev/null +++ b/config/initializers/email_validator_override.rb @@ -0,0 +1,8 @@ +# Override validate_email gem's EmailValidator with Foreman's implementation. +# The openid_connect gem depends on validate_email which provides its own +# EmailValidator in ActiveModel::Validations namespace. We need Foreman's +# custom validator (with length checking and single-word domain support). +# +# Load Foreman's validator and assign it to the namespace Rails uses. +require Rails.root.join('app/validators/email_validator') +ActiveModel::Validations::EmailValidator = EmailValidator diff --git a/config/initializers/f_foreman_permissions.rb b/config/initializers/f_foreman_permissions.rb index 70056a54f0d..c406be7571b 100644 --- a/config/initializers/f_foreman_permissions.rb +++ b/config/initializers/f_foreman_permissions.rb @@ -42,17 +42,24 @@ :"api/v2/auth_sources" => [:index, :show], :"api/v2/auth_source_internals" => [:index, :show], :"api/v2/auth_source_externals" => [:index, :show], + :"api/v2/auth_source_oidcs" => [:index, :show, :status, :test_connection], } map.permission :create_authenticators, {:auth_source_ldaps => [:new, :create].push(*ajax_actions), :"api/v2/auth_source_ldaps" => [:create], + :auth_source_oidcs => [:new, :create], + :"api/v2/auth_source_oidcs" => [:create], } map.permission :edit_authenticators, {:auth_source_ldaps => [:edit, :update].push(*ajax_actions), :auth_source_externals => [:edit, :update], :"api/v2/auth_source_ldaps" => [:update, :test], :"api/v2/auth_source_externals" => [:update], + :auth_source_oidcs => [:edit, :update], + :"api/v2/auth_source_oidcs" => [:update], } map.permission :destroy_authenticators, {:auth_source_ldaps => [:destroy], :"api/v2/auth_source_ldaps" => [:destroy], + :auth_source_oidcs => [:destroy], + :"api/v2/auth_source_oidcs" => [:destroy], } end diff --git a/config/initializers/oidc_setting_callbacks.rb b/config/initializers/oidc_setting_callbacks.rb new file mode 100644 index 00000000000..3d2b036c3a6 --- /dev/null +++ b/config/initializers/oidc_setting_callbacks.rb @@ -0,0 +1,18 @@ +# Callback to update OIDC redirect URIs when foreman_url setting changes +# This ensures redirect URIs stay in sync with the configured Foreman URL +# We cannot compose the URI from the setting dynamically because it is not available at boot time. + +Rails.application.config.to_prepare do + Setting.class_eval do + after_save :update_oidc_redirect_uris_if_foreman_url_changed + + private + + def update_oidc_redirect_uris_if_foreman_url_changed + return unless name == 'foreman_url' && saved_change_to_value? + + Rails.logger.info "OIDC: foreman_url changed, updating all OIDC redirect URIs" + AuthSourceOidc.update_all_redirect_uris + end + end +end diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 00000000000..d07e3e7baca --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,80 @@ +# OmniAuth configuration for native OIDC support +# This initializer configures OmniAuth providers based on AuthSourceOidc records + +Rails.application.config.middleware.use OmniAuth::Builder do + configure do |config| + config.path_prefix = '/users/auth' + config.logger = Rails.logger if Rails.env.development? + config.allowed_request_methods = [:post, :get] + end + + begin + if ActiveRecord::Base.connection.table_exists?('auth_sources') + AuthSourceOidc.all.each do |auth_source| + Rails.logger.debug "OIDC: Configuring provider '#{auth_source.name}' (#{auth_source.provider_name}) for issuer #{auth_source.oidc_issuer}" + + issuer_uri = URI.parse(auth_source.oidc_issuer) + use_discovery = !auth_source.uses_manual_endpoints? + redirect_uri = auth_source.oidc_redirect_uri + + if redirect_uri.blank? + Rails.logger.warn "OIDC: Provider '#{auth_source.name}' has no redirect_uri configured. " \ + "Ensure foreman_url setting is configured and restart Foreman." + next + end + + provider_options = { + name: auth_source.provider_name, + issuer: auth_source.oidc_issuer, + discovery: use_discovery, + scope: auth_source.scopes_array, + response_type: :code, + uid_field: 'sub', + client_options: { + identifier: auth_source.oidc_client_id, + secret: auth_source.oidc_client_secret, + redirect_uri: redirect_uri, + scheme: issuer_uri.scheme, + host: issuer_uri.host, + port: issuer_uri.port, + }, + } + + unless use_discovery + provider_options.merge!( + authorization_endpoint: auth_source.oidc_authorization_endpoint, + token_endpoint: auth_source.oidc_token_endpoint, + userinfo_endpoint: auth_source.oidc_userinfo_endpoint, + jwks_uri: auth_source.oidc_jwks_uri, + end_session_endpoint: auth_source.oidc_end_session_endpoint + ) + end + + provider :openid_connect, provider_options + + mode = use_discovery ? 'discovery' : 'manual endpoints' + Rails.logger.debug "OIDC: Provider '#{auth_source.name}' configured (#{mode}, host: #{issuer_uri.host})" + end + + provider_count = AuthSourceOidc.count + if provider_count == 0 + Rails.logger.debug "OIDC: No OIDC providers configured" + else + Rails.logger.debug "OIDC: Configured #{provider_count} OIDC provider(s)" + end + else + Rails.logger.debug "OIDC: auth_sources table does not exist yet" + end + rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad => e + Foreman::Logging.exception("OIDC: Database not available, skipping provider configuration", e, :level => :warn) + rescue => e + Foreman::Logging.exception("OIDC: Failed to configure providers", e, :level => :error) + end +end + +OmniAuth.config.on_failure = proc { |env| + OmniAuth::FailureEndpoint.new(env).redirect_to_failure +} + +OmniAuth.config.logger = Rails.logger +OmniAuth.config.silence_get_warning = true if Rails.env.development? diff --git a/config/routes.rb b/config/routes.rb index 959f785f59f..edab7ea7b5b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,14 @@ Foreman::Application.routes.draw do apipie_dsl + + # POST only for login initiation (security best practice - prevents CSRF via GET) + post '/users/auth/:provider', to: 'users#oidc_passthru', as: :oidc_login, + constraints: { provider: /oidc_\d+/ } + # Callback accepts GET (IdP redirect) and POST (form_post response mode) + match '/users/auth/:provider/callback', to: 'users#oidc_callback', via: [:get, :post], as: :oidc_callback, + constraints: { provider: /oidc_\d+/ } + match '/users/auth/failure', to: 'users#oidc_failure', via: [:get, :post], as: :oidc_failure + resources :mail_notifications, only: [] do collection do get 'auto_complete_search' @@ -294,6 +303,7 @@ resources :auth_sources, only: [:show, :index] resources :auth_source_externals, only: [:update, :edit] + resources :auth_source_oidcs, except: [:show, :index] put 'users/(:id)/test_mail', to: 'users#test_mail', as: 'test_mail_user' diff --git a/config/routes/api/v2.rb b/config/routes/api/v2.rb index 664e570e54b..f1be945733d 100644 --- a/config/routes/api/v2.rb +++ b/config/routes/api/v2.rb @@ -43,6 +43,14 @@ resources :external_usergroups, :except => [:new, :edit] end + resources :auth_source_oidcs, :except => [:new, :edit] do + resources :locations, :only => [:index, :show] + resources :organizations, :only => [:index, :show] + resources :users, :except => [:new, :edit] + end + get 'auth_source_oidcs/:id/status', :to => 'auth_source_oidcs#status' + get 'auth_source_oidcs/:id/test_connection', :to => 'auth_source_oidcs#test_connection' + resources :bookmarks, :except => [:new, :edit] resources :common_parameters, :except => [:new, :edit] diff --git a/db/migrate/20251209124615_add_oidc_columns_to_auth_sources.rb b/db/migrate/20251209124615_add_oidc_columns_to_auth_sources.rb new file mode 100644 index 00000000000..034b36a1b00 --- /dev/null +++ b/db/migrate/20251209124615_add_oidc_columns_to_auth_sources.rb @@ -0,0 +1,23 @@ +class AddOidcColumnsToAuthSources < ActiveRecord::Migration[7.0] + def change + add_column :auth_sources, :oidc_issuer, :string + add_column :auth_sources, :oidc_client_id, :string + add_column :auth_sources, :oidc_client_secret, :text # Encrypted + add_column :auth_sources, :oidc_scopes, :string, default: 'openid email profile' + + add_column :auth_sources, :oidc_authorization_endpoint, :string + add_column :auth_sources, :oidc_token_endpoint, :string + add_column :auth_sources, :oidc_userinfo_endpoint, :string + add_column :auth_sources, :oidc_jwks_uri, :string + add_column :auth_sources, :oidc_end_session_endpoint, :string + add_column :auth_sources, :oidc_redirect_uri, :string + + add_column :auth_sources, :oidc_auto_provision, :boolean, default: false + add_column :auth_sources, :oidc_email_autolink, :boolean, default: false + + add_column :auth_sources, :oidc_groups_claim, :string, default: 'groups' + add_column :auth_sources, :oidc_role_mappings, :text # Stored as YAML/JSON + + add_index :auth_sources, :oidc_issuer + end +end diff --git a/db/migrate/20251209125851_add_oidc_fields_to_users.rb b/db/migrate/20251209125851_add_oidc_fields_to_users.rb new file mode 100644 index 00000000000..8eb53a6a098 --- /dev/null +++ b/db/migrate/20251209125851_add_oidc_fields_to_users.rb @@ -0,0 +1,10 @@ +class AddOidcFieldsToUsers < ActiveRecord::Migration[7.0] + def change + add_column :users, :oidc_subject, :string + add_column :users, :oidc_issuer, :string + add_column :users, :oidc_email, :string + add_column :users, :oidc_provider, :string, default: 'openid_connect' + + add_index :users, [:oidc_subject, :oidc_issuer], unique: true, name: 'index_users_on_oidc_subject_and_issuer' + end +end diff --git a/test/helpers/form_helper_test.rb b/test/helpers/form_helper_test.rb index 212ae7237c8..74960cf4ba4 100644 --- a/test/helpers/form_helper_test.rb +++ b/test/helpers/form_helper_test.rb @@ -81,7 +81,7 @@ class FormHelperTest < ActionView::TestCase html = field(f, :login, :error => user.errors[:mail]) do 'zzz' end - assert_match /is invalid/, html + assert_match /is not a valid email address/, html end end diff --git a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.fixtures.js b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.fixtures.js index a31f578d74f..cd2e61ba0c5 100644 --- a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.fixtures.js +++ b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.fixtures.js @@ -2,10 +2,23 @@ export const alerts = { error: 'some-error' }; export const caption = 'some caption'; export const logoSrc = '/some-logo'; export const token = 'some token'; +export const oidcProviders = [ + { id: 1, name: 'Google', loginUrl: '/users/auth/oidc_1' }, + { id: 2, name: 'Keycloak', loginUrl: '/users/auth/oidc_2' }, +]; export const props = { alerts, caption, logoSrc, token, + oidcProviders: [], +}; + +export const propsWithOidc = { + alerts, + caption, + logoSrc, + token, + oidcProviders, }; diff --git a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.js b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.js index 81ecccc3973..09098f7d164 100644 --- a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.js +++ b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import PropTypes from 'prop-types'; import { LoginPage as PF5LoginPage, @@ -16,7 +16,7 @@ import { translate as __ } from '../../common/I18n'; import { adjustAlerts, defaultFormProps } from './helpers'; import './LoginPage.scss'; -const LoginPage = ({ alerts, caption, logoSrc, token }) => { +const LoginPage = ({ alerts, caption, logoSrc, token, oidcProviders = [] }) => { const { modifiedAlerts, submitErrors } = adjustAlerts(alerts); const [username, setUsername] = useState(''); @@ -24,6 +24,8 @@ const LoginPage = ({ alerts, caption, logoSrc, token }) => { const [isLoginDisabled, setIsLoginDisabled] = useState(true); const [isLoading, setIsLoading] = useState(false); const [alertArr, setAlertArr] = useState(modifiedAlerts); + const [loadingProvider, setLoadingProvider] = useState(null); + const closeAlert = index => { const other = alertArr.filter((_, i) => i !== index); setAlertArr(other); @@ -46,6 +48,12 @@ const LoginPage = ({ alerts, caption, logoSrc, token }) => { }, 10); }; + // Handle OIDC provider login via POST form submission + const handleOidcLogin = (provider, formRef) => { + setLoadingProvider(provider.id); + formRef.current.submit(); + }; + const loginForm = (
{submitErrors.length > 0 && ( @@ -111,6 +119,58 @@ const LoginPage = ({ alerts, caption, logoSrc, token }) => {
); + // OIDC Provider Buttons Component + const OidcProviderButton = ({ provider }) => { + const formRef = useRef(null); + return ( +
+ + +
+ ); + }; + + OidcProviderButton.propTypes = { + provider: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + loginUrl: PropTypes.string.isRequired, + }).isRequired, + }; + + // eslint-disable-next-line spellcheck/spell-checker + const oidcSection = oidcProviders.length > 0 && ( + // eslint-disable-next-line spellcheck/spell-checker +
+ {/* eslint-disable-next-line spellcheck/spell-checker */} +
+ {__('Or login with')} +
+ {/* eslint-disable-next-line spellcheck/spell-checker */} +
+ {oidcProviders.map(provider => ( + // eslint-disable-next-line react/prop-types + + ))} +
+
+ ); + return (
{ textContent={caption} > {loginForm} + {oidcSection}
); @@ -136,6 +197,13 @@ LoginPage.propTypes = { caption: PropTypes.string, logoSrc: PropTypes.string, token: PropTypes.string.isRequired, + oidcProviders: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + loginUrl: PropTypes.string.isRequired, + }) + ), }; LoginPage.defaultProps = { @@ -143,6 +211,7 @@ LoginPage.defaultProps = { backgroundUrl: null, caption: null, logoSrc: null, + oidcProviders: [], }; export default LoginPage; diff --git a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.scss b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.scss index a43ff8b467f..4d9450db217 100644 --- a/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.scss +++ b/webpack/assets/javascripts/react_app/components/LoginPage/LoginPage.scss @@ -16,3 +16,38 @@ $background_image: url('../LoginPage/background.svg'); .pf-v5-c-login__footer p { overflow-wrap: break-word; } + +.oidc-login-section { + margin-top: 24px; + + .oidc-divider { + display: flex; + align-items: center; + text-align: center; + margin: 16px 0; + color: #6a6e73; + + &::before, + &::after { + content: ''; + flex: 1; + border-bottom: 1px solid #d2d2d2; + } + + span { + padding: 0 15px; + font-size: 14px; + white-space: nowrap; + } + } + + .oidc-buttons { + display: flex; + flex-direction: column; + gap: 12px; + } + + .oidc-provider-form { + margin: 0; + } +} diff --git a/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/LoginPage.test.js b/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/LoginPage.test.js index af2dbdc11f0..65005d9e1c6 100644 --- a/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/LoginPage.test.js +++ b/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/LoginPage.test.js @@ -1,9 +1,10 @@ import LoginPage from '../LoginPage'; -import { props } from '../LoginPage.fixtures'; +import { props, propsWithOidc } from '../LoginPage.fixtures'; import { testComponentSnapshotsWithFixtures } from '../../../common/testHelpers'; const fixtures = { 'renders LoginPage': props, + 'renders LoginPage with OIDC providers': propsWithOidc, }; describe('LoginPage', () => { describe('rendering', () => { diff --git a/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/__snapshots__/LoginPage.test.js.snap b/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/__snapshots__/LoginPage.test.js.snap index 22f57741cc7..3324129828f 100644 --- a/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/__snapshots__/LoginPage.test.js.snap +++ b/webpack/assets/javascripts/react_app/components/LoginPage/__tests__/__snapshots__/LoginPage.test.js.snap @@ -84,3 +84,123 @@ exports[`LoginPage rendering renders LoginPage 1`] = `
`; + +exports[`LoginPage rendering renders LoginPage with OIDC providers 1`] = ` +
+ +
+ + + + + + + + + + + + + +
+
+
+ + Or login with + +
+
+ + +
+
+
+
+`;