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
269 lines
10 KiB
Ruby
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
|