Two more fixes from the device-flow security review: slow_down interval grew without bound (OidcController): - Each too-fast poll bumped the persisted interval by 5s with no ceiling, so a client polling slightly fast — or an attacker spamming a known device_code — could balloon it (5→10→15→…) past the 10-minute expiry and starve a legitimate client of its token. Clamp the bump to OidcDeviceCode::MAX_INTERVAL (30s); a client polling at the advertised interval is never throttled. Expired device codes accumulated forever (OidcTokenCleanupJob): - Anonymous callers can create device codes via /oauth/device_authorization, and expired/denied/abandoned rows were never reaped. Sweep rows past expiry (with a 1-hour grace to stay clear of in-flight redemption); redeemed codes are already destroyed at token issuance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Q4ATZHoMCWqvSpE2yYoie
277 lines
10 KiB
Ruby
277 lines
10 KiB
Ruby
require "test_helper"
|
|
|
|
class OidcDeviceFlowControllerTest < ActionDispatch::IntegrationTest
|
|
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
|
|
|
def setup
|
|
@group = Group.create!(name: "device-flow-testers", description: "test")
|
|
@user = User.create!(email_address: "device_flow@example.com", password: "password123")
|
|
@user.groups << @group
|
|
|
|
@cli = Application.create!(
|
|
name: "Device Flow CLI",
|
|
slug: "device-flow-cli",
|
|
app_type: "oidc",
|
|
is_public_client: true,
|
|
active: true
|
|
)
|
|
@cli.allowed_groups << @group
|
|
|
|
@resource_secret = "resource-server-secret-value-1234567890"
|
|
@resource = Application.create!(
|
|
name: "Device Flow Resource Server",
|
|
slug: "device-flow-rs",
|
|
app_type: "oidc",
|
|
client_secret: @resource_secret,
|
|
active: true
|
|
)
|
|
end
|
|
|
|
def teardown
|
|
Current.session = nil
|
|
[@cli, @resource].each do |app|
|
|
OidcRefreshToken.where(application: app).delete_all
|
|
OidcAccessToken.where(application: app).delete_all
|
|
OidcDeviceCode.where(application: app).delete_all
|
|
OidcUserConsent.where(application: app).delete_all
|
|
end
|
|
end
|
|
|
|
# --- Discovery -------------------------------------------------------------
|
|
|
|
test "discovery advertises the device grant and new endpoints" do
|
|
get "/.well-known/openid-configuration"
|
|
assert_response :success
|
|
config = JSON.parse(@response.body)
|
|
|
|
assert_includes config["grant_types_supported"], DEVICE_GRANT
|
|
assert config["device_authorization_endpoint"].end_with?("/oauth/device_authorization")
|
|
assert config["introspection_endpoint"].end_with?("/oauth/introspect")
|
|
end
|
|
|
|
# --- Device authorization endpoint -----------------------------------------
|
|
|
|
test "device_authorization issues a device_code and user_code" do
|
|
post "/oauth/device_authorization", params: {
|
|
client_id: @cli.client_id, scope: "openid groups",
|
|
code_challenge: code_challenge_for(CODE_VERIFIER)
|
|
}
|
|
assert_response :success
|
|
body = JSON.parse(@response.body)
|
|
|
|
assert body["device_code"].present?
|
|
assert_match(/\A[A-HJ-NP-Z2-9]{8}\z/, body["user_code"])
|
|
assert body["verification_uri"].end_with?("/device")
|
|
assert body["verification_uri_complete"].include?("user_code=#{body["user_code"]}")
|
|
assert_equal 5, body["interval"]
|
|
assert body["expires_in"].positive?
|
|
end
|
|
|
|
test "device_authorization requires PKCE for a public client" do
|
|
post "/oauth/device_authorization", params: {client_id: @cli.client_id, scope: "openid"}
|
|
assert_response :bad_request
|
|
assert_equal "invalid_request", JSON.parse(@response.body)["error"]
|
|
assert_equal 0, OidcDeviceCode.where(application: @cli).count
|
|
end
|
|
|
|
test "device_authorization rejects an unknown client" do
|
|
post "/oauth/device_authorization", params: {client_id: "does-not-exist"}
|
|
assert_response :unauthorized
|
|
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "device_authorization rejects a confidential client with no secret" do
|
|
post "/oauth/device_authorization", params: {client_id: @resource.client_id, scope: "openid"}
|
|
assert_response :unauthorized
|
|
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "device_authorization rejects a confidential client with a wrong secret" do
|
|
post "/oauth/device_authorization",
|
|
params: {client_id: @resource.client_id, client_secret: "wrong-secret", scope: "openid"}
|
|
assert_response :unauthorized
|
|
assert_equal "invalid_client", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "device_authorization accepts a confidential client with a valid secret" do
|
|
post "/oauth/device_authorization", params: {
|
|
client_id: @resource.client_id, client_secret: @resource_secret, scope: "openid",
|
|
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
|
|
}
|
|
assert_response :success
|
|
assert JSON.parse(@response.body)["device_code"].present?
|
|
end
|
|
|
|
# --- Token endpoint device_code grant --------------------------------------
|
|
|
|
test "token endpoint returns authorization_pending while pending" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
poll(dc)
|
|
assert_response :bad_request
|
|
assert_equal "authorization_pending", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "token endpoint returns slow_down when polled faster than the interval" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
poll(dc) # first poll records last_polled_at
|
|
poll(dc) # immediate second poll is too fast
|
|
assert_response :bad_request
|
|
assert_equal "slow_down", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "slow_down interval is capped and does not grow without bound" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
# Hammer the code far more times than it would take to exceed the cap if the
|
|
# interval grew by 5 unbounded (20 * 5 = 100s >> MAX_INTERVAL).
|
|
20.times { poll(dc) }
|
|
assert_operator dc.reload.interval, :<=, OidcDeviceCode::MAX_INTERVAL
|
|
end
|
|
|
|
test "token endpoint returns expired_token for an expired code" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid", expires_at: 1.minute.ago)
|
|
poll(dc)
|
|
assert_response :bad_request
|
|
assert_equal "expired_token", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "token endpoint returns access_denied when the user denied" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
dc.deny!
|
|
poll(dc)
|
|
assert_response :bad_request
|
|
assert_equal "access_denied", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "token endpoint issues tokens once approved, then the code is single-use" do
|
|
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid groups", granted_at: Time.current)
|
|
dc = OidcDeviceCode.create!(
|
|
application: @cli, scope: "openid groups",
|
|
code_challenge: code_challenge_for(CODE_VERIFIER), code_challenge_method: "S256"
|
|
)
|
|
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
|
|
|
poll(dc, code_verifier: CODE_VERIFIER)
|
|
assert_response :success
|
|
body = JSON.parse(@response.body)
|
|
assert body["access_token"].present?
|
|
assert body["refresh_token"].present?
|
|
assert body["id_token"].present?
|
|
assert_equal "Bearer", body["token_type"]
|
|
assert_equal "openid groups", body["scope"]
|
|
|
|
# Replaying the (now consumed) device_code fails.
|
|
poll(dc, code_verifier: CODE_VERIFIER)
|
|
assert_response :bad_request
|
|
assert_equal "invalid_grant", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
test "token endpoint refuses a PKCE-required client whose device_code lacks a challenge" do
|
|
OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
|
|
# A device_code minted without PKCE (e.g. slipped past the front door) must
|
|
# never redeem tokens for a public client.
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i)
|
|
|
|
poll(dc)
|
|
assert_response :bad_request
|
|
assert_equal "invalid_grant", JSON.parse(@response.body)["error"]
|
|
end
|
|
|
|
# --- Verification page -----------------------------------------------------
|
|
|
|
test "verification page shows the approval prompt for a signed-in allowed user" do
|
|
sign_in_as(@user)
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups")
|
|
|
|
get "/device", params: {user_code: dc.user_code}
|
|
assert_response :success
|
|
assert_match(/Approve/, @response.body)
|
|
assert_match(dc.user_code, @response.body)
|
|
end
|
|
|
|
test "approving records consent and approves the device code" do
|
|
sign_in_as(@user)
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid groups")
|
|
|
|
post "/device", params: {user_code: dc.user_code}
|
|
assert_response :success
|
|
assert_match(/approved/i, @response.body)
|
|
|
|
dc.reload
|
|
assert dc.approved?
|
|
assert_equal @user, dc.user
|
|
assert OidcUserConsent.exists?(user: @user, application: @cli)
|
|
end
|
|
|
|
test "approving a narrower device request merges into existing consent" do
|
|
# User already consented to a broader scope set (with stored claims) via the
|
|
# browser flow.
|
|
existing = OidcUserConsent.create!(
|
|
user: @user, application: @cli,
|
|
scopes_granted: "openid email profile groups",
|
|
claims_requests: {"userinfo" => {"email" => nil}},
|
|
granted_at: 1.day.ago
|
|
)
|
|
|
|
sign_in_as(@user)
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
post "/device", params: {user_code: dc.user_code}
|
|
assert_response :success
|
|
|
|
existing.reload
|
|
# Prior scopes are preserved (union), not shrunk to the device request's "openid".
|
|
assert_equal %w[openid email profile groups].sort, existing.scopes.sort
|
|
# Stored claims are not wiped.
|
|
assert_equal({"userinfo" => {"email" => nil}}, existing.parsed_claims_requests)
|
|
end
|
|
|
|
test "denying marks the device code denied" do
|
|
sign_in_as(@user)
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
|
|
post "/device", params: {user_code: dc.user_code, deny: "1"}
|
|
assert_response :success
|
|
dc.reload
|
|
assert dc.denied?
|
|
end
|
|
|
|
test "a user without access cannot approve" do
|
|
outsider = User.create!(email_address: "outsider@example.com", password: "password123")
|
|
sign_in_as(outsider)
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
|
|
post "/device", params: {user_code: dc.user_code}
|
|
assert_response :success
|
|
assert_match(/not allowed/i, @response.body)
|
|
dc.reload
|
|
assert dc.pending?, "device code must stay pending when approval is refused"
|
|
end
|
|
|
|
test "verification page requires authentication" do
|
|
dc = OidcDeviceCode.create!(application: @cli, scope: "openid")
|
|
get "/device", params: {user_code: dc.user_code}
|
|
assert_redirected_to signin_path
|
|
end
|
|
|
|
# Introspection is covered in depth in oidc_introspection_test.rb.
|
|
|
|
private
|
|
|
|
# A valid PKCE verifier (48 chars, RFC 7636 charset) and its S256 challenge.
|
|
CODE_VERIFIER = "device_flow_pkce_code_verifier_0123456789_abcdef".freeze
|
|
|
|
def code_challenge_for(verifier)
|
|
Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
end
|
|
|
|
def poll(device_code, code_verifier: nil)
|
|
params = {
|
|
grant_type: DEVICE_GRANT,
|
|
device_code: device_code.plaintext_device_code,
|
|
client_id: @cli.client_id
|
|
}
|
|
params[:code_verifier] = code_verifier if code_verifier
|
|
post "/oauth/token", params: params
|
|
end
|
|
end
|