From d036e25fef6c273c42d3b80444bc93234a6b6505 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Wed, 31 Dec 2025 17:07:54 +1100 Subject: [PATCH] Add auth_time, acr and azp support for OIDC claims --- app/controllers/concerns/authentication.rb | 4 ++-- app/controllers/oidc_controller.rb | 22 +++++++++++------- app/controllers/sessions_controller.rb | 16 ++++++------- app/services/oidc_jwt_service.rb | 9 +++++++- ...1231055350_add_auth_time_to_oidc_tokens.rb | 6 +++++ ...112_add_acr_to_oidc_tokens_and_sessions.rb | 7 ++++++ db/schema.rb | 5 +++- test/services/oidc_jwt_service_test.rb | 23 +++++++++++++++++++ 8 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 db/migrate/20251231055350_add_auth_time_to_oidc_tokens.rb create mode 100644 db/migrate/20251231060112_add_acr_to_oidc_tokens_and_sessions.rb diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 542db5f..9f56e5f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -44,9 +44,9 @@ module Authentication final_url end - def start_new_session_for(user) + def start_new_session_for(user, acr: "1") user.update!(last_sign_in_at: Time.current) - user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr).tap do |session| Current.session = session # Extract root domain for cross-subdomain cookies (required for forward auth) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index df72daf..b1ef227 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -163,6 +163,7 @@ class OidcController < ApplicationController code_challenge: code_challenge, code_challenge_method: code_challenge_method, auth_time: Current.session.created_at.to_i, + acr: Current.session.acr, expires_at: 10.minutes.from_now ) @@ -261,6 +262,7 @@ class OidcController < ApplicationController code_challenge: oauth_params['code_challenge'], code_challenge_method: oauth_params['code_challenge_method'], auth_time: Current.session.created_at.to_i, + acr: Current.session.acr, expires_at: 10.minutes.from_now ) @@ -402,7 +404,8 @@ class OidcController < ApplicationController user: user, oidc_access_token: access_token_record, scope: auth_code.scope, - auth_time: auth_code.auth_time + auth_time: auth_code.auth_time, + acr: auth_code.acr ) # Find user consent for this application @@ -414,15 +417,16 @@ class OidcController < ApplicationController return end - # Generate ID token (JWT) with pairwise SID, at_hash, and auth_time - # auth_time comes from the authorization code (captured at /authorize time) + # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr + # auth_time and acr come from the authorization code (captured at /authorize time) id_token = OidcJwtService.generate_id_token( user, application, consent: consent, nonce: auth_code.nonce, access_token: access_token_record.plaintext_token, - auth_time: auth_code.auth_time + auth_time: auth_code.auth_time, + acr: auth_code.acr ) # Return tokens @@ -528,7 +532,8 @@ class OidcController < ApplicationController oidc_access_token: new_access_token, scope: refresh_token_record.scope, token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking - auth_time: refresh_token_record.auth_time # Carry over original auth_time + auth_time: refresh_token_record.auth_time, # Carry over original auth_time + acr: refresh_token_record.acr # Carry over original acr ) # Find user consent for this application @@ -540,14 +545,15 @@ class OidcController < ApplicationController return end - # Generate new ID token (JWT with pairwise SID, at_hash, and auth_time; no nonce for refresh grants) - # auth_time comes from the original refresh token (carried over from initial auth) + # Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants) + # auth_time and acr come from the original refresh token (carried over from initial auth) id_token = OidcJwtService.generate_id_token( user, application, consent: consent, access_token: new_access_token.plaintext_token, - auth_time: refresh_token_record.auth_time + auth_time: refresh_token_record.auth_time, + acr: refresh_token_record.acr ) # Return new tokens diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 3ce4b5c..b379f91 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -71,8 +71,8 @@ class SessionsController < ApplicationController return end - # Sign in successful - start_new_session_for user + # Sign in successful (password only) + start_new_session_for user, acr: "1" redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true end @@ -101,26 +101,26 @@ class SessionsController < ApplicationController return end - # Try TOTP verification first + # Try TOTP verification first (password + TOTP = 2FA) if user.verify_totp(code) session.delete(:pending_totp_user_id) # Restore redirect URL if it was preserved if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end - start_new_session_for user + start_new_session_for user, acr: "2" redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true return end - # Try backup code verification + # Try backup code verification (password + backup code = 2FA) if user.verify_backup_code(code) session.delete(:pending_totp_user_id) # Restore redirect URL if it was preserved if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end - start_new_session_for user + start_new_session_for user, acr: "2" redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true return end @@ -268,8 +268,8 @@ class SessionsController < ApplicationController session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) end - # Create session - start_new_session_for user + # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2") + start_new_session_for user, acr: "2" render json: { success: true, diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index eb8b4e8..73bf9f9 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -3,7 +3,7 @@ class OidcJwtService class << self # Generate an ID token (JWT) for the user - def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil) + def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil) now = Time.current.to_i # Use application's configured ID token TTL (defaults to 1 hour) ttl = application.id_token_expiry_seconds @@ -29,6 +29,13 @@ class OidcJwtService # Add auth_time if provided (OIDC Core §2 - required when max_age is used) payload[:auth_time] = auth_time if auth_time.present? + # Add acr if provided (OIDC Core §2 - authentication context class reference) + payload[:acr] = acr if acr.present? + + # Add azp (authorized party) - the client_id this token was issued to + # OIDC Core §2 - required when aud has multiple values, optional but useful for single + payload[:azp] = application.client_id + # Add at_hash if access token is provided (OIDC Core spec §3.1.3.6) # at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded if access_token.present? diff --git a/db/migrate/20251231055350_add_auth_time_to_oidc_tokens.rb b/db/migrate/20251231055350_add_auth_time_to_oidc_tokens.rb new file mode 100644 index 0000000..581b356 --- /dev/null +++ b/db/migrate/20251231055350_add_auth_time_to_oidc_tokens.rb @@ -0,0 +1,6 @@ +class AddAuthTimeToOidcTokens < ActiveRecord::Migration[8.1] + def change + add_column :oidc_authorization_codes, :auth_time, :integer + add_column :oidc_refresh_tokens, :auth_time, :integer + end +end diff --git a/db/migrate/20251231060112_add_acr_to_oidc_tokens_and_sessions.rb b/db/migrate/20251231060112_add_acr_to_oidc_tokens_and_sessions.rb new file mode 100644 index 0000000..afbaf92 --- /dev/null +++ b/db/migrate/20251231060112_add_acr_to_oidc_tokens_and_sessions.rb @@ -0,0 +1,7 @@ +class AddAcrToOidcTokensAndSessions < ActiveRecord::Migration[8.1] + def change + add_column :sessions, :acr, :string + add_column :oidc_authorization_codes, :acr, :string + add_column :oidc_refresh_tokens, :acr, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 61a387f..9a41079 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[8.1].define(version: 2025_12_31_055350) do +ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -113,6 +113,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do end create_table "oidc_authorization_codes", force: :cascade do |t| + t.string "acr" t.integer "application_id", null: false t.integer "auth_time" t.string "code_challenge" @@ -135,6 +136,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do end create_table "oidc_refresh_tokens", force: :cascade do |t| + t.string "acr" t.integer "application_id", null: false t.integer "auth_time" t.datetime "created_at", null: false @@ -172,6 +174,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do end create_table "sessions", force: :cascade do |t| + t.string "acr" t.datetime "created_at", null: false t.string "device_name" t.datetime "expires_at" diff --git a/test/services/oidc_jwt_service_test.rb b/test/services/oidc_jwt_service_test.rb index e13c78c..f994fa1 100644 --- a/test/services/oidc_jwt_service_test.rb +++ b/test/services/oidc_jwt_service_test.rb @@ -539,4 +539,27 @@ class OidcJwtServiceTest < ActiveSupport::TestCase assert_equal auth_time, decoded_auth_code["auth_time"], "auth_time should be in authorization code flow" assert_equal auth_time, decoded_refresh["auth_time"], "auth_time should be in refresh token flow" end + + test "should include acr when provided" do + token = @service.generate_id_token(@user, @application, acr: "2") + + decoded = JWT.decode(token, nil, false).first + assert_includes decoded.keys, "acr", "Should include acr claim" + assert_equal "2", decoded["acr"], "acr should match provided value" + end + + test "should not include acr when not provided" do + token = @service.generate_id_token(@user, @application) + + decoded = JWT.decode(token, nil, false).first + refute_includes decoded.keys, "acr", "Should not include acr when not provided" + end + + test "should include azp (authorized party) with client_id" do + token = @service.generate_id_token(@user, @application) + + decoded = JWT.decode(token, nil, false).first + assert_includes decoded.keys, "azp", "Should include azp claim" + assert_equal @application.client_id, decoded["azp"], "azp should be the application's client_id" + end end \ No newline at end of file