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