diff --git a/.env.example b/.env.example index 0f33b8eb9..b6014d453 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,12 @@ POSTGRES_USER=postgres # This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= +# OpenID Connect configuration +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ISSUER= +OIDC_REDIRECT_URI= + # Product/Brand Name PRODUCT_NAME= BRAND_NAME= diff --git a/.env.local.example b/.env.local.example index dc20f5a84..830a73906 100644 --- a/.env.local.example +++ b/.env.local.example @@ -13,6 +13,12 @@ OPENAI_MODEL = # OPENAI_URI_BASE = http://host.docker.internal:1234/ # OPENAI_MODEL = qwen/qwen3-vl-4b +# OpenID Connect for development +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ISSUER= +OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback + # Langfuse config LANGFUSE_PUBLIC_KEY = LANGFUSE_SECRET_KEY = diff --git a/.env.test.example b/.env.test.example index f42af1685..4c6c62cda 100644 --- a/.env.test.example +++ b/.env.test.example @@ -1,5 +1,11 @@ SELF_HOSTED=false +# OpenID Connect for tests +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://localhost:3000/auth/openid_connect/callback + # ================ # Data Providers # --------------------------------------------------------------------------------- diff --git a/Gemfile b/Gemfile index 6b56a8a01..9d4ac1fe9 100644 --- a/Gemfile +++ b/Gemfile @@ -75,6 +75,11 @@ gem "rqrcode", "~> 3.0" gem "activerecord-import" gem "rubyzip", "~> 2.3" +# OpenID Connect authentication +gem "omniauth", "~> 2.1" +gem "omniauth-rails_csrf_protection" +gem "omniauth_openid_connect" + # State machines gem "aasm" gem "after_commit_everywhere", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index b3dedb3c6..d85c4caac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,10 +85,12 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + aes_key_wrap (1.1.0) after_commit_everywhere (1.6.0) activerecord (>= 4.2) activesupport ast (2.4.3) + attr_required (1.0.2) aws-eventstream (1.4.0) aws-partitions (1.1113.0) aws-sdk-core (3.225.1) @@ -119,6 +121,7 @@ GEM parser (>= 2.4) smart_properties bigdecimal (3.2.2) + bindata (2.5.1) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -182,6 +185,8 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) + email_validator (2.2.4) + activemodel erb (5.0.1) erb_lint (0.9.0) activesupport @@ -200,6 +205,8 @@ GEM faraday-net_http (>= 2.0, < 3.5) json logger + faraday-follow_redirects (0.3.0) + faraday (>= 1, < 3) faraday-multipart (1.1.1) multipart-post (~> 2.0) faraday-net_http (3.4.1) @@ -224,6 +231,7 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashdiff (1.2.0) + hashie (5.0.0) heapy (0.2.0) thor highline (3.1.2) @@ -276,6 +284,13 @@ GEM activesupport (>= 5.0.0) jmespath (1.6.2) json (2.12.2) + json-jwt (1.16.7) + activesupport (>= 4.2) + aes_key_wrap + base64 + bindata + faraday (~> 2.0) + faraday-follow_redirects jwt (2.10.2) base64 langfuse-ruby (0.1.4) @@ -374,6 +389,29 @@ GEM octokit (10.0.0) faraday (>= 1, < 3) sawyer (~> 0.9) + omniauth (2.1.3) + hashie (>= 3.4.6) + rack (>= 2.2.3) + rack-protection + 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) ostruct (0.6.2) pagy (9.3.5) parallel (1.27.0) @@ -409,6 +447,17 @@ GEM rack (>= 1.0, < 4) rack-mini-profiler (4.0.0) rack (>= 1.2.0) + rack-oauth2 (2.2.1) + activesupport + attr_required + faraday (~> 2.0) + faraday-follow_redirects + json-jwt (>= 1.11.0) + rack (>= 2.1.0) + rack-protection (4.1.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) @@ -567,6 +616,11 @@ GEM railties (>= 6.0.0) stringio (3.1.7) stripe (15.3.0) + swd (2.0.3) + activesupport (>= 3) + attr_required (>= 0.0.5) + faraday (~> 2.0) + faraday-follow_redirects tailwindcss-rails (4.2.3) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) @@ -593,6 +647,9 @@ GEM unicode-emoji (4.0.4) uri (1.0.3) useragent (0.16.11) + validate_url (1.0.15) + activemodel (>= 3.0.0) + public_suffix vcr (6.3.1) base64 vernier (1.8.0) @@ -605,6 +662,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webfinger (2.1.3) + activesupport + faraday (~> 2.0) + faraday-follow_redirects webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -668,6 +729,9 @@ DEPENDENCIES lucide-rails! mocha octokit + omniauth (~> 2.1) + omniauth-rails_csrf_protection + omniauth_openid_connect ostruct pagy pg (~> 1.5) diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 05d43058d..e2df294c3 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -10,6 +10,8 @@ @import "./simonweb_pickr.css"; +@import "./google-sign-in.css"; + @layer components { .pcr-app{ position: static !important; diff --git a/app/assets/tailwind/google-sign-in.css b/app/assets/tailwind/google-sign-in.css new file mode 100644 index 000000000..151b14fe3 --- /dev/null +++ b/app/assets/tailwind/google-sign-in.css @@ -0,0 +1,106 @@ +@layer components { + .gsi-material-button { + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + -webkit-appearance: none; + background-color: WHITE; + background-image: none; + border: 1px solid #747775; + -webkit-border-radius: 4px; + border-radius: 4px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: #1f1f1f; + cursor: pointer; + font-family: 'Roboto', arial, sans-serif; + font-size: 14px; + height: 40px; + letter-spacing: 0.25px; + outline: none; + overflow: hidden; + padding: 0 12px; + position: relative; + text-align: center; + -webkit-transition: background-color .218s, border-color .218s, box-shadow .218s; + transition: background-color .218s, border-color .218s, box-shadow .218s; + vertical-align: middle; + white-space: nowrap; + width: auto; + max-width: 400px; + min-width: min-content; + display: inline-flex; + } + + .gsi-material-button .gsi-material-button-icon { + height: 20px; + margin-right: 12px; + min-width: 20px; + width: 20px; + } + + .gsi-material-button .gsi-material-button-content-wrapper { + -webkit-align-items: center; + align-items: center; + display: flex; + -webkit-flex-direction: row; + flex-direction: row; + -webkit-flex-wrap: nowrap; + flex-wrap: nowrap; + height: 100%; + justify-content: space-between; + position: relative; + width: 100%; + } + + .gsi-material-button .gsi-material-button-contents { + -webkit-flex-grow: 1; + flex-grow: 1; + font-family: 'Roboto', arial, sans-serif; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; + } + + .gsi-material-button .gsi-material-button-state { + -webkit-transition: opacity .218s; + transition: opacity .218s; + bottom: 0; + left: 0; + opacity: 0; + position: absolute; + right: 0; + top: 0; + } + + .gsi-material-button:disabled { + cursor: default; + background-color: #ffffff61; + border-color: #1f1f1f1f; + } + + .gsi-material-button:disabled .gsi-material-button-contents { + opacity: 38%; + } + + .gsi-material-button:disabled .gsi-material-button-icon { + opacity: 38%; + } + + .gsi-material-button:not(:disabled):active .gsi-material-button-state, + .gsi-material-button:not(:disabled):focus .gsi-material-button-state { + background-color: #303030; + opacity: 12%; + } + + .gsi-material-button:not(:disabled):hover { + -webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15); + box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15); + } + + .gsi-material-button:not(:disabled):hover .gsi-material-button-state { + background-color: #303030; + opacity: 8%; + } +} diff --git a/app/controllers/oidc_accounts_controller.rb b/app/controllers/oidc_accounts_controller.rb new file mode 100644 index 000000000..231790ab3 --- /dev/null +++ b/app/controllers/oidc_accounts_controller.rb @@ -0,0 +1,125 @@ +class OidcAccountsController < ApplicationController + skip_authentication only: [ :link, :create_link, :new_user, :create_user ] + layout "auth" + + def link + # Check if there's pending OIDC auth in session + @pending_auth = session[:pending_oidc_auth] + + if @pending_auth.nil? + redirect_to new_session_path, alert: "No pending OIDC authentication found" + return + end + + @email = @pending_auth["email"] + @user_exists = User.exists?(email: @email) if @email.present? + end + + def create_link + @pending_auth = session[:pending_oidc_auth] + + if @pending_auth.nil? + redirect_to new_session_path, alert: "No pending OIDC authentication found" + return + end + + # Verify user's password to confirm identity + user = User.authenticate_by(email: params[:email], password: params[:password]) + + if user + # Create the OIDC identity link + oidc_identity = user.oidc_identities.create!( + provider: @pending_auth["provider"], + uid: @pending_auth["uid"], + info: { + email: @pending_auth["email"], + name: @pending_auth["name"], + first_name: @pending_auth["first_name"], + last_name: @pending_auth["last_name"] + }, + last_authenticated_at: Time.current + ) + + # Clear pending auth from session + session.delete(:pending_oidc_auth) + + # Check if user has MFA enabled + if user.otp_required? + session[:mfa_user_id] = user.id + redirect_to verify_mfa_path + else + @session = create_session_for(user) + redirect_to root_path, notice: "Account successfully linked to #{@pending_auth['provider']}" + end + else + @email = params[:email] + @user_exists = User.exists?(email: @email) if @email.present? + flash.now[:alert] = "Invalid email or password" + render :link, status: :unprocessable_entity + end + end + + def new_user + # Check if there's pending OIDC auth in session + @pending_auth = session[:pending_oidc_auth] + + if @pending_auth.nil? + redirect_to new_session_path, alert: "No pending OIDC authentication found" + return + end + + # Pre-fill user details from OIDC provider + @user = User.new( + email: @pending_auth["email"], + first_name: @pending_auth["first_name"], + last_name: @pending_auth["last_name"] + ) + end + + def create_user + @pending_auth = session[:pending_oidc_auth] + + if @pending_auth.nil? + redirect_to new_session_path, alert: "No pending OIDC authentication found" + return + end + + # Create user with a secure random password since they're using OIDC + secure_password = SecureRandom.base58(32) + @user = User.new( + email: @pending_auth["email"], + first_name: @pending_auth["first_name"], + last_name: @pending_auth["last_name"], + password: secure_password, + password_confirmation: secure_password + ) + + # Create new family for this user + @user.family = Family.new + @user.role = :admin + + if @user.save + # Create the OIDC identity + @user.oidc_identities.create!( + provider: @pending_auth["provider"], + uid: @pending_auth["uid"], + info: { + email: @pending_auth["email"], + name: @pending_auth["name"], + first_name: @pending_auth["first_name"], + last_name: @pending_auth["last_name"] + }, + last_authenticated_at: Time.current + ) + + # Clear pending auth from session + session.delete(:pending_oidc_auth) + + # Create session and log them in + @session = create_session_for(@user) + redirect_to root_path, notice: "Welcome! Your account has been created." + else + render :new_user, status: :unprocessable_entity + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 3b7357f84..eb290d37e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,6 @@ class SessionsController < ApplicationController before_action :set_session, only: :destroy - skip_authentication only: %i[new create] + skip_authentication only: %i[new create openid_connect failure] layout "auth" @@ -27,6 +27,50 @@ class SessionsController < ApplicationController redirect_to new_session_path, notice: t(".logout_successful") end + def openid_connect + auth = request.env["omniauth.auth"] + + # Nil safety: ensure auth and required fields are present + unless auth&.provider && auth&.uid + redirect_to new_session_path, alert: t("sessions.openid_connect.failed") + return + end + + # Security fix: Look up by provider + uid, not just email + oidc_identity = OidcIdentity.find_by(provider: auth.provider, uid: auth.uid) + + if oidc_identity + # Existing OIDC identity found - authenticate the user + user = oidc_identity.user + oidc_identity.record_authentication! + + # MFA check: If user has MFA enabled, require verification + if user.otp_required? + session[:mfa_user_id] = user.id + redirect_to verify_mfa_path + else + @session = create_session_for(user) + redirect_to root_path + end + else + # No existing OIDC identity - need to link to account + # Store auth data in session and redirect to linking page + session[:pending_oidc_auth] = { + provider: auth.provider, + uid: auth.uid, + email: auth.info&.email, + name: auth.info&.name, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name + } + redirect_to link_oidc_account_path + end + end + + def failure + redirect_to new_session_path, alert: t("sessions.failure.failed") + end + private def set_session @session = Current.user.sessions.find(params[:id]) diff --git a/app/controllers/settings/llm_usages_controller.rb b/app/controllers/settings/llm_usages_controller.rb index f59d1ead8..8013ecdff 100644 --- a/app/controllers/settings/llm_usages_controller.rb +++ b/app/controllers/settings/llm_usages_controller.rb @@ -9,14 +9,6 @@ class Settings::LlmUsagesController < ApplicationController @family = Current.family # Get date range from params or default to last 30 days - def safe_parse_date(s) - Date.iso8601(s) - rescue ArgumentError, TypeError - nil - end - - private - @end_date = safe_parse_date(params[:end_date]) || Date.today @start_date = safe_parse_date(params[:start_date]) || (@end_date - 30.days) if @start_date > @end_date @@ -32,4 +24,11 @@ class Settings::LlmUsagesController < ApplicationController # Get statistics @statistics = LlmUsage.statistics_for_family(@family, start_date: @start_date.beginning_of_day, end_date: @end_date.end_of_day) end + + private + def safe_parse_date(s) + Date.iso8601(s) + rescue ArgumentError, TypeError + nil + end end diff --git a/app/models/oidc_identity.rb b/app/models/oidc_identity.rb new file mode 100644 index 000000000..12b8fd477 --- /dev/null +++ b/app/models/oidc_identity.rb @@ -0,0 +1,27 @@ +class OidcIdentity < ApplicationRecord + belongs_to :user + + validates :provider, presence: true + validates :uid, presence: true, uniqueness: { scope: :provider } + validates :user_id, presence: true + + # Update the last authenticated timestamp + def record_authentication! + update!(last_authenticated_at: Time.current) + end + + # Extract and store relevant info from OmniAuth auth hash + def self.create_from_omniauth(auth, user) + create!( + user: user, + provider: auth.provider, + uid: auth.uid, + info: { + email: auth.info&.email, + name: auth.info&.name, + first_name: auth.info&.first_name, + last_name: auth.info&.last_name + } + ) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 49b3aa9d3..a3ca25ada 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,7 @@ class User < ApplicationRecord has_many :invitations, foreign_key: :inviter_id, dependent: :destroy has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy + has_many :oidc_identities, dependent: :destroy accepts_nested_attributes_for :family, update_only: true validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } diff --git a/app/views/oidc_accounts/link.html.erb b/app/views/oidc_accounts/link.html.erb new file mode 100644 index 000000000..bfab765ec --- /dev/null +++ b/app/views/oidc_accounts/link.html.erb @@ -0,0 +1,62 @@ +<% + header_title @user_exists ? "Link OIDC Account" : "Create Account" +%> + +<% if @user_exists %> +
+

