From fcdd2b6de734dc5ba0be24b291e2b48f4af5ff50 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Wed, 31 Dec 2025 16:57:28 +1100 Subject: [PATCH] Continue adding auth_time - need it in the refresh token too, so we can accurately create new access tokens. --- app/controllers/oidc_controller.rb | 16 ++++++++++------ db/schema.rb | 4 +++- test/controllers/oidc_pkce_controller_test.rb | 16 +++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index e93578f..df72daf 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -162,6 +162,7 @@ class OidcController < ApplicationController nonce: nonce, code_challenge: code_challenge, code_challenge_method: code_challenge_method, + auth_time: Current.session.created_at.to_i, expires_at: 10.minutes.from_now ) @@ -259,6 +260,7 @@ class OidcController < ApplicationController nonce: oauth_params['nonce'], code_challenge: oauth_params['code_challenge'], code_challenge_method: oauth_params['code_challenge_method'], + auth_time: Current.session.created_at.to_i, expires_at: 10.minutes.from_now ) @@ -399,7 +401,8 @@ class OidcController < ApplicationController application: application, user: user, oidc_access_token: access_token_record, - scope: auth_code.scope + scope: auth_code.scope, + auth_time: auth_code.auth_time ) # Find user consent for this application @@ -412,14 +415,14 @@ class OidcController < ApplicationController end # Generate ID token (JWT) with pairwise SID, at_hash, and auth_time - # auth_time comes from the Session model's created_at (when user logged in) + # auth_time comes 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: Current.session.created_at.to_i + auth_time: auth_code.auth_time ) # Return tokens @@ -524,7 +527,8 @@ class OidcController < ApplicationController user: user, 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 + 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 ) # Find user consent for this application @@ -537,13 +541,13 @@ class OidcController < ApplicationController end # Generate new ID token (JWT with pairwise SID, at_hash, and auth_time; no nonce for refresh grants) - # auth_time comes from the Session model's created_at (when user logged in) + # auth_time comes 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: Current.session.created_at.to_i + auth_time: refresh_token_record.auth_time ) # Return new tokens diff --git a/db/schema.rb b/db/schema.rb index 14327f3..61a387f 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_043838) do +ActiveRecord::Schema[8.1].define(version: 2025_12_31_055350) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -114,6 +114,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do create_table "oidc_authorization_codes", force: :cascade do |t| t.integer "application_id", null: false + t.integer "auth_time" t.string "code_challenge" t.string "code_challenge_method" t.string "code_hmac", null: false @@ -135,6 +136,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_043838) do create_table "oidc_refresh_tokens", force: :cascade do |t| t.integer "application_id", null: false + t.integer "auth_time" t.datetime "created_at", null: false t.datetime "expires_at", null: false t.integer "oidc_access_token_id", null: false diff --git a/test/controllers/oidc_pkce_controller_test.rb b/test/controllers/oidc_pkce_controller_test.rb index ba4502e..b2ec8e1 100644 --- a/test/controllers/oidc_pkce_controller_test.rb +++ b/test/controllers/oidc_pkce_controller_test.rb @@ -532,7 +532,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # AUTH_TIME CLAIM TESTS # ==================== - test "ID token includes auth_time claim from session created_at" do + test "ID token includes auth_time claim from authorization code" do # Create consent OidcUserConsent.create!( user: @user, @@ -551,7 +551,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Get the expected auth_time from the session's created_at expected_auth_time = Current.session.created_at.to_i - # Create authorization code + # Create authorization code with auth_time auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, @@ -559,6 +559,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest scope: "openid profile", code_challenge: code_challenge, code_challenge_method: "S256", + auth_time: expected_auth_time, expires_at: 10.minutes.from_now ) @@ -577,10 +578,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest tokens = JSON.parse(@response.body) assert tokens.key?("id_token") - # Decode and verify auth_time is present and matches session created_at + # Decode and verify auth_time is present and matches what we stored decoded = JWT.decode(tokens["id_token"], nil, false).first assert_includes decoded.keys, "auth_time", "ID token should include auth_time" - assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match session created_at" + assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match authorization code" end test "ID token includes auth_time in refresh token flow" do @@ -596,7 +597,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Get the expected auth_time from the session's created_at expected_auth_time = Current.session.created_at.to_i - # Create initial access and refresh tokens (bypass PKCE for this test) + # Create initial access and refresh tokens with auth_time auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, @@ -604,6 +605,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest scope: "openid profile offline_access", code_challenge: nil, code_challenge_method: nil, + auth_time: expected_auth_time, expires_at: 10.minutes.from_now ) @@ -638,10 +640,10 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest new_tokens = JSON.parse(@response.body) assert new_tokens.key?("id_token") - # Decode and verify auth_time is still present from session created_at + # Decode and verify auth_time is preserved from original authorization decoded = JWT.decode(new_tokens["id_token"], nil, false).first assert_includes decoded.keys, "auth_time", "Refreshed ID token should include auth_time" - assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match session created_at" + assert_equal expected_auth_time, decoded["auth_time"], "auth_time should match original authorization code" end test "at_hash is correctly computed and included in ID token" do