Udtræk af transaktioner hos Saxo Bank efter indførsel af tofaktor-login

Redigeret den 3. februar 2024: Hent den nye, forbedrede udgave her!

Kapitalisterne hos Saxo Bank har indført tofaktor-login til deres selvbetjeningsløsning, hvilket er godt for sikkerheden, men desværre har ødelagt min tidligere løsning til udtræk af data fra deres API.

I dette indlæg fortæller jeg om, hvordan jeg undersøgte loginløsningen hos Saxo Bank og udviklede et nyt program, så jeg igen kan trække data ud. Hele det nye program finder du nederst.

Sådan virker den nye loginløsning

Saxo Banks nye login-løsning virker ved at bede om en kode, som sendes med SMS, første gang, man logger på med et nyt device/en ny browser. Når man først er logget ind én gang og har sagt ja til at huske ens enhed, bliver man ikke bedt om en tofaktor-kode igen.

Første del af login
Anden del af login

Hvad sker der bag kulisserne?

Når det kan lade sig gøre at huske, at jeg en gang har brugt tofaktor-kode i en browser, er cookies et godt bud på teknologien til at huske min browser.

For at undersøge, hvad der sker, trykker jeg F12 i Firefox, besøger login-siden og logger ind. I fanen Network får jeg en liste over alle de forespørgsler, min browser foretager, for at jeg kan logge ind. Ved at indkredse til to slags forespørgsler: HTML og XHR (XHR er forespørgsler, som scripts foretager), får jeg et nogenlunde overblik over proceduren fra login-siden frem til der foretages det første kald til Saxo Banks API.

Jeg har markeret de vigtige forespørgsler med rødt:

De forespørgsler, min browser foretager, når jeg logger ind på Saxo Bank.

For at kunne reproducere det, min browser gør, i et Python-script, undersøger jeg, hvad min browser sender af sted og modtager i hvert enkelt trin.

Trin 0: Klargøring af script

Inden jeg går i gang, importerer jeg nogle biblioteker, som jeg ved jeg skal bruge. Jeg indtaster også mine loginoplysninger og et par datoer, og til sidst gør jeg en session klar i biblioteket requests. Ved at bruge en session, bibeholder biblioteket de http-headers og cookies, som bliver sat undervejs i loginproceduren.

import requests 
from datetime import datetime
from datetime import date
import json

# Saxo user account credentials
user = 'USERNAME'
password = 'PASSWORD'

# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2019-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')

# Initialize requests session
session = requests.Session()

Trin 1: Besøg på loginsiden

Det første, jeg har brug for, er at kunne overbevise Saxo Banks hjemmeside om, at Python-biblioteket requests er den samme browser, som jeg netop er logget ind.

Jeg kan se, at den cookie min browser sender til login-siden indeholder nogle værdier, som ser tilpas tilfældige ud til, at de må udgøre en eller anden form for identifikation.

Jeg gør mit script klar til at imitere browser ved at reproducere en enkel header (den der siger noget om browseren) og alle de cookie-værdier, jeg kan se:

session.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; rv:122.0) Gecko/20100101 Firefox/122.0'
session.cookies['LoginLanguage']='da'
session.cookies['UseNewLoginExperience']='True'
session.cookies['Theme_investor']='white'
session.cookies['stgo-V4_PROD_DMZ_SAXOTRADER_GO_LVE_81']='KOPIERET_FRA_BROWSER'
session.cookies['ASP.NET_SessionId']='KOPIERET_FRA_BROWSER'
session.cookies['stgo-SSO_LIVE_LOGIN_50201']='KOPIERET_FRA_BROWSER'
session.cookies['stgo-V4_ENT_DMZ_LVE_OA_CORE_8080']='KOPIERET_FRA_BROWSER'
session.cookies['lb']='KOPIERET_FRA_BROWSER'

For en sikkerheds skyld (jeg ved strengt taget ikke om det er nødvendigt endnu), besøger mit script login-siden:

step_one = session.get('https://www.saxoinvestor.dk/Login/da')

For at undersøge, om dette er nok til at imitere min browser, går jeg til næste trin i login-proceduren:

Trin 2: Authenticate (1/3)

Den næste interessante forespørgel går til:

https://www.saxoinvestor.dk/am/json/realms/root/realms/dca/authenticate?authIndexType=service&authIndexValue=authn-web-v6

