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

This commit is contained in:
Dan Milne
2025-12-31 16:36:32 +11:00
parent 364e6e21dd
commit 4b4afe277e
8 changed files with 256 additions and 78 deletions

View File

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

View File

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

View File

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