From 4b4afe277e9f31a804b9ca0fe8a73a97d2632b64 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Wed, 31 Dec 2025 16:36:32 +1100 Subject: [PATCH] Include auth_time in ID token. Switch from upsert -> find_and_create_by so we actually get sid values for consent on the creation of the record --- app/controllers/concerns/authentication.rb | 3 + app/controllers/oidc_controller.rb | 23 +-- app/services/oidc_jwt_service.rb | 5 +- .../oidc_authorization_code_security_test.rb | 52 ++--- test/controllers/oidc_pkce_controller_test.rb | 195 ++++++++++++++++-- .../oidc_refresh_token_controller_test.rb | 3 +- test/models/pkce_authorization_code_test.rb | 9 - test/services/oidc_jwt_service_test.rb | 44 ++++ 8 files changed, 256 insertions(+), 78 deletions(-) diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 542db5f..b1e4bbd 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -49,6 +49,9 @@ module Authentication user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| Current.session = session + # Store auth_time in session for OIDC max_age support + session[:auth_time] = Time.now.to_i + # Extract root domain for cross-subdomain cookies (required for forward auth) domain = extract_root_domain(request.host) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 6a44993..aa1dff5 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -245,15 +245,10 @@ class OidcController < ApplicationController # Record user consent requested_scopes = oauth_params['scope'].split(' ') - OidcUserConsent.upsert( - { - user_id: user.id, - application_id: application.id, - scopes_granted: requested_scopes.join(' '), - granted_at: Time.current - }, - unique_by: [:user_id, :application_id] - ) + consent = OidcUserConsent.find_or_initialize_by(user: user, application: application) + consent.scopes_granted = requested_scopes.join(' ') + consent.granted_at = Time.current + consent.save! # Generate authorization code auth_code = OidcAuthorizationCode.create!( @@ -416,13 +411,14 @@ class OidcController < ApplicationController return end - # Generate ID token (JWT) with pairwise SID and at_hash + # Generate ID token (JWT) with pairwise SID, at_hash, and auth_time id_token = OidcJwtService.generate_id_token( user, application, consent: consent, nonce: auth_code.nonce, - access_token: access_token_record.plaintext_token + access_token: access_token_record.plaintext_token, + auth_time: session[:auth_time] ) # Return tokens @@ -539,12 +535,13 @@ class OidcController < ApplicationController return end - # Generate new ID token (JWT with pairwise SID and at_hash, no nonce for refresh grants) + # Generate new ID token (JWT with pairwise SID, at_hash, and auth_time; no nonce for refresh grants) id_token = OidcJwtService.generate_id_token( user, application, consent: consent, - access_token: new_access_token.plaintext_token + access_token: new_access_token.plaintext_token, + auth_time: session[:auth_time] ) # Return new tokens diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 71eb8aa..eb8b4e8 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) + def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil) now = Time.current.to_i # Use application's configured ID token TTL (defaults to 1 hour) ttl = application.id_token_expiry_seconds @@ -26,6 +26,9 @@ class OidcJwtService # Add nonce if provided (OIDC requires this for implicit flow) payload[:nonce] = nonce if nonce.present? + # Add auth_time if provided (OIDC Core §2 - required when max_age is used) + payload[:auth_time] = auth_time if auth_time.present? + # 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/test/controllers/oidc_authorization_code_security_test.rb b/test/controllers/oidc_authorization_code_security_test.rb index c7c2ea2..fe115ac 100644 --- a/test/controllers/oidc_authorization_code_security_test.rb +++ b/test/controllers/oidc_authorization_code_security_test.rb @@ -47,7 +47,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -55,7 +54,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -94,7 +93,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -102,7 +100,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -149,7 +147,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", used: true, @@ -158,7 +155,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -186,7 +183,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 5.minutes.ago @@ -194,7 +190,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -221,7 +217,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -229,7 +224,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://evil.com/callback" # Wrong redirect URI } @@ -284,7 +279,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -293,7 +287,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Try to use it with different application credentials token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -325,7 +319,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -333,7 +326,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -359,7 +352,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -367,7 +359,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -393,7 +385,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -401,7 +392,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", client_id: @application.client_id, client_secret: @plain_client_secret @@ -428,7 +419,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -436,7 +426,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -495,7 +485,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -503,7 +492,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -616,7 +605,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", nonce: "test_nonce_123", @@ -626,7 +614,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Exchange code for tokens post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") @@ -660,7 +648,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -669,7 +656,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Exchange code for tokens post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") @@ -705,7 +692,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -716,7 +702,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Try to exchange code without code_verifier post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" }, headers: { "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}") @@ -745,7 +731,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -756,7 +741,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Exchange code with correct code_verifier post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", code_verifier: code_verifier }, headers: { @@ -785,7 +770,6 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -796,7 +780,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest # Try with wrong code_verifier post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", code_verifier: "wrong_code_verifier_12345678901234567890" }, headers: { @@ -855,9 +839,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest assert_not_equal old_refresh_token, new_refresh_token # Verify token family is preserved - new_token_record = OidcRefreshToken.where(application: @application).find do |rt| - rt.token_matches?(new_refresh_token) - end + new_token_record = OidcRefreshToken.find_by_token(new_refresh_token) assert_equal original_token_family_id, new_token_record.token_family_id # Old refresh token should be revoked diff --git a/test/controllers/oidc_pkce_controller_test.rb b/test/controllers/oidc_pkce_controller_test.rb index adf6504..80acb05 100644 --- a/test/controllers/oidc_pkce_controller_test.rb +++ b/test/controllers/oidc_pkce_controller_test.rb @@ -127,7 +127,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", @@ -137,7 +136,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -165,7 +164,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", @@ -175,7 +173,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -203,7 +201,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", @@ -213,7 +210,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", # Use a properly formatted but wrong verifier (43+ chars, base64url) code_verifier: "wrongverifier_with_enough_characters_base64url" @@ -249,7 +246,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -259,7 +255,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", code_verifier: code_verifier } @@ -291,7 +287,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_verifier, # Same as verifier for plain method @@ -301,7 +296,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback", code_verifier: code_verifier } @@ -342,7 +337,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: legacy_app, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:5000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -350,7 +344,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:5000/callback" } @@ -408,7 +402,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: public_app, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:6000/callback", scope: "openid profile", expires_at: 10.minutes.from_now, @@ -419,7 +412,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Token request with PKCE but no client_secret token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:6000/callback", client_id: public_app.client_id, code_verifier: code_verifier @@ -467,7 +460,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: public_app, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:7000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -476,7 +468,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Token request without PKCE should fail token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:7000/callback", client_id: public_app.client_id } @@ -514,7 +506,6 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -523,7 +514,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest # Token request without PKCE should fail token_params = { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: "http://localhost:4000/callback" } @@ -536,4 +527,172 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest assert_equal "invalid_request", error["error"] assert_match /PKCE is required/, error["error_description"] end + + # ==================== + # AUTH_TIME CLAIM TESTS + # ==================== + + test "ID token includes auth_time claim from session" do + # Create consent + OidcUserConsent.create!( + user: @user, + application: @application, + scopes_granted: "openid profile", + granted_at: Time.current, + sid: "test-sid-auth-time" + ) + + # Generate valid PKCE pair + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Digest::SHA256.base64digest(code_verifier) + .tr("+/", "-_") + .tr("=", "") + + # Set auth_time in session (simulating user login) + session[:auth_time] = Time.now.to_i - 300 # 5 minutes ago + + # Create authorization code + auth_code = OidcAuthorizationCode.create!( + application: @application, + user: @user, + redirect_uri: "http://localhost:4000/callback", + scope: "openid profile", + code_challenge: code_challenge, + code_challenge_method: "S256", + expires_at: 10.minutes.from_now + ) + + token_params = { + grant_type: "authorization_code", + code: auth_code.plaintext_code, + redirect_uri: "http://localhost:4000/callback", + code_verifier: code_verifier + } + + post "/oauth/token", params: token_params, headers: { + "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}") + } + + assert_response :success + tokens = JSON.parse(@response.body) + assert tokens.key?("id_token") + + # Decode and verify auth_time is present + decoded = JWT.decode(tokens["id_token"], nil, false).first + assert_includes decoded.keys, "auth_time", "ID token should include auth_time" + assert_equal session[:auth_time], decoded["auth_time"], "auth_time should match session value" + end + + test "ID token includes auth_time in refresh token flow" do + # Create consent + OidcUserConsent.create!( + user: @user, + application: @application, + scopes_granted: "openid profile offline_access", + granted_at: Time.current, + sid: "test-sid-refresh-auth-time" + ) + + # Set auth_time in session + session[:auth_time] = Time.now.to_i - 600 # 10 minutes ago + + # Create initial access and refresh tokens (bypass PKCE for this test) + auth_code = OidcAuthorizationCode.create!( + application: @application, + user: @user, + redirect_uri: "http://localhost:4000/callback", + scope: "openid profile offline_access", + code_challenge: nil, + code_challenge_method: nil, + expires_at: 10.minutes.from_now + ) + + # Update application to not require PKCE for testing + @application.update!(require_pkce: false) + + token_params = { + grant_type: "authorization_code", + code: auth_code.plaintext_code, + redirect_uri: "http://localhost:4000/callback" + } + + post "/oauth/token", params: token_params, headers: { + "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}") + } + + assert_response :success + tokens = JSON.parse(@response.body) + refresh_token = tokens["refresh_token"] + + # Now use the refresh token + refresh_params = { + grant_type: "refresh_token", + refresh_token: refresh_token + } + + post "/oauth/token", params: refresh_params, headers: { + "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}") + } + + assert_response :success + new_tokens = JSON.parse(@response.body) + assert new_tokens.key?("id_token") + + # Decode and verify auth_time is still present from refresh + 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 session[:auth_time], decoded["auth_time"], "auth_time should persist from original session" + end + + test "at_hash is correctly computed and included in ID token" do + # Create consent + OidcUserConsent.create!( + user: @user, + application: @application, + scopes_granted: "openid profile", + granted_at: Time.current, + sid: "test-sid-at-hash" + ) + + # Generate valid PKCE pair + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Digest::SHA256.base64digest(code_verifier) + .tr("+/", "-_") + .tr("=", "") + + # Create authorization code + auth_code = OidcAuthorizationCode.create!( + application: @application, + user: @user, + redirect_uri: "http://localhost:4000/callback", + scope: "openid profile", + code_challenge: code_challenge, + code_challenge_method: "S256", + expires_at: 10.minutes.from_now + ) + + token_params = { + grant_type: "authorization_code", + code: auth_code.plaintext_code, + redirect_uri: "http://localhost:4000/callback", + code_verifier: code_verifier + } + + post "/oauth/token", params: token_params, headers: { + "Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}") + } + + assert_response :success + tokens = JSON.parse(@response.body) + access_token = tokens["access_token"] + id_token = tokens["id_token"] + + # Decode ID token + decoded = JWT.decode(id_token, nil, false).first + assert_includes decoded.keys, "at_hash", "ID token should include at_hash" + + # Verify at_hash matches the access token hash + expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false) + assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token" + end end \ No newline at end of file diff --git a/test/controllers/oidc_refresh_token_controller_test.rb b/test/controllers/oidc_refresh_token_controller_test.rb index fe49e9a..02aeec4 100644 --- a/test/controllers/oidc_refresh_token_controller_test.rb +++ b/test/controllers/oidc_refresh_token_controller_test.rb @@ -15,7 +15,6 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: @application.parsed_redirect_uris.first, scope: "openid profile email", expires_at: 10.minutes.from_now @@ -24,7 +23,7 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest # Exchange authorization code for tokens post "/oauth/token", params: { grant_type: "authorization_code", - code: auth_code.code, + code: auth_code.plaintext_code, redirect_uri: @application.parsed_redirect_uris.first, client_id: @application.client_id, client_secret: @client_secret diff --git a/test/models/pkce_authorization_code_test.rb b/test/models/pkce_authorization_code_test.rb index 0a92f15..39e8a2b 100644 --- a/test/models/pkce_authorization_code_test.rb +++ b/test/models/pkce_authorization_code_test.rb @@ -26,7 +26,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -46,7 +45,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: code_challenge, @@ -63,7 +61,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.create!( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now @@ -78,7 +75,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", @@ -93,7 +89,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", @@ -112,7 +107,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: valid_challenge, @@ -130,7 +124,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: invalid_challenge, @@ -149,7 +142,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", code_challenge: short_challenge, @@ -165,7 +157,6 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase auth_code = OidcAuthorizationCode.new( application: @application, user: @user, - code: SecureRandom.urlsafe_base64(32), redirect_uri: "http://localhost:4000/callback", scope: "openid profile", expires_at: 10.minutes.from_now diff --git a/test/services/oidc_jwt_service_test.rb b/test/services/oidc_jwt_service_test.rb index 499a96b..e13c78c 100644 --- a/test/services/oidc_jwt_service_test.rb +++ b/test/services/oidc_jwt_service_test.rb @@ -495,4 +495,48 @@ class OidcJwtServiceTest < ActiveSupport::TestCase decoded = JWT.decode(token, nil, false).first refute_includes decoded.keys, "at_hash", "Should not include at_hash when no access token" end + + test "should include auth_time when provided" do + auth_time = Time.now.to_i - 300 # 5 minutes ago + token = @service.generate_id_token(@user, @application, auth_time: auth_time) + + decoded = JWT.decode(token, nil, false).first + assert_includes decoded.keys, "auth_time", "Should include auth_time claim" + assert_equal auth_time, decoded["auth_time"], "auth_time should match provided value" + end + + test "should not include auth_time when not provided" do + token = @service.generate_id_token(@user, @application) + + decoded = JWT.decode(token, nil, false).first + refute_includes decoded.keys, "auth_time", "Should not include auth_time when not provided" + end + + test "auth_time should be included in both authorization code and refresh token flows" do + auth_time = Time.now.to_i - 600 # 10 minutes ago + access_token = "test-access-token" + + # Authorization code flow (with nonce) + token_with_auth_code = @service.generate_id_token( + @user, + @application, + nonce: "test-nonce", + access_token: access_token, + auth_time: auth_time + ) + + # Refresh token flow (no nonce) + token_with_refresh = @service.generate_id_token( + @user, + @application, + access_token: access_token, + auth_time: auth_time + ) + + decoded_auth_code = JWT.decode(token_with_auth_code, nil, false).first + decoded_refresh = JWT.decode(token_with_refresh, nil, false).first + + 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 end \ No newline at end of file