I min browser kan jeg se, at forespørgslen er af typen POST, og at der ikke sendes noget data med forespørgslen, andet end nogle headers (som cookies er en del af).

Ved at kigge på response-fanen i browseren, kan jeg se hvad jeg gerne skulle få tilbage, hvis forespørgslen går godt:

En succesfuld forespørgsel returnerer en json-struktur med et langt “authId”, noget om hvor jeg er nået til i processen (“stage”) og en liste af “callbacks”, der kunne minde om noget, der beskriver felterne i en login-formular.

Jeg forsøger mig:

login_url = 'https://www.saxoinvestor.dk/am/json/realms/root/realms/dca/authenticate?authIndexType=service&authIndexValue=authn-web-v6'
step_two = session.post(login_url)
step_two_json = step_two.json()

Ved at inspicere “step_two” og “step_two_json”, kan jeg se, at forespørgslen er gået godt: Jeg får samme datastruktur tilbage, som jeg lige har set på i min browser.

Videre til næste trin!

Trin 3: Authenticate (2/3)

I browseren går jeg videre til næste forespørgsel til:

https://www.saxoinvestor.dk/am/json/realms/root/realms/dca/authenticate?authIndexType=service&authIndexValue=authn-web-v6

Ved at undersøge forespørgslen fra min browser under “Request”, kan jeg se at der sendes en JSON-datastruktur af sted, der minder meget om den, jeg lige har modtaget i trinnet før. Der er dog kommet lidt ekstra på. I listen med “callbacks” finder jeg mit brugernavn og nogle ret detaljerede oplysninger om mit system (fx styresystem, browser, et id-nummer og den slags).

Forespørgsel der svarer til svaret på forespørgslen i trin 2, bare med lidt ekstra oplysninger om blandt andet mit brugernavn.

I mit script reproducerer jeg datastrukturen ved hjælp af kopier/sæt ind fra browseren. Det bliver lidt kluntet, for jeg har svært ved at få værdien i det fjerde callback (som er en streng, men ligner JSON til forveksling) til at spille pænt med. Læg mærke til, at jeg indsætter variablen “user” i JSON-strukturen (step_three_json_struct) og at benytter det AuthId, som jeg fik tilbage på forespørgslen i trin 2:

identity = "{\"identifier\":\"BORTREDIGERET\",\"metadata\":{\"hardware\":{\"cpuClass\":null,\"deviceMemory\":null,\"hardwareConcurrency\":2,\"maxTouchPoints\":0,\"oscpu\":\"Windows NT 10.0; Win64; x64\",\"display\":{\"width\":2560,\"height\":771,\"pixelDepth\":24,\"angle\":0}},\"browser\":{\"userAgent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0\",\"appName\":\"Netscape\",\"appCodeName\":\"Mozilla\",\"appVersion\":\"5.0 (Windows)\",\"appMinorVersion\":null,\"buildID\":\"20181001000000\",\"product\":\"Gecko\",\"productSub\":\"20100101\",\"vendor\":\"\",\"vendorSub\":\"\",\"browserLanguage\":null,\"plugins\":\"internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;\"},\"platform\":{\"language\":\"en\",\"platform\":\"Win32\",\"userLanguage\":null,\"systemLanguage\":null,\"deviceName\":\"Windows (Browser)\",\"fonts\":\"BORTREDIGERET;\",\"timezone\":0}}}"

step_three_json_struct = json.loads('{"authId":"","callbacks":[{"type":"NameCallback","output":[{"name":"prompt","value":"User Name"}],"input":[{"name":"IDToken1","value":"' + user +'"}],"_id":0},{"type":"HiddenValueCallback","output":[{"name":"value","value":""},{"name":"id","value":"Enter SP"}],"input":[{"name":"IDToken2","value":"https://www.saxoinvestor.dk/investor"}],"_id":1},{"type":"HiddenValueCallback","output":[{"name":"value","value":""},{"name":"id","value":"Enter Application"}],"input":[{"name":"IDToken3","value":"SaxoInvestor"}],"_id":2},{"type":"DeviceProfileCallback","output":[{"name":"metadata","value":true},{"name":"location","value":false},{"name":"message","value":""}],"input":[{"name":"IDToken4","value":""}],"_id":3}],"stage":"loginIdPage","status":200,"ok":true}')

step_three_json_struct['authId'] = step_two_json['authId']
step_three_json_struct['callbacks'][3]['input'][0]['value'] = identity

