Add test files, update checklist
Some checks failed
Some checks failed
This commit is contained in:
394
test/controllers/oidc_claims_security_test.rb
Normal file
394
test/controllers/oidc_claims_security_test.rb
Normal file
@@ -0,0 +1,394 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
|
||||
@application = Application.create!(
|
||||
name: "Claims Security Test App",
|
||||
slug: "claims-security-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
|
||||
# Store the plain text client secret for testing
|
||||
@application.generate_new_client_secret!
|
||||
@plain_client_secret = @application.client_secret
|
||||
@application.save!
|
||||
end
|
||||
|
||||
def teardown
|
||||
# Delete in correct order to avoid foreign key constraints
|
||||
OidcRefreshToken.where(application: @application).delete_all
|
||||
OidcAccessToken.where(application: @application).delete_all
|
||||
OidcAuthorizationCode.where(application: @application).delete_all
|
||||
OidcUserConsent.where(application: @application).delete_all
|
||||
@user.destroy
|
||||
@application.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# CLAIMS PARAMETER ESCALATION ATTACKS
|
||||
# ====================
|
||||
|
||||
test "rejects claims parameter during authorization code exchange" do
|
||||
# Create consent with minimal scopes (no profile, email, or admin access)
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
|
||||
# The client is trying to request 'admin' claim that they never got consent for
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(response.body)
|
||||
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
|
||||
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
|
||||
end
|
||||
|
||||
test "rejects claims parameter during authorization code exchange with profile escalation" do
|
||||
# Create consent with ONLY openid scope (no profile scope)
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
# SHOULD: Reject the claims parameter
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
end
|
||||
|
||||
test "rejects claims parameter during refresh token grant" do
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
|
||||
# Trying to escalate to admin claims during refresh
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
# SHOULD: Reject the claims parameter
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(response.body)
|
||||
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
|
||||
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
|
||||
end
|
||||
|
||||
test "rejects claims parameter during refresh token grant with custom claims escalation" do
|
||||
# Setup: User has a custom claim at user level
|
||||
@user.update!(custom_claims: {"role" => "user"})
|
||||
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
# ATTEMPT: Try to escalate role to admin via claims parameter
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token,
|
||||
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
# SHOULD: Reject the claims parameter
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
end
|
||||
|
||||
test "allows token exchange without claims parameter" do
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Normal token exchange WITHOUT claims parameter should work fine
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
assert response_body.key?("access_token")
|
||||
assert response_body.key?("id_token")
|
||||
end
|
||||
|
||||
test "allows refresh without claims parameter" do
|
||||
# Create consent for this application
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-refresh-456"
|
||||
)
|
||||
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid profile"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid profile"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
# Normal refresh WITHOUT claims parameter should work fine
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
assert response_body.key?("access_token")
|
||||
assert response_body.key?("id_token")
|
||||
end
|
||||
|
||||
# ====================
|
||||
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
|
||||
# ====================
|
||||
|
||||
test "claims parameter is only valid in authorization request per OIDC spec" do
|
||||
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
|
||||
# This test verifies that claims parameter cannot be used at token endpoint
|
||||
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Test various attempts to inject claims parameter
|
||||
malicious_claims = [
|
||||
'{"id_token":{"admin":true}}',
|
||||
'{"id_token":{"email":{"essential":true}}}',
|
||||
'{"userinfo":{"groups":{"values":["admin"]}}}',
|
||||
'{"id_token":{"custom_claim":"custom_value"}}',
|
||||
'invalid-json'
|
||||
]
|
||||
|
||||
malicious_claims.each do |claims_value|
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
claims: claims_value
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
# All should be rejected
|
||||
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
|
||||
error = JSON.parse(response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
end
|
||||
end
|
||||
|
||||
# ====================
|
||||
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
|
||||
# ====================
|
||||
|
||||
test "token endpoint respects scopes granted during authorization" do
|
||||
# Create consent with ONLY openid scope (no email, profile, etc.)
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
redirect_uri: "http://localhost:4000/callback",
|
||||
scope: "openid",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.plaintext_code,
|
||||
redirect_uri: "http://localhost:4000/callback"
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
id_token = response_body["id_token"]
|
||||
|
||||
# Decode ID token to check claims
|
||||
decoded = JWT.decode(id_token, nil, false).first
|
||||
|
||||
# Should only have required claims, not email/profile
|
||||
assert_includes decoded.keys, "iss"
|
||||
assert_includes decoded.keys, "sub"
|
||||
assert_includes decoded.keys, "aud"
|
||||
assert_includes decoded.keys, "exp"
|
||||
assert_includes decoded.keys, "iat"
|
||||
|
||||
# Should NOT have claims that weren't consented to
|
||||
refute_includes decoded.keys, "email", "Should not include email without email scope"
|
||||
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
|
||||
refute_includes decoded.keys, "name", "Should not include name without profile scope"
|
||||
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
|
||||
end
|
||||
|
||||
test "refresh token preserves original scopes granted during authorization" do
|
||||
# Create consent with specific scopes
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid email",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-refresh-123"
|
||||
)
|
||||
|
||||
access_token = OidcAccessToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
scope: "openid email"
|
||||
)
|
||||
|
||||
refresh_token = OidcRefreshToken.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
oidc_access_token: access_token,
|
||||
scope: "openid email"
|
||||
)
|
||||
|
||||
plaintext_refresh_token = refresh_token.token
|
||||
|
||||
# Refresh the token
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: plaintext_refresh_token
|
||||
}, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
response_body = JSON.parse(response.body)
|
||||
id_token = response_body["id_token"]
|
||||
|
||||
# Decode ID token to verify scopes are preserved
|
||||
decoded = JWT.decode(id_token, nil, false).first
|
||||
|
||||
# Should have email claims (from original consent)
|
||||
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
|
||||
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
|
||||
|
||||
# Should NOT have profile claims (not in original consent)
|
||||
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
|
||||
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
|
||||
end
|
||||
end
|
||||
236
test/controllers/oidc_prompt_login_test.rb
Normal file
236
test/controllers/oidc_prompt_login_test.rb
Normal file
@@ -0,0 +1,236 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:alice)
|
||||
@application = applications(:kavita_app)
|
||||
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||
@application.client_secret = @client_secret
|
||||
@application.save!
|
||||
|
||||
# Pre-authorize the application so we skip consent screen
|
||||
consent = OidcUserConsent.find_or_initialize_by(
|
||||
user: @user,
|
||||
application: @application
|
||||
)
|
||||
consent.scopes_granted ||= "openid profile email"
|
||||
consent.save!
|
||||
end
|
||||
|
||||
teardown do
|
||||
# Clean up
|
||||
OidcAccessToken.where(user: @user, application: @application).destroy_all
|
||||
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
|
||||
end
|
||||
|
||||
test "max_age requires re-authentication when session is too old" do
|
||||
# Sign in to create a session
|
||||
post "/signin", params: {
|
||||
email_address: @user.email_address,
|
||||
password: "password"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
|
||||
# Get first auth_time
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
response_type: "code",
|
||||
scope: "openid",
|
||||
state: "first-state",
|
||||
nonce: "first-nonce"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
first_redirect_url = response.location
|
||||
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||
|
||||
# Exchange for tokens and extract auth_time
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: first_code,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
first_tokens = JSON.parse(response.body)
|
||||
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||
first_auth_time = first_id_token[0]["auth_time"]
|
||||
|
||||
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
|
||||
# Then request with max_age=0 (means session must be brand new)
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
response_type: "code",
|
||||
scope: "openid",
|
||||
state: "second-state",
|
||||
nonce: "second-nonce",
|
||||
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
|
||||
}
|
||||
|
||||
# Should redirect to sign in because session is too old
|
||||
assert_response :redirect
|
||||
assert_redirected_to /signin/
|
||||
|
||||
# Sign in again
|
||||
post "/signin", params: {
|
||||
email_address: @user.email_address,
|
||||
password: "password"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
follow_redirect!
|
||||
|
||||
# Should receive authorization code
|
||||
assert_response :redirect
|
||||
second_redirect_url = response.location
|
||||
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||
|
||||
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||
|
||||
# Exchange second authorization code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: second_code,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
second_tokens = JSON.parse(response.body)
|
||||
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||
second_auth_time = second_id_token[0]["auth_time"]
|
||||
|
||||
# The second auth_time should be >= the first (re-authentication occurred)
|
||||
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||
assert second_auth_time >= first_auth_time,
|
||||
"max_age=0 should result in a re-authentication. " \
|
||||
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||
end
|
||||
|
||||
test "prompt=none returns login_required error when not authenticated" do
|
||||
# Don't sign in - user is not authenticated
|
||||
|
||||
# Request authorization with prompt=none
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
response_type: "code",
|
||||
scope: "openid",
|
||||
state: "test-state",
|
||||
prompt: "none"
|
||||
}
|
||||
|
||||
# Should redirect with error=login_required (NOT to sign-in page)
|
||||
assert_response :redirect
|
||||
redirect_url = response.location
|
||||
|
||||
# Parse the redirect URL
|
||||
uri = URI.parse(redirect_url)
|
||||
query_params = uri.query ? CGI.parse(uri.query) : {}
|
||||
|
||||
assert_equal "login_required", query_params["error"]&.first,
|
||||
"Should return login_required error for prompt=none when not authenticated"
|
||||
assert_equal "test-state", query_params["state"]&.first,
|
||||
"Should return state parameter"
|
||||
end
|
||||
|
||||
test "prompt=login forces re-authentication with new auth_time" do
|
||||
# First authentication
|
||||
post "/signin", params: {
|
||||
email_address: @user.email_address,
|
||||
password: "password"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
|
||||
# Get first authorization code
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
response_type: "code",
|
||||
scope: "openid",
|
||||
state: "first-state",
|
||||
nonce: "first-nonce"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
first_redirect_url = response.location
|
||||
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||
|
||||
# Exchange for tokens and extract auth_time from ID token
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: first_code,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
first_tokens = JSON.parse(response.body)
|
||||
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||
first_auth_time = first_id_token[0]["auth_time"]
|
||||
|
||||
# Now request authorization again with prompt=login
|
||||
get "/oauth/authorize", params: {
|
||||
client_id: @application.client_id,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
response_type: "code",
|
||||
scope: "openid",
|
||||
state: "second-state",
|
||||
nonce: "second-nonce",
|
||||
prompt: "login"
|
||||
}
|
||||
|
||||
# Should redirect to sign in
|
||||
assert_response :redirect
|
||||
assert_redirected_to /signin/
|
||||
|
||||
# Sign in again (simulating user re-authentication)
|
||||
post "/signin", params: {
|
||||
email_address: @user.email_address,
|
||||
password: "password"
|
||||
}
|
||||
|
||||
assert_response :redirect
|
||||
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
|
||||
follow_redirect!
|
||||
|
||||
# Should receive authorization code redirect
|
||||
assert_response :redirect
|
||||
second_redirect_url = response.location
|
||||
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||
|
||||
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||
|
||||
# Exchange second authorization code for tokens
|
||||
post "/oauth/token", params: {
|
||||
grant_type: "authorization_code",
|
||||
code: second_code,
|
||||
redirect_uri: @application.parsed_redirect_uris.first,
|
||||
client_id: @application.client_id,
|
||||
client_secret: @client_secret
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
second_tokens = JSON.parse(response.body)
|
||||
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||
second_auth_time = second_id_token[0]["auth_time"]
|
||||
|
||||
# The second auth_time should be >= the first (re-authentication occurred)
|
||||
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||
assert second_auth_time >= first_auth_time,
|
||||
"prompt=login should result in a later auth_time. " \
|
||||
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user