diff --git a/CHANGELOG.md b/CHANGELOG.md index b69a3e32..993af968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +# OIDC and KML support release + +To configure your OIDC provider, set the following environment variables: + +``` +OIDC_CLIENT_ID=client_id_example +OIDC_CLIENT_SECRET=client_secret_example +OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ +OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback +``` + ## Added - Support for KML file uploads. #350 @@ -18,6 +29,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Changed - Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706 +- Implemented authentication via GitHub and Google for Dawarich Cloud. +- Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66 + # [0.35.1] - 2025-11-09 diff --git a/Gemfile b/Gemfile index e1817622..36cf0d9c 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby File.read('.ruby-version').strip -gem 'activerecord-postgis-adapter', '~> 11.0' +gem 'activerecord-postgis-adapter', '11.0' # https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40 gem 'aws-sdk-core', '~> 3.215.1', require: false gem 'aws-sdk-kms', '~> 1.96.0', require: false @@ -24,6 +24,10 @@ gem 'jwt', '~> 2.8' gem 'kaminari' gem 'lograge' gem 'oj' +gem 'omniauth-github', '~> 2.0.0' +gem 'omniauth-google-oauth2' +gem 'omniauth_openid_connect' +gem 'omniauth-rails_csrf_protection' gem 'parallel' gem 'pg' gem 'prometheus_exporter' diff --git a/Gemfile.lock b/Gemfile.lock index bcf42c11..a32eb801 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,8 +86,10 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) ast (2.4.3) attr_extras (7.1.0) + attr_required (1.0.2) aws-eventstream (1.3.2) aws-partitions (1.1072.0) aws-sdk-core (3.215.1) @@ -108,6 +110,7 @@ GEM bcrypt (3.1.20) benchmark (0.4.1) bigdecimal (3.3.1) + bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.1.0) @@ -161,6 +164,8 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) + email_validator (2.2.4) + activemodel erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) @@ -171,6 +176,14 @@ GEM factory_bot (~> 6.5) railties (>= 6.1.0) fakeredis (0.1.4) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-follow_redirects (0.4.0) + faraday (>= 1, < 3) + faraday-net_http (3.4.1) + net-http (>= 0.5.0) ffaker (2.25.0) ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-arm-linux-gnu) @@ -196,6 +209,7 @@ GEM rgeo-geojson (~> 2.1) zeitwerk (~> 2.5) hashdiff (1.1.2) + hashie (5.0.0) httparty (0.23.1) csv mini_mime (>= 1.0.0) @@ -213,6 +227,13 @@ GEM reline (>= 0.4.2) jmespath (1.6.2) json (2.15.0) + json-jwt (1.17.0) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects json-schema (5.0.1) addressable (~> 2.8) jwt (2.10.1) @@ -256,6 +277,8 @@ GEM multi_json (1.15.0) multi_xml (0.7.1) bigdecimal (~> 3.1) + net-http (0.6.0) + uri net-imap (0.5.12) date net-protocol @@ -279,9 +302,52 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) + oauth2 (2.0.17) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) oj (3.16.11) bigdecimal (>= 3.0) ostruct (>= 0.2) + omniauth (2.1.4) + hashie (>= 3.4.6) + logger + rack (>= 2.2.3) + rack-protection + omniauth-github (2.0.1) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-google-oauth2 (1.2.1) + jwt (>= 2.9.2) + oauth2 (~> 2.0) + omniauth (~> 2.0) + omniauth-oauth2 (~> 1.8) + omniauth-oauth2 (1.8.0) + oauth2 (>= 1.4, < 3) + omniauth (~> 2.0) + omniauth-rails_csrf_protection (1.0.2) + actionpack (>= 4.2) + omniauth (~> 2.0) + omniauth_openid_connect (0.8.0) + omniauth (>= 1.9, < 3) + openid_connect (~> 2.2) + openid_connect (2.3.1) + activemodel + attr_required (>= 1.0.0) + email_validator + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.16) + mail + rack-oauth2 (~> 2.2) + swd (~> 2.0) + tzinfo + validate_url + webfinger (~> 2.0) optimist (3.2.1) orm_adapter (0.5.0) ostruct (0.6.1) @@ -321,6 +387,17 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.3) + rack-oauth2 (2.3.0) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.2.1) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -475,6 +552,9 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) @@ -492,6 +572,11 @@ GEM attr_extras (>= 6.2.4) diff-lcs patience_diff + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects tailwindcss-rails (3.3.2) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) @@ -515,8 +600,16 @@ GEM unicode-emoji (4.1.0) uri (1.0.4) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix + version_gem (1.1.9) warden (1.2.9) rack (>= 2.0.9) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -540,7 +633,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - activerecord-postgis-adapter (~> 11.0) + activerecord-postgis-adapter (= 11.0) aws-sdk-core (~> 3.215.1) aws-sdk-kms (~> 1.96.0) aws-sdk-s3 (~> 1.177.0) @@ -568,6 +661,10 @@ DEPENDENCIES kaminari lograge oj + omniauth-github (~> 2.0.0) + omniauth-google-oauth2 + omniauth-rails_csrf_protection + omniauth_openid_connect parallel pg prometheus_exporter diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 00000000..10fd37ac --- /dev/null +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + def github + handle_auth('GitHub') + end + + def google_oauth2 + handle_auth('Google') + end + + def openid_connect + handle_auth('OpenID Connect') + end + + def failure + error_type = request.env['omniauth.error.type'] + error = request.env['omniauth.error'] + + # Provide user-friendly error messages + error_message = + case error_type + when :invalid_credentials + 'Invalid credentials. Please check your username and password.' + when :timeout + 'Connection timeout. Please try again.' + when :csrf_detected + 'Security error detected. Please try again.' + else + if error&.message&.include?('Discovery') + 'Unable to connect to authentication provider. Please contact your administrator.' + elsif error&.message&.include?('Issuer mismatch') + 'Authentication provider configuration error. Please contact your administrator.' + else + "Authentication failed: #{params[:message] || error&.message || 'Unknown error'}" + end + end + + redirect_to root_path, alert: error_message + end + + private + + def handle_auth(provider) + @user = User.from_omniauth(request.env['omniauth.auth']) + + if @user.persisted? + flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider + sign_in_and_redirect @user, event: :authentication + else + redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") + end + end +end diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb new file mode 100644 index 00000000..b0fbb21b --- /dev/null +++ b/app/models/concerns/omniauthable.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Omniauthable + extend ActiveSupport::Concern + + class_methods do + def from_omniauth(access_token) + data = access_token.info + provider = access_token.provider + uid = access_token.uid + + # First, try to find user by provider and uid (for linked accounts) + user = find_by(provider: provider, uid: uid) + + return user if user + + # If not found, try to find by email + user = find_by(email: data['email']) + + if user + # Update provider and uid for existing user (first-time linking) + user.update(provider: provider, uid: uid) + return user + end + + # Create new user if not found + user = create( + email: data['email'], + password: Devise.friendly_token[0, 20], + provider: provider, + uid: uid + ) + + user + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 71269d64..d328cb20 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -2,8 +2,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength include UserFamily + include Omniauthable + devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable, :trackable + :recoverable, :rememberable, :validatable, :trackable, + :omniauthable, omniauth_providers: ::OMNIAUTH_PROVIDERS has_many :points, dependent: :destroy has_many :imports, dependent: :destroy diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 5536a889..52ad05db 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -64,10 +64,10 @@
<%= f.submit "Update", class: 'btn btn-primary' %>
- - <%= render "devise/shared/links" %> <% end %> + <%= render "devise/shared/links" %> +

Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: 'btn' %>

diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 4a32be9e..d367458b 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -74,10 +74,10 @@ <%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"), class: 'btn btn-primary' %> + <% end %> - <% unless @invitation %> - <%= render "devise/shared/links" %> - <% end %> + <% unless @invitation %> + <%= render "devise/shared/links" %> <% end %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 633337c1..a12c108c 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -49,10 +49,10 @@

<%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
+ <% end %> - <% unless @invitation %> - <%= render "devise/shared/links" %> - <% end %> + <% unless @invitation %> + <%= render "devise/shared/links" %> <% end %> diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index c968119a..857c7d41 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -1,4 +1,4 @@ -
+
<% if !signed_in? %>
<%= link_to "Log in", new_session_path(resource_name) %> diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 2226c69a..d33d86b1 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -68,6 +68,20 @@
%>
+ + <% unless DawarichSettings.self_hosted? %> +
+

+ <%= icon 'link', class: "text-primary mr-1" %> Connected Accounts +

+
+

+ You've connected your account using the following OAuth provider: + <%= current_user.provider.capitalize %> +

+
+
+ <% end %>
<%= f.submit "Save changes", class: "btn btn-primary" %> diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index 1129be64..0215de24 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -36,3 +36,17 @@ MANAGER_URL = SELF_HOSTED ? nil : ENV.fetch('MANAGER_URL', nil) METRICS_USERNAME = ENV.fetch('METRICS_USERNAME', 'prometheus') METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus') # /Prometheus metrics + +# Configure OAuth providers based on environment +# Self-hosted: only OpenID Connect, Cloud: only GitHub and Google +OMNIAUTH_PROVIDERS = + if SELF_HOSTED + # Self-hosted: only OpenID Connect + ENV['OIDC_CLIENT_ID'].present? ? %i[openid_connect] : [] + else + # Cloud: only GitHub and Google + providers = [] + providers << :github if ENV['GITHUB_OAUTH_CLIENT_ID'].present? + providers << :google_oauth2 if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? + providers + end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 7b207ed3..51e15216 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -265,7 +265,63 @@ Devise.setup do |config| # ==> OmniAuth # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. - # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' + + # Cloud version: only GitHub, Google (when env vars present) + if !SELF_HOSTED + if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present? + config.omniauth :github, ENV['GITHUB_OAUTH_CLIENT_ID'], ENV['GITHUB_OAUTH_CLIENT_SECRET'], scope: 'user:email' + Rails.logger.info 'OAuth: GitHub configured' + end + + if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present? + config.omniauth :google_oauth2, ENV['GOOGLE_OAUTH_CLIENT_ID'], ENV['GOOGLE_OAUTH_CLIENT_SECRET'], + scope: 'userinfo.email,userinfo.profile' + Rails.logger.info 'OAuth: Google configured' + end + end + + # Self-hosted version: only OpenID Connect (when env vars present) + # Generic OpenID Connect provider (Authelia, Authentik, Keycloak, etc.) + # Supports both discovery mode (preferred) and manual endpoint configuration + if SELF_HOSTED && ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present? + oidc_config = { + name: :openid_connect, + scope: %i[openid email profile], + response_type: :code, + client_options: { + identifier: ENV['OIDC_CLIENT_ID'], + secret: ENV['OIDC_CLIENT_SECRET'], + redirect_uri: ENV.fetch('OIDC_REDIRECT_URI', "#{ENV.fetch('APPLICATION_URL', 'http://localhost:3000')}/users/auth/openid_connect/callback") + } + } + + # Use OIDC discovery if issuer is provided (recommended for Authelia, Authentik, Keycloak) + if ENV['OIDC_ISSUER'].present? + oidc_config[:issuer] = ENV['OIDC_ISSUER'] + oidc_config[:discovery] = true + Rails.logger.info "OIDC: Discovery mode enabled with issuer: #{ENV['OIDC_ISSUER']}" + # Otherwise use manual endpoint configuration + elsif ENV['OIDC_HOST'].present? + oidc_config[:client_options].merge!( + { + host: ENV['OIDC_HOST'], + scheme: ENV.fetch('OIDC_SCHEME', 'https'), + port: ENV.fetch('OIDC_PORT', 443).to_i, + authorization_endpoint: ENV.fetch('OIDC_AUTHORIZATION_ENDPOINT', '/authorize'), + token_endpoint: ENV.fetch('OIDC_TOKEN_ENDPOINT', '/token'), + userinfo_endpoint: ENV.fetch('OIDC_USERINFO_ENDPOINT', '/userinfo') + } + ) + Rails.logger.info "OIDC: Manual mode enabled with host: #{ENV['OIDC_SCHEME']}://#{ENV['OIDC_HOST']}:#{ENV.fetch( + 'OIDC_PORT', 443 + )}" + end + + Rails.logger.info "OIDC: Client ID: #{ENV['OIDC_CLIENT_ID']}, Redirect URI: #{oidc_config[:client_options][:redirect_uri]}" + config.omniauth :openid_connect, oidc_config + else + Rails.logger.warn 'OIDC: Not configured (missing OIDC_CLIENT_ID or OIDC_CLIENT_SECRET)' + end # ==> Warden configuration # If you want to use other strategies, that are not supported by Devise, or diff --git a/config/routes.rb b/config/routes.rb index 38666530..16d2eacb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -103,7 +103,8 @@ Rails.application.routes.draw do devise_for :users, controllers: { registrations: 'users/registrations', - sessions: 'users/sessions' + sessions: 'users/sessions', + omniauth_callbacks: 'users/omniauth_callbacks' } resources :metrics, only: [:index] diff --git a/db/migrate/20251028130433_add_omniauth_to_users.rb b/db/migrate/20251028130433_add_omniauth_to_users.rb new file mode 100644 index 00000000..01c61b71 --- /dev/null +++ b/db/migrate/20251028130433_add_omniauth_to_users.rb @@ -0,0 +1,6 @@ +class AddOmniauthToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :provider, :string + add_column :users, :uid, :string + end +end diff --git a/docker/.env.example b/docker/.env.example index 008b2af6..18efe182 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -139,3 +139,49 @@ APP_MEMORY_LIMIT=4G # SECRET_KEY_BASE=your-generated-secret-key # SELF_HOSTED=true # PROMETHEUS_EXPORTER_ENABLED=true + +# ============================================================================= +# Example of configuration for OpenID Connect (OIDC) authentication +# +# ============================================================================= + +# Generic OpenID Connect (for Authelia, Authentik, Keycloak, etc.) +# Option 1: Using OIDC Discovery (Recommended) +# Set OIDC_ISSUER to your provider's issuer URL (e.g., https://auth.example.com) +# The provider must support OpenID Connect Discovery (.well-known/openid-configuration) +OIDC_CLIENT_ID=client_id_example +OIDC_CLIENT_SECRET=client_secret_example +OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ +OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback + +# Option 2: Manual Endpoint Configuration (if discovery is not supported) +# Use this if your provider doesn't support OIDC discovery +# OIDC_CLIENT_ID= +# OIDC_CLIENT_SECRET= +# OIDC_HOST=auth.example.com +# OIDC_SCHEME=https +# OIDC_PORT=443 +# OIDC_AUTHORIZATION_ENDPOINT=/authorize +# OIDC_TOKEN_ENDPOINT=/token +# OIDC_USERINFO_ENDPOINT=/userinfo +# OIDC_REDIRECT_URI=https://yourdomain.com/users/auth/openid_connect/callback + +# Example configurations: +# +# Authelia: +# OIDC_ISSUER=https://auth.example.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Authentik: +# OIDC_ISSUER=https://authentik.example.com/application/o/dawarich/ +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback +# +# Keycloak: +# OIDC_ISSUER=https://keycloak.example.com/realms/your-realm +# OIDC_CLIENT_ID=dawarich +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://dawarich.example.com/users/auth/openid_connect/callback diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 928df596..25770617 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -336,4 +336,97 @@ RSpec.describe User, type: :model do end end end + + describe '.from_omniauth' do + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'github', + uid: '123545', + info: { + email: email, + name: 'Test User' + } + }) + end + + context 'when user exists with the same email' do + let(:email) { 'existing@example.com' } + let!(:existing_user) { create(:user, email: email) } + + it 'returns the existing user' do + user = described_class.from_omniauth(auth_hash) + expect(user).to eq(existing_user) + expect(user.persisted?).to be true + end + + it 'does not create a new user' do + expect do + described_class.from_omniauth(auth_hash) + end.not_to change(User, :count) + end + end + + context 'when user does not exist' do + let(:email) { 'new@example.com' } + + it 'creates a new user with the OAuth email' do + expect do + described_class.from_omniauth(auth_hash) + end.to change(User, :count).by(1) + + user = User.last + expect(user.email).to eq(email) + end + + it 'generates a random password for the new user' do + user = described_class.from_omniauth(auth_hash) + expect(user.encrypted_password).to be_present + end + + it 'returns a persisted user' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be true + end + end + + context 'when OAuth provider is Google' do + let(:email) { 'google@example.com' } + let(:auth_hash) do + OmniAuth::AuthHash.new({ + provider: 'google_oauth2', + uid: '123545', + info: { + email: email, + name: 'Google User' + } + }) + end + + it 'creates a user from Google OAuth data' do + user = described_class.from_omniauth(auth_hash) + expect(user.email).to eq(email) + expect(user.persisted?).to be true + end + end + + context 'when email is nil' do + let(:email) { nil } + + it 'attempts to create a user but fails validation' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be false + expect(user.errors[:email]).to be_present + end + end + + context 'when email is blank' do + let(:email) { '' } + + it 'attempts to create a user but fails validation' do + user = described_class.from_omniauth(auth_hash) + expect(user.persisted?).to be false + expect(user.errors[:email]).to be_present + end + end + end end diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb new file mode 100644 index 00000000..7fbc1b07 --- /dev/null +++ b/spec/requests/users/omniauth_callbacks_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::OmniauthCallbacks', type: :request do + let(:email) { 'oauth_user@example.com' } + + before do + Rails.application.env_config['devise.mapping'] = Devise.mappings[:user] + end + + shared_examples 'successful OAuth authentication' do |provider, provider_name| + context "when user doesn't exist" do + it 'creates a new user and signs them in' do + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + end.to change(User, :count).by(1) + + expect(response).to redirect_to(root_path) + + user = User.find_by(email: email) + expect(user).to be_present + expect(user.encrypted_password).to be_present + end + end + + context 'when user already exists' do + let!(:existing_user) { create(:user, email: email) } + + it 'signs in the existing user without creating a new one' do + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + end.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + end + end + + context 'when user creation fails' do + before do + allow(User).to receive(:create).and_return( + User.new(email: email).tap do |u| + u.errors.add(:email, 'is invalid') + end + ) + end + + it 'redirects to registration with error message' do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[provider] + get "/users/auth/#{provider}/callback" + + expect(response).to redirect_to(new_user_registration_url) + end + end + end + + # Self-hosted configuration (SELF_HOSTED=true) uses OpenID Connect + describe 'GET /users/auth/openid_connect/callback' do + before do + mock_openid_connect_auth(email: email) + end + + include_examples 'successful OAuth authentication', :openid_connect, 'OpenID Connect' + end + + describe 'OAuth flow integration with OpenID Connect' do + context 'with OpenID Connect (Authelia/Authentik/Keycloak)' do + before { mock_openid_connect_auth(email: 'oidc@example.com') } + + it 'completes the full OAuth flow' do + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect] + get '/users/auth/openid_connect/callback' + end.to change(User, :count).by(1) + + user = User.find_by(email: 'oidc@example.com') + expect(user).to be_present + expect(user.email).to eq('oidc@example.com') + expect(response).to redirect_to(root_path) + end + end + end + + describe 'CSRF protection' do + it 'does not raise CSRF error for OpenID Connect callback' do + mock_openid_connect_auth(email: email) + + expect do + Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect] + get '/users/auth/openid_connect/callback' + end.not_to raise_error + end + end +end diff --git a/spec/support/omniauth.rb b/spec/support/omniauth.rb new file mode 100644 index 00000000..8f809e62 --- /dev/null +++ b/spec/support/omniauth.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +OmniAuth.config.test_mode = true + +module OmniauthHelpers + def mock_github_auth(email: 'test@github.com') + OmniAuth.config.mock_auth[:github] = OmniAuth::AuthHash.new({ + provider: 'github', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://avatars.githubusercontent.com/u/123545' + }, + credentials: { + token: 'mock_token', + expires_at: Time.now + 1.week + }, + extra: { + raw_info: { + login: 'testuser', + avatar_url: 'https://avatars.githubusercontent.com/u/123545', + name: 'Test User', + email: email + } + } + }) + end + + def mock_google_auth(email: 'test@gmail.com') + OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new({ + provider: 'google_oauth2', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://lh3.googleusercontent.com/a/test' + }, + credentials: { + token: 'mock_token', + refresh_token: 'mock_refresh_token', + expires_at: Time.now + 1.hour + }, + extra: { + raw_info: { + email: email, + email_verified: true, + name: 'Test User', + given_name: 'Test', + family_name: 'User', + picture: 'https://lh3.googleusercontent.com/a/test' + } + } + }) + end + + def mock_openid_connect_auth(email: 'test@oidc.com', provider_name: 'Authelia') + OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({ + provider: 'openid_connect', + uid: '123545', + info: { + email: email, + name: 'Test User', + image: 'https://example.com/avatar.jpg' + }, + credentials: { + token: 'mock_token', + refresh_token: 'mock_refresh_token', + expires_at: Time.now + 1.hour, + id_token: 'mock_id_token' + }, + extra: { + raw_info: { + sub: '123545', + email: email, + email_verified: true, + name: 'Test User', + preferred_username: 'testuser', + given_name: 'Test', + family_name: 'User', + picture: 'https://example.com/avatar.jpg' + } + } + }) + end + + def mock_oauth_failure(provider) + OmniAuth.config.mock_auth[provider] = :invalid_credentials + end +end + +RSpec.configure do |config| + config.include OmniauthHelpers, type: :request + config.include OmniauthHelpers, type: :system + + config.before do + OmniAuth.config.test_mode = true + end + + config.after do + OmniAuth.config.mock_auth[:github] = nil + OmniAuth.config.mock_auth[:google_oauth2] = nil + OmniAuth.config.mock_auth[:openid_connect] = nil + end +end