Med data på plads, sender jeg forespørgslen med mit brugernavn af sted:

step_three = session.post(login_url, json=step_three_json_struct)
step_three_json = step_three.json()

Ligesom før inspicerer jeg step_three og step_three_json og konstaterer at min forespørgsel (efter et par forsøg på at få JSON-strukturen til at blive helt rigtig), lykkes.

Jeg får endnu en JSON-struktur tilbage med et nyt “authId” og nogle nye callbacks. Mon ikke den struktur skal bruges til næste trin i login-proceduren?

Svaret på forespørgslen i trin 3

Trin 4: Authenticate (3/3)

I trin 4 gentager jeg proceduren fra trin 3 med at gøre en JSON-datastruktur klar med copy/paste fra browseren, indsætte mit password i strengen, indsætte “authId” fra trinnet lige før og fyre min forespørgsel af sted:

step_four_json_struct = json.loads('{"authId":"","callbacks":[{"type":"MetadataCallback","output":[{"name":"data","value":{"authenticationAttributes":{"type":"loginid-password"}}}],"_id":4},{"type":"NameCallback","output":[{"name":"prompt","value":"User Name"}],"input":[{"name":"IDToken2","value":"' + user + '"}],"_id":5},{"type":"PasswordCallback","output":[{"name":"prompt","value":"Password"}],"input":[{"name":"IDToken3","value":"' + password + '"}],"_id":6},{"type":"ChoiceCallback","output":[{"name":"prompt","value":"LoginId or password?"},{"name":"choices","value":["loginid","password"]},{"name":"defaultChoice","value":1}],"input":[{"name":"IDToken4","value":1}],"_id":7}],"stage":"loginIdOrPasswordPage","status":200,"ok":true}')

step_four_json_struct['authId'] = step_three_json['authId']

step_four = session.post(login_url, json=step_four_json_struct)
step_four_json = step_four.json()

Når det lykkes, ser svaret fra serveren ca. sådan her ud:

Svaret på forespørgslen indeholder et “tokenId”, “successUrl” og “realms”.

Videre til trin 5!

Trin 5: Authorize

Trin 5 i login-proceduren går til en ny URL:

https://www.saxoinvestor.dk/am/oauth2/realms/root/realms/dca/authorize

Når jeg kigger på forespørgslen i min browser, kan jeg se at der både er kommet:

  • En ny header, der hedder “x-forgerock-transaction-id”
  • En ny cookie, der hedder “butterAndStocks” (hvem har godkendt DEN navngivning?), som blev sat i trin 4.
  • Noget formulardata, hvor især “crsf” og “state” ser ud til at have nogle ikke-generiske værdier.
Formulardata fra trin 5

For at undersøge, hvor alle de værdier kommer fra, søger jeg i min forespørgselshistorik i browseren på de strenge, der ikke er generiske, fx værdien fra “crsf”.

Jeg finder ud af:

  • Værdien af “crsf” er værdien af “tokenId”, som jeg fik i trin 4-forespørgslen

Værdien af “x-forgerock-transaction-id” finder jeg helt tilbage i den allerførste forespørgsel til:

https://www.saxoinvestor.dk/login/

I sidens kilde (Response > Raw i Developer Tools) finder jeg et script med en variabel, der hedder “window.config”, der indeholder en JSON-struktur. I den struktur er en nøgle, der hedder “correlationId”, og værdien af den nøgle er den samme som i “x-forgerock-transaction-id”. Helt tilbage mellem trin 0 og trin 1 indsætter jeg et besøg på login-siden, hvor jeg udtrækker værdien:

step_one = session.get('https://www.saxoinvestor.dk/Login/da')
step_one_text = step_one.text
search_string = '"correlationId":"'
start_of_correlation_id = step_one_text.index(search_string)+len(search_string)
end_of_correlation_id = step_one_text.index('"', start_of_correlation_id)
correlation_id = step_one_text[start_of_correlation_id:end_of_correlation_id]

Jeg har straks sværere ved at finde “state”-værdien tidligere i forespørgselshistorikken. Faktisk så svært, jeg slet ikke kan finde den.

Jeg søger på internettet og finder ud af, at det er en sikkerhedsparameter, og at det typisk er tilfældigt genereret. I Googles eksempler kan jeg se, at det typisk bliver kodet til fx hex-værdier, og det giver mig idéen om, at jeg måske kan dekode state-værdien. Jeg gætter på, at der er tale om base64-kodning, forsøger at dekode værdien og går fra:

