diff --git a/CHANGELOG.md b/CHANGELOG.md
index e25d55b..1a3477b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,21 @@
# Changelog
+## Version 2.0.1 - Bugcrowd Auth Fix
+
+### 🐛 Bug Fixes
+
+#### Migrated Bugcrowd Authentication to Okta-Based Flow
+
+Bugcrowd has migrated its authentication system to Okta. The previous CSRF-based login flow no longer works.
+
+**Changes:**
+- **New Okta flow**: Authentication now goes through `login.hackers.bugcrowd.com` using Okta's IDX API
+- **Multi-step authentication**: Implements the full Okta pipeline — `stateToken` extraction, introspect, identify, password challenge, and OTP challenge
+- **Removed legacy code**: Dropped CSRF token extraction, `CGI`-based form encoding, manual redirect following, and the old `prepare_login_body` / `extract_csrf` / `authenticated_response?` helpers
+- **Simplified session verification**: Dashboard check now uses a simple body content match
+
+---
+
## Version 2.0.0 - Complete Refactor
### 🚨 BREAKING CHANGES
diff --git a/lib/scopes_extractor/platforms/bugcrowd/authenticator.rb b/lib/scopes_extractor/platforms/bugcrowd/authenticator.rb
index 25bbb5c..b2c9754 100644
--- a/lib/scopes_extractor/platforms/bugcrowd/authenticator.rb
+++ b/lib/scopes_extractor/platforms/bugcrowd/authenticator.rb
@@ -1,16 +1,23 @@
# frozen_string_literal: true
require 'rotp'
-require 'cgi'
module ScopesExtractor
module Platforms
module Bugcrowd
- # Bugcrowd authenticator
+ # Bugcrowd authenticator (Okta-based flow)
class Authenticator
IDENTITY_URL = 'https://identity.bugcrowd.com'
+ OKTA_URL = 'https://login.hackers.bugcrowd.com'
DASHBOARD_URL = 'https://bugcrowd.com/dashboard'
+ OKTA_HEADERS = {
+ 'Accept' => 'application/json; okta-version=1.0.0',
+ 'Content-Type' => 'application/json',
+ 'X-Okta-User-Agent-Extended' => 'okta-auth-js/7.14.0 okta-signin-widget-7.43.1',
+ 'Origin' => OKTA_URL
+ }.freeze
+
def initialize(email:, password:, otp_secret:)
@email = email
@password = password
@@ -18,17 +25,11 @@ def initialize(email:, password:, otp_secret:)
@authenticated = false
end
- # Returns authentication status
- # @return [Boolean] true if authenticated
def authenticated?
@authenticated
end
- # Performs authentication flow with Bugcrowd
- # @return [Boolean] true if successful
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
def authenticate
- # Validate credentials
unless @email && @password && @otp_secret
ScopesExtractor.logger.error '[Bugcrowd] Missing credentials (email, password, or OTP secret)'
return false
@@ -36,96 +37,146 @@ def authenticate
ScopesExtractor.logger.debug '[Bugcrowd] Starting authentication flow'
- # Step 1: Get login page and extract CSRF token
- login_url = "#{IDENTITY_URL}/login?user_hint=researcher&returnTo=/dashboard"
- response = HTTP.get(login_url)
+ # Step 1: Get Okta login page and extract stateToken
+ state_token = fetch_state_token
+ return false unless state_token
+
+ # Step 2: Introspect to get stateHandle
+ state_handle = introspect(state_token)
+ return false unless state_handle
+
+ # Step 3: Identify (send email)
+ state_handle = identify(state_handle)
+ return false unless state_handle
+
+ # Step 4: Password challenge
+ state_handle = challenge_answer(state_handle, @password, 'Password')
+ return false unless state_handle
+
+ # Step 5: OTP challenge — returns success redirect URL
+ otp_code = ROTP::TOTP.new(@otp_secret).now
+ success_url = otp_challenge(state_handle, otp_code)
+ return false unless success_url
+
+ # Step 6: Follow token/redirect to establish session (Typhoeus follows 183/184 automatically)
+ HTTP.get(success_url)
+
+ # Step 7: Verify authentication
+ establish_session
+ rescue StandardError => e
+ ScopesExtractor.logger.error "[Bugcrowd] Authentication error: #{e.message}"
+ false
+ end
+
+ private
+
+ def fetch_state_token
+ response = HTTP.get("#{IDENTITY_URL}/login/hacker/oauth2/authorization/hacker")
unless response.success?
- ScopesExtractor.logger.error "[Bugcrowd] Failed to fetch login page: #{response.code}"
- return false
+ ScopesExtractor.logger.error "[Bugcrowd] Failed to fetch Okta login page: #{response.code}"
+ return nil
end
- csrf = extract_csrf(response)
- unless csrf
- ScopesExtractor.logger.error '[Bugcrowd] Failed to extract CSRF token'
- return false
+ match = response.body.match(/"stateToken"\s*:\s*"([^"]+)"/)
+ unless match
+ ScopesExtractor.logger.error '[Bugcrowd] Failed to extract stateToken'
+ return nil
end
- ScopesExtractor.logger.debug "[Bugcrowd] CSRF token extracted: #{csrf[0..5]}..."
+ # Unescape JS hex sequences (e.g. \x2D -> -)
+ token = match[1].gsub(/\\x([0-9A-Fa-f]{2})/) { [::Regexp.last_match(1).hex].pack('C') }
+ ScopesExtractor.logger.debug '[Bugcrowd] stateToken extracted'
+ token
+ end
- # Step 2: Initial login POST (expects 422 for OTP challenge)
- login_body = prepare_login_body(with_otp: false)
+ def introspect(state_token)
response = HTTP.post(
- "#{IDENTITY_URL}/login",
- body: login_body,
- headers: {
- 'X-Csrf-Token' => csrf,
- 'Origin' => IDENTITY_URL,
- 'Referer' => login_url,
- 'Content-Type' => 'application/x-www-form-urlencoded'
- }
+ "#{OKTA_URL}/idp/idx/introspect",
+ body: { stateToken: state_token }.to_json,
+ headers: OKTA_HEADERS.merge(
+ 'Accept' => 'application/ion+json; okta-version=1.0.0',
+ 'Content-Type' => 'application/ion+json; okta-version=1.0.0'
+ )
)
- unless response.code == 422
- ScopesExtractor.logger.error "[Bugcrowd] Login failed: #{response.code}"
- return false
+ unless response.success?
+ ScopesExtractor.logger.error "[Bugcrowd] Introspect failed: #{response.code}"
+ return nil
end
- ScopesExtractor.logger.debug '[Bugcrowd] OTP challenge triggered'
+ data = JSON.parse(response.body)
+ state_handle = data['stateHandle']
+ unless state_handle
+ ScopesExtractor.logger.error '[Bugcrowd] No stateHandle in introspect response'
+ return nil
+ end
+
+ ScopesExtractor.logger.debug '[Bugcrowd] stateHandle obtained'
+ state_handle
+ end
- # Step 3: OTP challenge
- otp_body = prepare_login_body(with_otp: true)
+ def identify(state_handle)
response = HTTP.post(
- "#{IDENTITY_URL}/auth/otp-challenge",
- body: otp_body,
- headers: {
- 'X-Csrf-Token' => csrf,
- 'Origin' => IDENTITY_URL,
- 'Referer' => "#{IDENTITY_URL}/login",
- 'Content-Type' => 'application/x-www-form-urlencoded'
- }
+ "#{OKTA_URL}/idp/idx/identify",
+ body: { identifier: @email, stateHandle: state_handle }.to_json,
+ headers: OKTA_HEADERS
)
unless response.success?
- ScopesExtractor.logger.error "[Bugcrowd] OTP challenge failed: #{response.code}"
- return false
+ ScopesExtractor.logger.error "[Bugcrowd] Identify failed: #{response.code}"
+ return nil
end
data = JSON.parse(response.body)
- redirect_url = data['redirect_to']
-
- ScopesExtractor.logger.debug "[Bugcrowd] Following redirect to: #{redirect_url}"
+ ScopesExtractor.logger.debug '[Bugcrowd] Identify successful'
+ data['stateHandle'] || state_handle
+ end
- # Step 4: Follow redirects to establish session
- current_url = redirect_url
- max_redirects = 10
- redirect_count = 0
+ def challenge_answer(state_handle, passcode, step_name)
+ response = HTTP.post(
+ "#{OKTA_URL}/idp/idx/challenge/answer",
+ body: { credentials: { passcode: passcode }, stateHandle: state_handle }.to_json,
+ headers: OKTA_HEADERS
+ )
- loop do
- break if redirect_count >= max_redirects
+ unless response.success?
+ ScopesExtractor.logger.error "[Bugcrowd] #{step_name} challenge failed: #{response.code}"
+ return nil
+ end
- response = HTTP.get(current_url)
- break unless [301, 302, 303, 307, 308].include?(response.code)
+ data = JSON.parse(response.body)
+ ScopesExtractor.logger.debug "[Bugcrowd] #{step_name} challenge successful"
+ data['stateHandle'] || state_handle
+ end
- location = response.headers['Location']
- break unless location
+ def otp_challenge(state_handle, otp_code)
+ response = HTTP.post(
+ "#{OKTA_URL}/idp/idx/challenge/answer",
+ body: { credentials: { passcode: otp_code }, stateHandle: state_handle }.to_json,
+ headers: OKTA_HEADERS
+ )
- # Handle relative URLs
- current_url = if location.start_with?('http')
- location
- else
- # Assume same domain
- "https://bugcrowd.com#{location}"
- end
+ unless response.success?
+ ScopesExtractor.logger.error "[Bugcrowd] OTP challenge failed: #{response.code}"
+ return nil
+ end
- ScopesExtractor.logger.debug "[Bugcrowd] Redirect to: #{current_url}"
- redirect_count += 1
+ data = JSON.parse(response.body)
+ success_url = data.dig('success', 'href')
+ unless success_url
+ ScopesExtractor.logger.error '[Bugcrowd] No success redirect URL in OTP response'
+ return nil
end
- # Step 5: Verify authentication by checking dashboard
+ ScopesExtractor.logger.debug '[Bugcrowd] OTP challenge successful'
+ success_url
+ end
+
+ def establish_session # rubocop:disable Naming/PredicateMethod
response = HTTP.get(DASHBOARD_URL)
- if authenticated_response?(response)
+ if response.success? && response.body.include?('dashboard')
ScopesExtractor.logger.debug '[Bugcrowd] Authentication successful'
@authenticated = true
true
@@ -133,43 +184,6 @@ def authenticate
ScopesExtractor.logger.error '[Bugcrowd] Authentication verification failed'
false
end
- rescue StandardError => e
- ScopesExtractor.logger.error "[Bugcrowd] Authentication error: #{e.message}"
- false
- end
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
-
- private
-
- def extract_csrf(response)
- set_cookie = response.headers['Set-Cookie']
- return nil unless set_cookie
-
- # set-cookie can be an array or string
- cookies = set_cookie.is_a?(Array) ? set_cookie : [set_cookie]
-
- cookies.each do |cookie_str|
- match = cookie_str.match(/csrf-token=([^;]+)/)
- return match[1] if match
- end
-
- nil
- end
-
- def prepare_login_body(with_otp:)
- body = "username=#{CGI.escape(@email)}&password=#{CGI.escape(@password)}&user_type=RESEARCHER"
-
- if with_otp
- otp_code = ROTP::TOTP.new(@otp_secret).now
- body += "&otp_code=#{otp_code}"
- end
-
- body
- end
-
- def authenticated_response?(response)
- response.body.include?('
Dashboard - Bugcrowd') ||
- response.headers['Location'] == '/dashboard'
end
end
end
diff --git a/spec/scopes_extractor/platforms/bugcrowd/authenticator_spec.rb b/spec/scopes_extractor/platforms/bugcrowd/authenticator_spec.rb
index 7e1f49f..2236a6e 100644
--- a/spec/scopes_extractor/platforms/bugcrowd/authenticator_spec.rb
+++ b/spec/scopes_extractor/platforms/bugcrowd/authenticator_spec.rb
@@ -2,12 +2,22 @@
require 'spec_helper'
+# rubocop:disable RSpec/MultipleMemoizedHelpers
RSpec.describe ScopesExtractor::Platforms::Bugcrowd::Authenticator do
let(:email) { 'test@example.com' }
let(:password) { 'test_password' }
let(:otp_secret) { 'BASE32SECRET' }
let(:authenticator) { described_class.new(email: email, password: password, otp_secret: otp_secret) }
+ let(:state_token) { 'eyJ6aXAiOiJERUYi.test_state_token' }
+ let(:state_handle) { '02.id.test_state_handle' }
+ let(:updated_state_handle) { '02.id.updated_state_handle' }
+ let(:success_redirect_url) { 'https://login.hackers.bugcrowd.com/login/token/redirect?stateToken=02.id.test_state_handle' }
+
+ let(:okta_login_page_body) do
+ ""
+ end
+
describe '#initialize' do
it 'sets email' do
expect(authenticator.instance_variable_get(:@email)).to eq(email)
@@ -25,26 +35,28 @@
end
describe '#authenticate' do
- let(:login_page_response) do
- double('Response',
- success?: true,
- code: 200,
- headers: { 'Set-Cookie' => 'csrf-token=test_csrf_token; Path=/' },
- body: '')
+ let(:okta_page_response) do
+ double('Response', success?: true, code: 200, body: okta_login_page_body)
+ end
+ let(:introspect_response) do
+ double('Response', success?: true, code: 200, body: { stateHandle: state_handle }.to_json)
+ end
+ let(:identify_response) do
+ double('Response', success?: true, code: 200, body: { stateHandle: updated_state_handle }.to_json)
+ end
+ let(:password_challenge_response) do
+ double('Response', success?: true, code: 200, body: { stateHandle: updated_state_handle }.to_json)
+ end
+ let(:otp_challenge_response) do
+ double('Response', success?: true, code: 200,
+ body: { stateHandle: updated_state_handle,
+ success: { name: 'success-redirect', href: success_redirect_url } }.to_json)
end
- let(:login_response) { double('Response', success?: false, code: 422) }
- let(:otp_response) do
- double('Response',
- success?: true,
- code: 200,
- body: { redirect_to: 'https://bugcrowd.com/auth/callback' }.to_json)
+ let(:token_redirect_response) do
+ double('Response', success?: true, code: 200, body: '')
end
- let(:redirect_response) { double('Response', success?: true, code: 200, headers: {}) }
let(:dashboard_response) do
- double('Response',
- success?: true,
- code: 200,
- body: 'Dashboard - Bugcrowd')
+ double('Response', success?: true, code: 200, body: 'dashboard')
end
context 'when credentials are missing' do
@@ -82,17 +94,20 @@
context 'when authentication flow succeeds' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(login_response)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('otp-challenge'), any_args)
- .and_return(otp_response)
+ .with(include('/idp/idx/identify'), any_args)
+ .and_return(identify_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/challenge/answer'), any_args)
+ .and_return(password_challenge_response, otp_challenge_response)
allow(ScopesExtractor::HTTP).to receive(:get)
- .with('https://bugcrowd.com/auth/callback')
- .and_return(redirect_response)
+ .with(success_redirect_url)
+ .and_return(token_redirect_response)
allow(ScopesExtractor::HTTP).to receive(:get)
.with('https://bugcrowd.com/dashboard')
.and_return(dashboard_response)
@@ -109,18 +124,20 @@
it 'logs debug messages' do
expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Starting authentication flow')
- expect(ScopesExtractor.logger).to receive(:debug).with(/CSRF token extracted/)
- expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] OTP challenge triggered')
- expect(ScopesExtractor.logger).to receive(:debug).with(/Following redirect to/)
+ expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] stateToken extracted')
+ expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] stateHandle obtained')
+ expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Identify successful')
+ expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Password challenge successful')
+ expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] OTP challenge successful')
expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Authentication successful')
authenticator.authenticate
end
end
- context 'when login page fetch fails' do
+ context 'when Okta login page fetch fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
+ .with(include('oauth2/authorization/hacker'))
.and_return(double('Response', success?: false, code: 500))
end
@@ -129,16 +146,16 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with(/Failed to fetch login page/)
+ expect(ScopesExtractor.logger).to receive(:error).with(/Failed to fetch Okta login page/)
authenticator.authenticate
end
end
- context 'when CSRF token extraction fails' do
+ context 'when stateToken extraction fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(double('Response', success?: true, code: 200, headers: {}, body: ''))
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(double('Response', success?: true, code: 200, body: 'no token here'))
end
it 'returns false' do
@@ -146,19 +163,19 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with('[Bugcrowd] Failed to extract CSRF token')
+ expect(ScopesExtractor.logger).to receive(:error).with('[Bugcrowd] Failed to extract stateToken')
authenticator.authenticate
end
end
- context 'when initial login fails' do
+ context 'when introspect fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(double('Response', success?: false, code: 401))
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(double('Response', success?: false, code: 400))
end
it 'returns false' do
@@ -166,21 +183,21 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with(/Login failed: 401/)
+ expect(ScopesExtractor.logger).to receive(:error).with(/Introspect failed: 400/)
authenticator.authenticate
end
end
- context 'when OTP challenge fails' do
+ context 'when identify fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(login_response)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('otp-challenge'), any_args)
+ .with(include('/idp/idx/identify'), any_args)
.and_return(double('Response', success?: false, code: 401))
end
@@ -189,28 +206,25 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with(/OTP challenge failed/)
+ expect(ScopesExtractor.logger).to receive(:error).with(/Identify failed: 401/)
authenticator.authenticate
end
end
- context 'when dashboard verification fails' do
+ context 'when password challenge fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(login_response)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('otp-challenge'), any_args)
- .and_return(otp_response)
- allow(ScopesExtractor::HTTP).to receive(:get)
- .with('https://bugcrowd.com/auth/callback')
- .and_return(redirect_response)
- allow(ScopesExtractor::HTTP).to receive(:get)
- .with('https://bugcrowd.com/dashboard')
- .and_return(double('Response', success?: true, code: 200, body: 'Not logged in', headers: {}))
+ .with(include('/idp/idx/identify'), any_args)
+ .and_return(identify_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/challenge/answer'), any_args)
+ .and_return(double('Response', success?: false, code: 401))
end
it 'returns false' do
@@ -218,16 +232,28 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with('[Bugcrowd] Authentication verification failed')
+ expect(ScopesExtractor.logger).to receive(:error).with(/Password challenge failed: 401/)
authenticator.authenticate
end
end
- context 'when an exception occurs' do
+ context 'when OTP challenge fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_raise(StandardError, 'Network error')
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/identify'), any_args)
+ .and_return(identify_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/challenge/answer'), any_args)
+ .and_return(
+ password_challenge_response,
+ double('Response', success?: false, code: 401)
+ )
end
it 'returns false' do
@@ -235,83 +261,92 @@
end
it 'logs error message' do
- expect(ScopesExtractor.logger).to receive(:error).with(/Authentication error: Network error/)
+ expect(ScopesExtractor.logger).to receive(:error).with(/OTP challenge failed: 401/)
authenticator.authenticate
end
end
- context 'with redirects' do
- let(:redirect_302_response) do
- double('Response',
- success?: false,
- code: 302,
- headers: { 'Location' => '/dashboard' })
- end
-
+ context 'when dashboard verification fails' do
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(login_response)
+ .with(include('/idp/idx/identify'), any_args)
+ .and_return(identify_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('otp-challenge'), any_args)
- .and_return(otp_response)
+ .with(include('/idp/idx/challenge/answer'), any_args)
+ .and_return(password_challenge_response, otp_challenge_response)
allow(ScopesExtractor::HTTP).to receive(:get)
- .with('https://bugcrowd.com/auth/callback')
- .and_return(redirect_302_response)
+ .with(success_redirect_url)
+ .and_return(token_redirect_response)
allow(ScopesExtractor::HTTP).to receive(:get)
.with('https://bugcrowd.com/dashboard')
- .and_return(dashboard_response)
+ .and_return(double('Response', success?: true, code: 200, body: 'Not logged in'))
+ end
+
+ it 'returns false' do
+ expect(authenticator.authenticate).to be false
end
- it 'follows redirects' do
- expect(ScopesExtractor::HTTP).to receive(:get).with('https://bugcrowd.com/dashboard')
+ it 'logs error message' do
+ expect(ScopesExtractor.logger).to receive(:error).with('[Bugcrowd] Authentication verification failed')
authenticator.authenticate
end
+ end
- it 'handles relative URLs' do
- expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Starting authentication flow')
- expect(ScopesExtractor.logger).to receive(:debug).with(/CSRF token extracted/)
- expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] OTP challenge triggered')
- expect(ScopesExtractor.logger).to receive(:debug).with(/Following redirect to/)
- expect(ScopesExtractor.logger).to receive(:debug).with(%r{Redirect to: https://bugcrowd.com/dashboard})
- expect(ScopesExtractor.logger).to receive(:debug).with('[Bugcrowd] Authentication successful')
+ context 'when an exception occurs' do
+ before do
+ allow(ScopesExtractor::HTTP).to receive(:get)
+ .with(include('oauth2/authorization/hacker'))
+ .and_raise(StandardError, 'Network error')
+ end
+
+ it 'returns false' do
+ expect(authenticator.authenticate).to be false
+ end
+
+ it 'logs error message' do
+ expect(ScopesExtractor.logger).to receive(:error).with(/Authentication error: Network error/)
authenticator.authenticate
end
end
- context 'with Set-Cookie as array' do
- let(:login_page_response) do
- double('Response',
- success?: true,
- code: 200,
- headers: { 'Set-Cookie' => ['other=value; Path=/', 'csrf-token=array_csrf; Path=/'] },
- body: '')
+ context 'with hex-escaped stateToken' do
+ let(:okta_login_page_body) do
+ ''
end
before do
allow(ScopesExtractor::HTTP).to receive(:get)
- .with(include('identity.bugcrowd.com/login'))
- .and_return(login_page_response)
+ .with(include('oauth2/authorization/hacker'))
+ .and_return(okta_page_response)
+ allow(ScopesExtractor::HTTP).to receive(:post)
+ .with(include('/idp/idx/introspect'), any_args)
+ .and_return(introspect_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('identity.bugcrowd.com/login'), any_args)
- .and_return(login_response)
+ .with(include('/idp/idx/identify'), any_args)
+ .and_return(identify_response)
allow(ScopesExtractor::HTTP).to receive(:post)
- .with(include('otp-challenge'), any_args)
- .and_return(otp_response)
+ .with(include('/idp/idx/challenge/answer'), any_args)
+ .and_return(password_challenge_response, otp_challenge_response)
allow(ScopesExtractor::HTTP).to receive(:get)
- .with('https://bugcrowd.com/auth/callback')
- .and_return(redirect_response)
+ .with(success_redirect_url)
+ .and_return(token_redirect_response)
allow(ScopesExtractor::HTTP).to receive(:get)
.with('https://bugcrowd.com/dashboard')
.and_return(dashboard_response)
end
- it 'extracts CSRF from cookie array' do
+ it 'unescapes hex sequences in stateToken' do
expect(authenticator.authenticate).to be true
+ expect(ScopesExtractor::HTTP).to have_received(:post)
+ .with(include('/idp/idx/introspect'), hash_including(body: include('"stateToken":"abc-def-ghi"')))
end
end
end
end
+# rubocop:enable RSpec/MultipleMemoizedHelpers