require "test_helper" # RFC 8707 Resource Indicators: a client names the target resource server via the # `resource` parameter; clinch binds it to the token as `aud` and reports it at # introspection. Validation is syntax-only (pass-through) — the resource server # enforces the audience. class OidcResourceIndicatorsTest < ActionDispatch::IntegrationTest DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze RESOURCE = "https://c2a2.example.com".freeze # RFC 7636 Appendix B example PKCE pair. PKCE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".freeze PKCE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".freeze def setup @group = Group.create!(name: "resource-testers", description: "test") @user = User.create!(email_address: "resource_test@example.com", password: "password123") @user.groups << @group @cli = Application.create!(name: "Resource CLI", slug: "resource-cli", app_type: "oidc", is_public_client: true, active: true) @cli.allowed_groups << @group @web = Application.create!(name: "Resource Web", slug: "resource-web", app_type: "oidc", is_public_client: true, active: true, skip_consent: true, redirect_uris: ["https://app.example.com/cb"].to_json) @web.allowed_groups << @group @resource_secret = "resource-server-secret-value-abcdefghij" # The resource server is registered to serve RESOURCE, so it is authorized to # introspect tokens bound to it (see OidcController#caller_may_introspect?). @resource = Application.create!(name: "Resource RS", slug: "resource-rs2", app_type: "oidc", client_secret: @resource_secret, active: true, resource_identifiers: [RESOURCE].to_json) end def teardown Current.session = nil [@cli, @web, @resource].each do |app| OidcRefreshToken.where(application: app).delete_all OidcAccessToken.where(application: app).delete_all OidcDeviceCode.where(application: app).delete_all OidcAuthorizationCode.where(application: app).delete_all OidcUserConsent.where(application: app).delete_all end end # --- Authorization code flow ---------------------------------------------- test "authorize binds the resource so introspection reports it as aud" do sign_in_as(@user) get "/oauth/authorize", params: { response_type: "code", client_id: @web.client_id, redirect_uri: "https://app.example.com/cb", scope: "openid", code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256", resource: RESOURCE } assert_response :redirect code = Rack::Utils.parse_query(URI(@response.location).query)["code"] assert code.present? post "/oauth/token", params: { grant_type: "authorization_code", code: code, redirect_uri: "https://app.example.com/cb", client_id: @web.client_id, code_verifier: PKCE_VERIFIER } assert_response :success tokens = JSON.parse(@response.body) assert_equal RESOURCE, introspect(tokens["access_token"])["aud"] # The bound audience survives refresh rotation. post "/oauth/token", params: {grant_type: "refresh_token", refresh_token: tokens["refresh_token"], client_id: @web.client_id} assert_response :success rotated = JSON.parse(@response.body) assert_equal RESOURCE, introspect(rotated["access_token"])["aud"] end test "authorize rejects an invalid resource with invalid_target" do sign_in_as(@user) get "/oauth/authorize", params: { response_type: "code", client_id: @web.client_id, redirect_uri: "https://app.example.com/cb", scope: "openid", resource: "https://c2a2.example.com/path#frag" } assert_response :redirect assert_includes @response.location, "error=invalid_target" end # --- Device flow ----------------------------------------------------------- test "device flow binds the resource to the issued token" do post "/oauth/device_authorization", params: { client_id: @cli.client_id, scope: "openid", resource: RESOURCE, code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256" } assert_response :success auth = JSON.parse(@response.body) dc = OidcDeviceCode.find_by_user_code(auth["user_code"]) assert_equal RESOURCE, dc.resource OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current) dc.approve!(user: @user, acr: "1", auth_time: Time.current.to_i) post "/oauth/token", params: {grant_type: DEVICE_GRANT, device_code: auth["device_code"], client_id: @cli.client_id, code_verifier: PKCE_VERIFIER} assert_response :success access = JSON.parse(@response.body)["access_token"] assert_equal RESOURCE, introspect(access)["aud"] end test "device_authorization rejects an invalid resource" do post "/oauth/device_authorization", params: { client_id: @cli.client_id, resource: "not-an-absolute-uri", code_challenge: PKCE_CHALLENGE, code_challenge_method: "S256" } assert_response :bad_request assert_equal "invalid_target", JSON.parse(@response.body)["error"] end # --- Fallback -------------------------------------------------------------- test "introspection aud falls back to the client when no resource was bound" do # A confidential client introspecting its own unbound token (no RFC 8707 # resource) sees aud fall back to the client_id. token = OidcAccessToken.create!(application: @resource, user: @user, scope: "openid") assert_equal @resource.client_id, introspect(token.plaintext_token)["aud"] end private def introspect(token) post "/oauth/introspect", params: {token: token, client_id: @resource.client_id, client_secret: @resource_secret} JSON.parse(@response.body) end end