Files
clinch/test/controllers/api/forward_auth_bearer_test.rb
Dan Milne 8a095e4939 Enforce group access on Bearer API key forward-auth at use-time
The ApiKey model only validates group access on creation (user_must_have_access
runs on create). The bearer path in /api/verify never re-checked, so a user
removed from an application's allowed groups kept access via an existing key
until it was manually revoked.

Add an app.user_allowed?(user) check to authenticate_bearer_token, matching the
session path, returning 401 when the user no longer has group access. Adds a
regression test that revokes membership after key creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:54:48 +10:00

186 lines
5.6 KiB
Ruby

require "test_helper"
module Api
class ForwardAuthBearerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:bob)
@app = Application.create!(
name: "WebDAV App",
slug: "webdav-app",
app_type: "forward_auth",
domain_pattern: "webdav.example.com",
active: true
)
grant_everyone_access(@app)
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
@token = @api_key.plaintext_token
end
test "valid bearer token returns 200 with user headers" do
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert_equal @user.email_address, response.headers["x-remote-user"]
assert_equal @user.email_address, response.headers["x-remote-email"]
end
test "valid bearer token updates last_used_at" do
assert_nil @api_key.last_used_at
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert @api_key.reload.last_used_at.present?
end
test "expired bearer token returns 401 JSON" do
@api_key.update_column(:expires_at, 1.hour.ago)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "revoked bearer token returns 401 JSON" do
@api_key.revoke!
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "invalid bearer token returns 401 JSON" do
get "/api/verify", headers: {
"Authorization" => "Bearer clk_totally_bogus_token",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "bearer token for wrong domain returns 401 JSON" do
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "other.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "API key not valid for this domain", json["error"]
end
test "bearer token for inactive user returns 401 JSON" do
@user.update!(status: :disabled)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "User account is not active", json["error"]
end
test "bearer token for inactive application returns 401 JSON" do
@app.update!(active: false)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Application is inactive", json["error"]
end
test "bearer token returns 401 once user is removed from allowed groups" do
# App restricted to a specific group; user is a member when the key is made.
group = Group.create!(name: "webdav-users")
restricted_app = Application.create!(
name: "Restricted WebDAV",
slug: "restricted-webdav",
app_type: "forward_auth",
domain_pattern: "restricted.example.com",
active: true
)
restricted_app.allowed_groups << group
@user.groups << group
key = @user.api_keys.create!(name: "Restricted Key", application: restricted_app)
token = key.plaintext_token
# Sanity: access works while membership stands.
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :ok
# Revoke group membership; the existing key must stop working.
@user.groups.destroy(group)
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Access denied: insufficient group membership", json["error"]
end
test "no bearer token falls through to cookie auth" do
# No auth header, no session -> should redirect (cookie flow)
get "/api/verify", headers: {
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :redirect
assert_match %r{/signin}, response.location
end
test "bearer token does not redirect on failure" do
get "/api/verify", headers: {
"Authorization" => "Bearer clk_bad",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
assert_equal "application/json", response.media_type
# Should NOT be a redirect
assert_nil response.headers["Location"]
end
test "cookie auth still works when no bearer token present" do
sign_in_as(@user)
get "/api/verify", headers: {
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert_equal @user.email_address, response.headers["x-remote-user"]
end
end
end