Add auth_time, acr and azp support for OIDC claims

This commit is contained in:
Dan Milne
2025-12-31 17:07:54 +11:00
parent fcdd2b6de7
commit d036e25fef
8 changed files with 72 additions and 20 deletions

View File

@@ -44,9 +44,9 @@ module Authentication
final_url final_url
end end
def start_new_session_for(user) def start_new_session_for(user, acr: "1")
user.update!(last_sign_in_at: Time.current) 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 Current.session = session
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)

View File

@@ -163,6 +163,7 @@ class OidcController < ApplicationController
code_challenge: code_challenge, code_challenge: code_challenge,
code_challenge_method: code_challenge_method, code_challenge_method: code_challenge_method,
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -261,6 +262,7 @@ class OidcController < ApplicationController
code_challenge: oauth_params['code_challenge'], code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'], code_challenge_method: oauth_params['code_challenge_method'],
auth_time: Current.session.created_at.to_i, auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -402,7 +404,8 @@ class OidcController < ApplicationController
user: user, user: user,
oidc_access_token: access_token_record, oidc_access_token: access_token_record,
scope: auth_code.scope, 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 # Find user consent for this application
@@ -414,15 +417,16 @@ class OidcController < ApplicationController
return return
end end
# Generate ID token (JWT) with pairwise SID, at_hash, and auth_time # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
# auth_time comes from the authorization code (captured at /authorize time) # auth_time and acr come from the authorization code (captured at /authorize time)
id_token = OidcJwtService.generate_id_token( id_token = OidcJwtService.generate_id_token(
user, user,
application, application,
consent: consent, consent: consent,
nonce: auth_code.nonce, nonce: auth_code.nonce,
access_token: access_token_record.plaintext_token, 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 # Return tokens
@@ -528,7 +532,8 @@ class OidcController < ApplicationController
oidc_access_token: new_access_token, oidc_access_token: new_access_token,
scope: refresh_token_record.scope, scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id, # Keep same family for rotation tracking 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 # Find user consent for this application
@@ -540,14 +545,15 @@ class OidcController < ApplicationController
return return
end end
# Generate new ID token (JWT with pairwise SID, at_hash, and auth_time; no nonce for refresh grants) # Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
# auth_time comes from the original refresh token (carried over from initial auth) # auth_time and acr come from the original refresh token (carried over from initial auth)
id_token = OidcJwtService.generate_id_token( id_token = OidcJwtService.generate_id_token(
user, user,
application, application,
consent: consent, consent: consent,
access_token: new_access_token.plaintext_token, 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 # Return new tokens

View File

@@ -71,8 +71,8 @@ class SessionsController < ApplicationController
return return
end end
# Sign in successful # Sign in successful (password only)
start_new_session_for user start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
end end
@@ -101,26 +101,26 @@ class SessionsController < ApplicationController
return return
end end
# Try TOTP verification first # Try TOTP verification first (password + TOTP = 2FA)
if user.verify_totp(code) if user.verify_totp(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end 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 redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
return return
end end
# Try backup code verification # Try backup code verification (password + backup code = 2FA)
if user.verify_backup_code(code) if user.verify_backup_code(code)
session.delete(:pending_totp_user_id) session.delete(:pending_totp_user_id)
# Restore redirect URL if it was preserved # Restore redirect URL if it was preserved
if session[:totp_redirect_url].present? if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end 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 redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
return return
end end
@@ -268,8 +268,8 @@ class SessionsController < ApplicationController
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url) session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
end end
# Create session # Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
start_new_session_for user start_new_session_for user, acr: "2"
render json: { render json: {
success: true, success: true,

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self class << self
# Generate an ID token (JWT) for the user # 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 now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour) # Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds 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) # Add auth_time if provided (OIDC Core §2 - required when max_age is used)
payload[:auth_time] = auth_time if auth_time.present? 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) # 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 # at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
if access_token.present? if access_token.present?

View File

@@ -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

View File

@@ -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

5
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -113,6 +113,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do
end end
create_table "oidc_authorization_codes", force: :cascade do |t| create_table "oidc_authorization_codes", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.integer "auth_time" t.integer "auth_time"
t.string "code_challenge" t.string "code_challenge"
@@ -135,6 +136,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do
end end
create_table "oidc_refresh_tokens", force: :cascade do |t| create_table "oidc_refresh_tokens", force: :cascade do |t|
t.string "acr"
t.integer "application_id", null: false t.integer "application_id", null: false
t.integer "auth_time" t.integer "auth_time"
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -172,6 +174,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do
end end
create_table "sessions", force: :cascade do |t| create_table "sessions", force: :cascade do |t|
t.string "acr"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "device_name" t.string "device_name"
t.datetime "expires_at" t.datetime "expires_at"

View File

@@ -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_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" assert_equal auth_time, decoded_refresh["auth_time"], "auth_time should be in refresh token flow"
end 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 end