eyJhcHBJZCI6ImludmVzdG8yIiwiY29ycmVsYXRpb25JZCI6IjA1NMNkNjE3LTM5YzQtNGRjNS02OGEwLWY2Y2ViMDZlNDMzNi1yNjEzNiJ9 (lettere redigeret)

Til:

{"appId":"investor","correlationId":"054cf617-39c4-4dc5-28a0-f6ceb03e4336-26136"} (lettere redigeret)

I den dekodede streng, kan jeg se at denne værdi også svarer til “correlationId” tilbage fra besøget på loginsiden i trin 1. Jeg kan derfor generere en værdi til “state” sådan her:

state_string = '{"appId":"investor","correlationId":"' + correlation_id + '"}'
state_string_b64_encoded = base64.b64encode(bytes(state_string, 'utf-8')).decode()

Med det på plads er jeg klar til at kode trin 5. I browseren kan jeg se, at jeg gerne først skulle få en viderestilling (http-kode 302) og dernæst endnu et JSON-svar:

I trin 5 bliver jeg viderestillet og får et JSON-svar med “code” og “state”

Den del af mit script, der gør klar til at udføre forespørgslen og udfører forespørgslen i trin 5, ser sådan her ud:

auth_url = 'https://www.saxoinvestor.dk/am/oauth2/realms/root/realms/dca/authorize'

data = 'csrf=' + step_four_json['tokenId'] + '&scope=openid%20profile%20openapi%20fr%3Aidm%3A*&response_type=code&client_id=SaxoInvestorPlatform&redirect_uri=https%3A%2F%2Fwww.saxoinvestor.dk%2Fapi%2Flogin%2Fcode&decision=allow&state=' + state_string_b64_encoded

session.headers['X-ForgeRock-TransactionId'] = correlation_id
session.headers['Content-Type'] = 'application/x-www-form-urlencoded'

step_five = session.post(auth_url, data=data)
step_five_json = step_five.json()

…Men vi skal videre.

Trin 6: Bearer token

Fra mit tidligere script – og ved at gå lidt frem i forespørgselshistorikken – kan jeg se, at jeg har brug for et “Bearer token” for at lave forespørgsler i API’et. Det bliver sendt i en header, der hedder “Authorization” og består af stringen “Bearer”, et mellemrum, og en lang streng af en masse forskellige tegn.

Forespørgsel til API’et med et Bearer token

Jeg søger i strengen fra “Bearer token” i forespørgselshistorikken i browseren, og finder ud af, at det først dukker op i kildekoden for siden:

https://www.saxoinvestor.dk/showapp

I forespørgselshistorikken kan jeg se, at denne side besøges umiddelbart efter trin 5. Min browser laver en tom forespørgsel af typen GET med nogle parametre, nemlig “code”, som jeg har fået fat i fra trin 5, og state, som jeg fik fat i helt tilbage i trin 1.

Forespørgslen til “showapp” med parametrene “code” og “state”.

Selve mit “Bearer token” befinder sig i (endnu) en window.config-variabel i et script i kildekoden og hedder “idToken”.

I min kode sletter jeg mine headers fra trin 5, laver en url til “showapp”-siden, besøger siden, udtrækker mit “Bearer token” af kildekoden og genererer en streng til brug for API-headeren:

del session.headers['X-ForgeRock-TransactionId']
del session.headers['Content-Type']

step_six_code = step_five_json['code']
step_six_url = f'https://www.saxoinvestor.dk/showapp?code={step_six_code}&state={state_string_b64_encoded}'

step_six = session.get(step_six_url)
step_six_text = step_six.text

search_string = ',"idToken":"'
start_of_bearer_token = step_six_text.index(search_string)+len(search_string)
end_of_bearer_token = step_six_text.index('"', start_of_bearer_token)
bearer_token = step_six_text[start_of_bearer_token:end_of_bearer_token]
bearer_string = 'Bearer ' + bearer_token

Trin 7: Hente data

Med loginproceduren overstået, er jeg klar til at hente mine transaktioner. Her kan jeg genbruge kode fra tidligere. Jeg bruger en frisk session, da jeg ikke længere har brug for diverse cookies:

# Start session and set bearer token as header
api_session = requests.Session()
api_session.headers['Authorization'] = bearer_string

# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
api_query = api_session.get(url)
clientdata = api_query.json()
clientkey = clientdata['ClientKey']

# Extract transactions
url = f'https://www.saxotrader.com/openapi/hist/v1/transactions?ClientKey={clientkey}&FromDate={startdate}&ToDate={enddate}'
transactions = api_session.get(url)
transactions_json = transactions.json()

Det færdige program

Det færdige script finder du her. Jeg har ryddet lidt op i det ift. gennemgangen oven for. Før du bruger scriptet, skal du logge ind i Saxo Bank og bruge udviklerværktøjerne i din browser, til at kopiere nogle værdier ind i scriptet. Jeg har forsøgt at hjælpe dig på vej i kommentarerne.

God fornøjelse.

# -*- coding: utf-8 -*-
# Author: Morten Helmstedt. E-mail: helmstedt@gmail.com
"""This script logs into a Saxo Bank account and performs a query to extract transactions.
In order to use the program, first log in to Saxo Bank in a browser. When prompted for a two
factor code, enter code and select "trust this device". Next, log out and open developer tools
in your browser (F12). Visit the Saxo Bank login page again and log in. This time you should not
be prompted for a two factor login code. Go through the request history in the network tab of
developer tools and insert the relevant values in the script below. I have marked the parts 
you need to edit with "EDIT THIS PART". Once you're done, run the script."""

import requests 
from datetime import datetime
from datetime import date
import json
import base64

# CONFIGURATION #

# EDIT THIS PART
# Saxo user account credentials
user = 'USERNAME'
password = 'PASSWORD'

# EDIT THIS PART (AS NEEDED)
# Start date (start of period for transactions) and date today used for extraction of transactions
startdate = '2019-01-01'
today = date.today()
enddate = datetime.strftime(today, '%Y-%m-%d')


# LOGIN TO SAXO BANK #

# Start requests session and set user agent
session = requests.Session()
# EDIT THIS PART
# Copy the user agent header value from your browser
session.headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; rv:122.0) Gecko/20100101 Firefox/122.0'

# Step one: Fetch login page and get correlation id from page source
step_one = session.get('https://www.saxoinvestor.dk/Login/da')
step_one_text = step_one.text
search_string = '"correlationId":"'
start_of_correlation_id = step_one_text.index(search_string)+len(search_string)
end_of_correlation_id = step_one_text.index('"', start_of_correlation_id)
correlation_id = step_one_text[start_of_correlation_id:end_of_correlation_id]

# Insert correlation id in state string and encode it as base 64 for later use
state_string = '{"appId":"investor","correlationId":"' + correlation_id + '"}'
state_string_b64_encoded = base64.b64encode(bytes(state_string, 'utf-8')).decode()

# Step two: Start authentication part one of three

login_url = 'https://www.saxoinvestor.dk/am/json/realms/root/realms/dca/authenticate?authIndexType=service&authIndexValue=authn-web-v6'

step_two = session.post(login_url)
step_two_json = step_two.json()

# Step three: Prepare data structure and perform authentication part two of three

# EDIT THIS PART
# Copy this value from the second request to login_url in your browser. Use the
# "Raw" request payload to copy from. Your string should look similar to the one here
identity = "{\"identifier\":\"EDITED\",\"metadata\":{\"hardware\":{\"cpuClass\":null,\"deviceMemory\":null,\"hardwareConcurrency\":2,\"maxTouchPoints\":0,\"oscpu\":\"Windows NT 10.0; Win64; x64\",\"display\":{\"width\":2560,\"height\":771,\"pixelDepth\":24,\"angle\":0}},\"browser\":{\"userAgent\":\"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0\",\"appName\":\"Netscape\",\"appCodeName\":\"Mozilla\",\"appVersion\":\"5.0 (Windows)\",\"appMinorVersion\":null,\"buildID\":\"20181001000000\",\"product\":\"Gecko\",\"productSub\":\"20100101\",\"vendor\":\"\",\"vendorSub\":\"\",\"browserLanguage\":null,\"plugins\":\"internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;internal-pdf-viewer;\"},\"platform\":{\"language\":\"en\",\"platform\":\"Win32\",\"userLanguage\":null,\"systemLanguage\":null,\"deviceName\":\"Windows (Browser)\",\"fonts\":\"EDITED;\",\"timezone\":0}}}"

