Files
clinch/test/controllers/oidc_device_flow_controller_test.rb
T
Dan MilneandClaude Opus 4.8 017dfdff0e Harden OIDC token endpoints from security review
Fixes from review of the device flow / introspection / DCR work:

Introspection over-disclosure (RFC 7662):
- Restrict introspection to authorized callers — the client a token was issued
  to, or a resource server registered (resource_identifiers) to serve the token's
  bound RFC 8707 audience. Unauthorized callers get {active:false}, disclosing
  nothing, instead of any confidential client reading any token.
- Scope-gate disclosed claims: username only with the email scope, groups only
  with the groups scope (mirrors userinfo). ADR 0005.

Tokens minted for revoked users:
- Re-check application.user_allowed?(user) at mint time in the device, refresh,
  and authorization-code grants (covers app-active, user-active, group
  membership). A user deactivated or removed from the allowed group between
  approval and the token request is refused with access_denied. The refresh
  check runs before rotation so a denied refresh has no side effects.

Device authorization hardening:
- Require confidential clients to authenticate, and require PKCE (code_challenge)
  up front for clients that require it, so an intercepted device_code plus a
  known public client_id cannot redeem tokens.
- Merge (not overwrite) the shared consent record on device approval.

Tests: new oidc_introspection_test and oidc_mint_authorization_test; existing
PKCE/claims tests updated to grant app access (they created ungrouped apps and
relied on the token endpoint not checking authorization). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F7cwhwDJp3MJJDoNPVE6zq
2026-07-19 13:19:41 +10:00

269 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 "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