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 + "<html><script>var oktaData = {\"stateToken\":\"#{state_token}\"};</script></html>" + 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: '<html></html>') + 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: '<html><title>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