From 3cd2d34634ba7bfef521d87a01fb74386e83c676 Mon Sep 17 00:00:00 2001 From: Ole Morud Date: Mon, 11 Nov 2024 22:01:18 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + close_page.html | 21 ++++++ get-balance.py | 173 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 6 ++ 4 files changed, 203 insertions(+) create mode 100644 .gitignore create mode 100644 close_page.html create mode 100755 get-balance.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e84bb8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.env +smn-oauth.json +venv diff --git a/close_page.html b/close_page.html new file mode 100644 index 0000000..770eb34 --- /dev/null +++ b/close_page.html @@ -0,0 +1,21 @@ + + + + + + Close Tab + + + +

The tab will now close.

+ + diff --git a/get-balance.py b/get-balance.py new file mode 100755 index 0000000..f7c8087 --- /dev/null +++ b/get-balance.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Final, Optional, cast +from uuid import uuid4 +import json +import os +import pdb +import requests +import socketserver +import sys +import threading +import time +import urllib.parse +import webbrowser + +# modifiable global variable due to library interface constraint +TOKEN: Optional[dict] = None + +STATE: Final[str] = str(uuid4()) +HOST: Final[str] = os.environ['SB1_HOST'] +PORT: Final[int] = int(os.environ['SB1_PORT']) +CLIENT_ID: Final[str] = os.environ['SB1_CLIENT_ID'] +CLIENT_SECRET: Final[str] = os.environ['SB1_CLIENT_SECRET'] +FIN_INST: Final[str] = os.environ['SB1_FIN_INST'] +REDIRECT_URI: Final[str] = os.environ['SB1_REDIRECT_URI'] + +def oauth_token(state: str, code: str, grant_type: str) -> Optional[dict]: + """ + Gets a new OAuth token by either using an authorization code obtained + through BankID authentication or by using a refresh token. + Valid grant_type values: + - 'authorization_code' + - 'refresh_token' + """ + + url = 'https://api.sparebank1.no/oauth/token' + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + data = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'grant_type': grant_type, + } + if grant_type == 'authorization_code': + data['code'] = code + data['state'] = state + data['redirect_uri'] = REDIRECT_URI + elif grant_type == 'refresh_token': + data['refresh_token'] = code + else: + raise ValueError("grant_type must be 'authorization_code' or 'refresh_token'") + + resp = requests.post('https://api.sparebank1.no/oauth/token', data=data, headers=headers) + if not resp.ok: + return None + + token = json.loads(resp.text) + + token['time'] = int(time.time()) + + return token + +def oauth_token_refresh(token: dict) -> Optional[dict]: + """ uses the `refresh_token` key in `token` to get a new `token` """ + return oauth_token("0", token['refresh_token'], 'refresh_token') + +def oauth_token_new(state: str, code: str) -> Optional[dict]: + """ uses an authentication code obtained through BankID authentication to obtain a new oauth token """ + return oauth_token(state, code, 'authorization_code') + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + """ BankId authentication redirects to this handler with the url + parameters `code` and `state`, which are used to obtain an OAuth + code. + BaseHTTPRequestHandler handlers can't return any values so a global + variable `TOKEN` is set instead. + """ + parsed_path = urllib.parse.urlparse(self.path) + params = urllib.parse.parse_qs(parsed_path.query) + + code = params.get("code", [None])[0] + state = params.get("state", [None])[0] + + assert code + assert state + + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + with open('close_page.html', 'rb') as f: + self.wfile.write(f.read()) + + global TOKEN + TOKEN = oauth_token_new(state, code) + +def browser_auth() -> Optional[dict]: + state = uuid4() + + auth_url = 'https://api.sparebank1.no/oauth/authorize' + auth_url += f'?client_id={CLIENT_ID}' + auth_url += f'&state={state}' + auth_url += f'&redirect_uri={REDIRECT_URI}' + if FIN_INST: + auth_url += f'&finInst={FIN_INST}' + auth_url += f'&response_type=code' + + print(f"opening {auth_url} in browser", file=sys.stderr) + webbrowser.open(auth_url) + + with HTTPServer((HOST, PORT), Handler) as httpd: + # handle_request sets the global variable TOKEN + httpd.handle_request() + + return TOKEN + +def token_oauth_expired(token: dict) -> bool: + """ Returns true if access token is expired """ + return token['time'] + token['expires_in'] < int(time.time()) + +def token_refresh_expired(token: dict) -> bool: + """ Return true if refresh token is expired """ + return token['time'] + token['refresh_token_expires_in'] < int(time.time()) + +def authenticate() -> dict: + """ Main authentication function, returns a token dict containing `acess_token` """ + try: + f = open('smn-oauth.json', 'r+') + with f: + token = json.load(f) + except FileNotFoundError: + token = None + + if not token: + print(f"Token not found, BankID authentication required...", file=sys.stderr) + token = browser_auth() + elif token_refresh_expired(token): + print(f"Refresh token expired, authenticating...", file=sys.stderr) + token = browser_auth() + elif token_oauth_expired(token): + print(f"OAuth expired, refreshing...", file=sys.stderr) + token = oauth_token_refresh(token) + if not token: + print(f"Failed to refresh, authenticating", file=sys.stderr) + browser_auth() + + if not token: + print(f"Fatal error: failed to get token", file=sys.stderr) + exit(1) + + with open('smn-oauth.json', 'w') as f: + json.dump(token, f, indent=4) + f.truncate() + + return token + +def main(): + token = authenticate() + + hw = requests.get( + 'https://api.sparebank1.no/personal/banking/accounts?includeCreditCardAccounts=true&includeAskAccounts=true', + headers = { + 'Authorization': f"Bearer {token['access_token']}", + 'Accept': 'application/vnd.sparebank1.v1+json; charset=utf-8' + } + ) + + for acc in hw.json()['accounts']: + print(f'{(acc["description"] + ':').ljust(25)} {acc["balance"]} {acc["currencyCode"]}') + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb75a7c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +certifi==2024.8.30 +charset-normalizer==3.4.0 +idna==3.10 +requests==2.32.3 +types-requests==2.32.0.20241016 +urllib3==2.2.3