Add auth_time, acr and azp support for OIDC claims
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
5
db/schema.rb
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user