step_three_json_struct = json.loads('{"authId":"","callbacks":[{"type":"NameCallback","output":[{"name":"prompt","value":"User Name"}],"input":[{"name":"IDToken1","value":"' + user +'"}],"_id":0},{"type":"HiddenValueCallback","output":[{"name":"value","value":""},{"name":"id","value":"Enter SP"}],"input":[{"name":"IDToken2","value":"https://www.saxoinvestor.dk/investor"}],"_id":1},{"type":"HiddenValueCallback","output":[{"name":"value","value":""},{"name":"id","value":"Enter Application"}],"input":[{"name":"IDToken3","value":"SaxoInvestor"}],"_id":2},{"type":"DeviceProfileCallback","output":[{"name":"metadata","value":true},{"name":"location","value":false},{"name":"message","value":""}],"input":[{"name":"IDToken4","value":""}],"_id":3}],"stage":"loginIdPage","status":200,"ok":true}')

step_three_json_struct['authId'] = step_two_json['authId']
step_three_json_struct['callbacks'][3]['input'][0]['value'] = identity

step_three = session.post(login_url, json=step_three_json_struct)
step_three_json = step_three.json()

# Step four: Prepare data structure and perform authentication part three of three
step_four_json_struct = json.loads('{"authId":"","callbacks":[{"type":"MetadataCallback","output":[{"name":"data","value":{"authenticationAttributes":{"type":"loginid-password"}}}],"_id":4},{"type":"NameCallback","output":[{"name":"prompt","value":"User Name"}],"input":[{"name":"IDToken2","value":"' + user + '"}],"_id":5},{"type":"PasswordCallback","output":[{"name":"prompt","value":"Password"}],"input":[{"name":"IDToken3","value":"' + password + '"}],"_id":6},{"type":"ChoiceCallback","output":[{"name":"prompt","value":"LoginId or password?"},{"name":"choices","value":["loginid","password"]},{"name":"defaultChoice","value":1}],"input":[{"name":"IDToken4","value":1}],"_id":7}],"stage":"loginIdOrPasswordPage","status":200,"ok":true}')

step_four_json_struct['authId'] = step_three_json['authId']

step_four = session.post(login_url, json=step_four_json_struct)
step_four_json = step_four.json()

# Step five: Authenticate
auth_url = 'https://www.saxoinvestor.dk/am/oauth2/realms/root/realms/dca/authorize'

data = 'csrf=' + step_four_json['tokenId'] + '&scope=openid%20profile%20openapi%20fr%3Aidm%3A*&response_type=code&client_id=SaxoInvestorPlatform&redirect_uri=https%3A%2F%2Fwww.saxoinvestor.dk%2Fapi%2Flogin%2Fcode&decision=allow&state=' + state_string_b64_encoded

session.headers['X-ForgeRock-TransactionId'] = correlation_id
session.headers['Content-Type'] = 'application/x-www-form-urlencoded'

step_five = session.post(auth_url, data=data)
step_five_json = step_five.json()

del session.headers['X-ForgeRock-TransactionId']
del session.headers['Content-Type']

# Step six: Open app website and extract API bearer token
step_six_code = step_five_json['code']
step_six_url = f'https://www.saxoinvestor.dk/showapp?code={step_six_code}&state={state_string_b64_encoded}'

step_six = session.get(step_six_url)
step_six_text = step_six.text

search_string = ',"idToken":"'
start_of_bearer_token = step_six_text.index(search_string)+len(search_string)
end_of_bearer_token = step_six_text.index('"', start_of_bearer_token)
bearer_token = step_six_text[start_of_bearer_token:end_of_bearer_token]
bearer_string = 'Bearer ' + bearer_token


# Perform API calls #
# Documentation at https://www.developer.saxo/openapi/learn

# Start new session and set bearer token as header
api_session = requests.Session()
api_session.headers['Authorization'] = bearer_string

# First API request gets Client Key which is used for most API calls
# See https://www.developer.saxo/openapi/learn/the-tutorial for expected return data
url = 'https://www.saxoinvestor.dk/openapi/port/v1/clients/me'
api_query = api_session.get(url)
clientdata = api_query.json()
clientkey = clientdata['ClientKey']

# Extract transactions
url = f'https://www.saxotrader.com/openapi/hist/v1/transactions?ClientKey={clientkey}&FromDate={startdate}&ToDate={enddate}'
transactions = api_session.get(url)
transactions_json = transactions.json()