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