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, and is reported as reuse —
    # distinguishable from the generic "Invalid device_code" for an unknown code.
    poll(dc, code_verifier: CODE_VERIFIER)
    assert_response :bad_request
    replay = JSON.parse(@response.body)
    assert_equal "invalid_grant", replay["error"]
    assert_match(/already been used/i, replay["error_description"])
  end

  test "replaying a redeemed device_code revokes the tokens it issued" do
    OidcUserConsent.create!(user: @user, application: @cli, scopes_granted: "openid", granted_at: Time.current)
    dc = OidcDeviceCode.create!(
      application: @cli, scope: "openid",
      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
    access = OidcAccessToken.find_by_token(JSON.parse(@response.body)["access_token"])
    refresh = OidcRefreshToken.where(oidc_device_code: dc).first
    assert access.active?, "token should be live before the replay"

    # The code is kept (not destroyed) so the replay is detectable...
    assert dc.reload.redeemed?
    poll(dc, code_verifier: CODE_VERIFIER)
    assert_response :bad_request

    # ...and every token descended from the replayed code is revoked.
    assert access.reload.revoked?
    assert refresh.reload.revoked?
  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