Verify Your Identity

+

+ To link your <%= @pending_auth["provider"] %> account<% if @pending_auth["email"].present? %> (<%= @pending_auth["email"] %>)<% end %>, + please verify your identity by entering your password. +

+
+ + <%= styled_form_with url: create_link_oidc_account_path, class: "space-y-4", data: { turbo: false } do |form| %> + <%= form.email_field :email, + label: "Email", + autofocus: false, + autocomplete: "email", + required: "required", + placeholder: "Enter your email", + value: @email %> + + <%= form.password_field :password, + label: "Password", + required: "required", + placeholder: "Enter your password", + autocomplete: "current-password" %> + +
+

This helps ensure that only you can link external accounts to your profile.

+
+ + <%= form.submit "Link Account" %> + <% end %> +<% else %> +
+

Create New Account

+

+ No account found with the email <%= @pending_auth["email"] %>. + Click below to create a new account using your <%= @pending_auth["provider"] %> identity. +

+
+ +
+
+

+ Email: <%= @pending_auth["email"] %> +

+ <% if @pending_auth["name"].present? %> +

+ Name: <%= @pending_auth["name"] %> +

+ <% end %> +
+ + <%= button_to "Create Account", create_user_oidc_account_path, method: :post, class: "w-full", data: { turbo: false } %> +
+<% end %> + +
+ <%= link_to "Cancel", new_session_path, class: "font-medium text-sm text-primary hover:underline transition" %> +
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index 6dfa12378..5d5774349 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -13,3 +13,24 @@
<%= link_to t(".forgot_password"), new_password_reset_path, class: "font-medium text-sm text-primary hover:underline transition" %>
+ +<% if Rails.configuration.x.auth.oidc_enabled %> +
+ <%= button_to "/auth/openid_connect", method: :post, form: { data: { turbo: false } }, class: "gsi-material-button" do %> +
+
+
+ + + + + + + +
+ <%= t(".google_auth_connect") %> + <%= t(".google_auth_connect") %> +
+ <% end %> +
+<% end %> diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 000000000..f839403ad --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "omniauth/rails_csrf_protection" + +# Configure OmniAuth for production or test environments +# In test mode, OmniAuth will use mock data instead of real provider configuration +required_env = %w[OIDC_ISSUER OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_REDIRECT_URI] +missing = required_env.select { |k| ENV[k].blank? } +if missing.empty? || Rails.env.test? + Rails.application.config.middleware.use OmniAuth::Builder do + provider :openid_connect, + name: :openid_connect, + scope: %i[openid email profile], + response_type: :code, + issuer: ENV["OIDC_ISSUER"].to_s.strip || "https://test.example.com", + discovery: true, + pkce: true, + client_options: { + identifier: ENV["OIDC_CLIENT_ID"] || "test_client_id", + secret: ENV["OIDC_CLIENT_SECRET"] || "test_client_secret", + redirect_uri: ENV["OIDC_REDIRECT_URI"] || "http://test.example.com/callback" + } + end + Rails.configuration.x.auth.oidc_enabled = true +else + Rails.logger.warn("OIDC not enabled: missing env vars: #{missing.join(', ')}") + raise "Missing required OIDC env vars: #{missing.join(', ')}" if Rails.env.production? + Rails.configuration.x.auth.oidc_enabled = false +end diff --git a/config/locales/views/sessions/en.yml b/config/locales/views/sessions/en.yml index 53f2a7ec7..589f9f3cc 100644 --- a/config/locales/views/sessions/en.yml +++ b/config/locales/views/sessions/en.yml @@ -5,6 +5,10 @@ en: invalid_credentials: Invalid email or password. destroy: logout_successful: You have signed out successfully. + openid_connect: + failed: Could not authenticate via OpenID Connect. + failure: + failed: Could not authenticate. new: email: Email address email_placeholder: you@example.com @@ -12,4 +16,6 @@ en: password: Password submit: Log in title: Sign in to your account - password_placeholder: Enter your password \ No newline at end of file + password_placeholder: Enter your password + openid_connect: Sign in with OpenID Connect + google_auth_connect: Sign in with Google diff --git a/config/locales/views/sessions/nb.yml b/config/locales/views/sessions/nb.yml index 75478ba72..c78d4a277 100644 --- a/config/locales/views/sessions/nb.yml +++ b/config/locales/views/sessions/nb.yml @@ -3,13 +3,16 @@ nb: sessions: create: invalid_credentials: Ugyldig e-post eller passord. - destroy: - logout_successful: Du har blitt logget ut. - new: - email: E-postadresse - email_placeholder: meg@example.com - forgot_password: Glemt passordet ditt? - password: Passord - submit: Logg inn - title: Logg inn på kontoen din - password_placeholder: Angi passordet ditt \ No newline at end of file + destroy: + logout_successful: Du har blitt logget ut. + openid_connect: + failed: Kunne ikke autentisere via OpenID Connect. + new: + email: E-postadresse + email_placeholder: meg@example.com + forgot_password: Glemt passordet ditt? + password: Passord + submit: Logg inn + title: Logg inn på kontoen din + password_placeholder: Angi passordet ditt + openid_connect: Logg inn med OpenID Connect diff --git a/config/locales/views/sessions/tr.yml b/config/locales/views/sessions/tr.yml index 1ba69a02b..91bd43b7b 100644 --- a/config/locales/views/sessions/tr.yml +++ b/config/locales/views/sessions/tr.yml @@ -5,6 +5,8 @@ tr: invalid_credentials: Geçersiz e-posta veya şifre. destroy: logout_successful: Başarıyla çıkış yaptınız. + openid_connect: + failed: OpenID Connect ile kimlik doğrulaması yapılamadı. new: email: E-posta adresi email_placeholder: ornek@eposta.com @@ -12,4 +14,5 @@ tr: password: Şifre submit: Giriş yap title: Hesabınıza giriş yapın - password_placeholder: Şifrenizi girin \ No newline at end of file + password_placeholder: Şifrenizi girin + openid_connect: OpenID Connect ile giriş yap diff --git a/config/routes.rb b/config/routes.rb index a140a191b..645c16d9c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,14 @@ Rails.application.routes.draw do resource :registration, only: %i[new create] resources :sessions, only: %i[new create destroy] + match "/auth/:provider/callback", to: "sessions#openid_connect", via: %i[get post] + match "/auth/failure", to: "sessions#failure", via: %i[get post] + resource :oidc_account, only: [] do + get :link, on: :collection + post :create_link, on: :collection + get :new_user, on: :collection + post :create_user, on: :collection + end resource :password_reset, only: %i[new create edit update] resource :password, only: %i[edit update] resource :email_confirmation, only: :new diff --git a/db/migrate/20251024083624_create_oidc_identities.rb b/db/migrate/20251024083624_create_oidc_identities.rb new file mode 100644 index 000000000..f203b81df --- /dev/null +++ b/db/migrate/20251024083624_create_oidc_identities.rb @@ -0,0 +1,15 @@ +class CreateOidcIdentities < ActiveRecord::Migration[7.2] + def change + create_table :oidc_identities, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :provider, null: false + t.string :uid, null: false + t.jsonb :info, default: {} + t.datetime :last_authenticated_at + + t.timestamps + end + + add_index :oidc_identities, [ :provider, :uid ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index c4fb4a708..1d97447db 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_10_22_151319) do +ActiveRecord::Schema[7.2].define(version: 2025_10_24_083624) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -29,7 +29,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_22_151319) do t.uuid "accountable_id" t.decimal "balance", precision: 19, scale: 4 t.string "currency" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true + t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.uuid "import_id" t.uuid "plaid_account_id" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" @@ -543,6 +543,18 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_22_151319) do t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end + create_table "oidc_identities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "provider", null: false + t.string "uid", null: false + t.jsonb "info", default: {} + t.datetime "last_authenticated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["provider", "uid"], name: "index_oidc_identities_on_provider_and_uid", unique: true + t.index ["user_id"], name: "index_oidc_identities_on_user_id" + end + create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -933,6 +945,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_22_151319) do add_foreign_key "mobile_devices", "users" add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id" add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" + add_foreign_key "oidc_identities", "users" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "rejected_transfers", "transactions", column: "inflow_transaction_id" diff --git a/docs/hosting/oidc.md b/docs/hosting/oidc.md new file mode 100644 index 000000000..473b23693 --- /dev/null +++ b/docs/hosting/oidc.md @@ -0,0 +1,43 @@ +# Configuring OpenID Connect with Google + +This guide shows how to enable OpenID Connect (OIDC) logins for Sure using Google as the identity provider. + +## 1. Create a Google Cloud project + +1. Visit [https://console.cloud.google.com](https://console.cloud.google.com) and sign in. +2. Create a new project or select an existing one. + +## 2. Configure the OAuth consent screen + +1. Navigate to **APIs & Services > OAuth consent screen**. +2. Choose **External** and follow the prompts to configure the consent screen. +3. Add your Google account as a test user. + +## 3. Create OAuth client credentials + +1. Go to **APIs & Services > Credentials** and click **Create Credentials > OAuth client ID**. +2. Select **Web application** as the application type. +3. Add an authorized redirect URI. For local development use: + ``` + http://localhost:3000/auth/openid_connect/callback + ``` + Replace with your domain for production, e.g.: + ``` + https://yourdomain.com/auth/openid_connect/callback + ``` +4. After creating the credentials, copy the **Client ID** and **Client Secret**. + +## 4. Configure Sure + +Set the following environment variables in your deployment (e.g. `.env`, `docker-compose`, or hosting platform): + +```bash +OIDC_ISSUER="https://accounts.google.com" +OIDC_CLIENT_ID="your-google-client-id" +OIDC_CLIENT_SECRET="your-google-client-secret" +OIDC_REDIRECT_URI="https://yourdomain.com/auth/openid_connect/callback" +``` + +Restart the application after saving the variables. + +The user can now sign in from the login page using the **Sign in with OpenID Connect** link. Google must report the user's email as verified and it must match the email on the account. diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 438e98c63..b756c30ce 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -11,7 +11,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase def sign_in(user) visit new_session_path - within "form" do + within %(form[action='#{sessions_path}']) do fill_in "Email", with: user.email fill_in "Password", with: user_password_test click_on "Log in" diff --git a/test/controllers/oidc_accounts_controller_test.rb b/test/controllers/oidc_accounts_controller_test.rb new file mode 100644 index 000000000..6e6238bd0 --- /dev/null +++ b/test/controllers/oidc_accounts_controller_test.rb @@ -0,0 +1,150 @@ +require "test_helper" + +class OidcAccountsControllerTest < ActionController::TestCase + setup do + @user = users(:family_admin) + end + + def pending_auth + { + "provider" => "openid_connect", + "uid" => "new-uid-12345", + "email" => @user.email, + "name" => "Bob Dylan", + "first_name" => "Bob", + "last_name" => "Dylan" + } + end + + test "should show link page when pending auth exists" do + session[:pending_oidc_auth] = pending_auth + get :link + assert_response :success + end + + test "should redirect to login when no pending auth" do + get :link + assert_redirected_to new_session_path + assert_equal "No pending OIDC authentication found", flash[:alert] + end + + test "should create OIDC identity with valid password" do + session[:pending_oidc_auth] = pending_auth + + assert_difference "OidcIdentity.count", 1 do + post :create_link, + params: { + email: @user.email, + password: user_password_test + } + end + + assert_redirected_to root_path + assert_not_nil @user.oidc_identities.find_by( + provider: pending_auth["provider"], + uid: pending_auth["uid"] + ) + end + + test "should reject linking with invalid password" do + session[:pending_oidc_auth] = pending_auth + + assert_no_difference "OidcIdentity.count" do + post :create_link, + params: { + email: @user.email, + password: "wrongpassword" + } + end + + assert_response :unprocessable_entity + assert_equal "Invalid email or password", flash[:alert] + end + + test "should redirect to MFA when user has MFA enabled" do + @user.setup_mfa! + @user.enable_mfa! + + session[:pending_oidc_auth] = pending_auth + + post :create_link, + params: { + email: @user.email, + password: user_password_test + } + + assert_redirected_to verify_mfa_path + end + + test "should reject create_link when no pending auth" do + post :create_link, params: { + email: @user.email, + password: user_password_test + } + + assert_redirected_to new_session_path + assert_equal "No pending OIDC authentication found", flash[:alert] + end + + # New user registration tests + def new_user_auth + { + "provider" => "openid_connect", + "uid" => "new-uid-99999", + "email" => "newuser@example.com", + "name" => "New User", + "first_name" => "New", + "last_name" => "User" + } + end + + test "should show create account option for new user" do + session[:pending_oidc_auth] = new_user_auth + + get :link + assert_response :success + assert_select "h3", text: "Create New Account" + assert_select "strong", text: new_user_auth["email"] + end + + test "should create new user account via OIDC" do + session[:pending_oidc_auth] = new_user_auth + + assert_difference [ "User.count", "OidcIdentity.count", "Family.count" ], 1 do + post :create_user + end + + assert_redirected_to root_path + assert_equal "Welcome! Your account has been created.", flash[:notice] + + # Verify user was created with correct details + new_user = User.find_by(email: new_user_auth["email"]) + assert_not_nil new_user + assert_equal new_user_auth["first_name"], new_user.first_name + assert_equal new_user_auth["last_name"], new_user.last_name + assert_equal "admin", new_user.role + + # Verify OIDC identity was created + oidc_identity = new_user.oidc_identities.first + assert_not_nil oidc_identity + assert_equal new_user_auth["provider"], oidc_identity.provider + assert_equal new_user_auth["uid"], oidc_identity.uid + end + + test "should create session after OIDC registration" do + session[:pending_oidc_auth] = new_user_auth + + post :create_user + + # Verify session was created + new_user = User.find_by(email: new_user_auth["email"]) + assert Session.exists?(user_id: new_user.id) + end + + test "should reject create_user when no pending auth" do + post :create_user + + assert_redirected_to new_session_path + assert_equal "No pending OIDC authentication found", flash[:alert] + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 8383ac0b2..1014136c1 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -5,6 +5,24 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest @user = users(:family_admin) end + teardown do + # Clear OmniAuth mock auth after each test + OmniAuth.config.mock_auth[:openid_connect] = nil + end + + def setup_omniauth_mock(provider:, uid:, email:, name:, first_name: nil, last_name: nil) + OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({ + provider: provider, + uid: uid, + info: { + email: email, + name: name, + first_name: first_name, + last_name: last_name + }.compact + }) + end + test "login page" do get new_session_url assert_response :success @@ -48,4 +66,107 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_equal @user.id, session[:mfa_user_id] assert_not Session.exists?(user_id: @user.id) end + + # OIDC Authentication Tests + test "authenticates with existing OIDC identity" do + oidc_identity = oidc_identities(:bob_google) + + # Set up OmniAuth mock + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan", + first_name: "Bob", + last_name: "Dylan" + ) + + get "/auth/openid_connect/callback" + + assert_redirected_to root_path + assert Session.exists?(user_id: @user.id) + end + + test "redirects to MFA when user has MFA and uses OIDC" do + @user.setup_mfa! + @user.enable_mfa! + @user.sessions.destroy_all + oidc_identity = oidc_identities(:bob_google) + + # Set up OmniAuth mock + setup_omniauth_mock( + provider: oidc_identity.provider, + uid: oidc_identity.uid, + email: @user.email, + name: "Bob Dylan" + ) + + get "/auth/openid_connect/callback" + + assert_redirected_to verify_mfa_path + assert_equal @user.id, session[:mfa_user_id] + assert_not Session.exists?(user_id: @user.id) + end + + test "redirects to account linking when no OIDC identity exists" do + # Use an existing user's email who doesn't have OIDC linked yet + user_without_oidc = users(:new_email) + + # Set up OmniAuth mock + setup_omniauth_mock( + provider: "openid_connect", + uid: "new-uid-99999", + email: user_without_oidc.email, + name: "New User" + ) + + get "/auth/openid_connect/callback" + + assert_redirected_to link_oidc_account_path + + # Follow redirect to verify session data is accessible + follow_redirect! + assert_response :success + + # Verify the session has the pending auth data by checking page content + assert_select "p", text: /To link your openid_connect account/ + end + + test "handles missing auth data gracefully" do + # Set up mock with invalid/incomplete auth to simulate failure + OmniAuth.config.mock_auth[:openid_connect] = OmniAuth::AuthHash.new({ + provider: nil, + uid: nil + }) + + get "/auth/openid_connect/callback" + + assert_redirected_to new_session_path + assert_equal "Could not authenticate via OpenID Connect.", flash[:alert] + end + + test "prevents account takeover via email matching" do + # Clean up any existing sessions + @user.sessions.destroy_all + + # This test verifies that we can't authenticate just by matching email + # The user must have an existing OIDC identity with matching provider + uid + # Set up OmniAuth mock + setup_omniauth_mock( + provider: "openid_connect", + uid: "attacker-uid-12345", # Different UID than user's OIDC identity + email: @user.email, # Same email as existing user + name: "Attacker" + ) + + get "/auth/openid_connect/callback" + + # Should NOT create a session, should redirect to account linking + assert_redirected_to link_oidc_account_path + assert_not Session.exists?(user_id: @user.id), "Session should not be created for unlinked OIDC identity" + + # Follow redirect to verify we're on the link page (not logged in) + follow_redirect! + assert_response :success + end end diff --git a/test/fixtures/oidc_identities.yml b/test/fixtures/oidc_identities.yml new file mode 100644 index 000000000..c2fbdb404 --- /dev/null +++ b/test/fixtures/oidc_identities.yml @@ -0,0 +1,21 @@ +bob_google: + user: family_admin + provider: openid_connect + uid: google-uid-12345 + info: + email: bob@bobdylan.com + name: Bob Dylan + first_name: Bob + last_name: Dylan + last_authenticated_at: <%= 1.day.ago %> + +jakob_google: + user: family_member + provider: openid_connect + uid: google-uid-67890 + info: + email: jakobdylan@yahoo.com + name: Jakob Dylan + first_name: Jakob + last_name: Dylan + last_authenticated_at: <%= 2.days.ago %> diff --git a/test/models/oidc_identity_test.rb b/test/models/oidc_identity_test.rb new file mode 100644 index 000000000..09d83902b --- /dev/null +++ b/test/models/oidc_identity_test.rb @@ -0,0 +1,81 @@ +require "test_helper" + +class OidcIdentityTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @oidc_identity = oidc_identities(:bob_google) + end + + test "belongs to user" do + assert_equal @user, @oidc_identity.user + end + + test "validates presence of provider" do + @oidc_identity.provider = nil + assert_not @oidc_identity.valid? + assert_includes @oidc_identity.errors[:provider], "can't be blank" + end + + test "validates presence of uid" do + @oidc_identity.uid = nil + assert_not @oidc_identity.valid? + assert_includes @oidc_identity.errors[:uid], "can't be blank" + end + + test "validates presence of user_id" do + @oidc_identity.user_id = nil + assert_not @oidc_identity.valid? + assert_includes @oidc_identity.errors[:user_id], "can't be blank" + end + + test "validates uniqueness of uid scoped to provider" do + duplicate = OidcIdentity.new( + user: users(:family_member), + provider: @oidc_identity.provider, + uid: @oidc_identity.uid + ) + + assert_not duplicate.valid? + assert_includes duplicate.errors[:uid], "has already been taken" + end + + test "allows same uid for different providers" do + different_provider = OidcIdentity.new( + user: users(:family_member), + provider: "different_provider", + uid: @oidc_identity.uid + ) + + assert different_provider.valid? + end + + test "records authentication timestamp" do + old_timestamp = @oidc_identity.last_authenticated_at + travel_to 1.hour.from_now do + @oidc_identity.record_authentication! + assert @oidc_identity.last_authenticated_at > old_timestamp + end + end + + test "creates from omniauth hash" do + auth = OmniAuth::AuthHash.new({ + provider: "google_oauth2", + uid: "google-123456", + info: { + email: "test@example.com", + name: "Test User", + first_name: "Test", + last_name: "User" + } + }) + + identity = OidcIdentity.create_from_omniauth(auth, @user) + + assert identity.persisted? + assert_equal "google_oauth2", identity.provider + assert_equal "google-123456", identity.uid + assert_equal "test@example.com", identity.info["email"] + assert_equal "Test User", identity.info["name"] + assert_equal @user, identity.user + end +end diff --git a/test/system/onboardings_test.rb b/test/system/onboardings_test.rb index 7124b5d52..9258c2c13 100644 --- a/test/system/onboardings_test.rb +++ b/test/system/onboardings_test.rb @@ -183,7 +183,7 @@ class OnboardingsTest < ApplicationSystemTestCase def sign_in(user) visit new_session_path - within "form" do + within %(form[action='#{sessions_path}']) do fill_in "Email", with: user.email fill_in "Password", with: user_password_test click_on "Log in" diff --git a/test/test_helper.rb b/test/test_helper.rb index a5edb0cde..1479ce88e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -36,6 +36,11 @@ VCR.configure do |config| config.filter_sensitive_data("") { ENV["PLAID_SECRET"] } end +# Configure OmniAuth for testing +OmniAuth.config.test_mode = true +# Allow both GET and POST for OIDC callbacks in tests +OmniAuth.config.allowed_request_methods = [ :get, :post ] + module ActiveSupport class TestCase # Run tests in parallel with specified workers