Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
224 changes: 119 additions & 105 deletions lib/scopes_extractor/platforms/bugcrowd/authenticator.rb
Original file line number Diff line number Diff line change
@@ -1,175 +1,189 @@
# 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
@otp_secret = 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
end

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
else
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?('<title>Dashboard - Bugcrowd') ||
response.headers['Location'] == '/dashboard'
end
end
end
Expand Down
Loading
Loading