Skip to content

Commit

Permalink
feat: oAuth support
Browse files Browse the repository at this point in the history
Due to breaking changes introduced by v2 for oAuth support,
some hacking things done with depreciation warnings supporting
backwards compatibility.

When using secrets version will default to V2 which is not yet
tested / implemented for all endpoints. Will be supported
in the coming PRs in non-breaking way as possible.

This will resolve #dogmatic69/nordigen-homeassistant/7 once the
HA integration is updated
  • Loading branch information
dogmatic69 committed Jan 6, 2022
1 parent c78becd commit 1855cbb
Show file tree
Hide file tree
Showing 17 changed files with 384 additions and 96 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.python
*egg*
client.py
examples/client.py
**/__pycache__
.coverage
htmlcov
Expand Down
13 changes: 8 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: build
build:
build: venv
python setup.py sdist

.PHONY: isort
Expand All @@ -16,13 +16,13 @@ flake8:

.PHONY: test
test:
pytest
pytest --ignore=examples/

.PHONY: ci
ci: isort black flake8 test
ci: venv isort black flake8 test

.PHONY: ci-fix
ci-fix:
ci-fix: venv
isort ./nordigen ./tests ./examples
black ./nordigen ./tests ./examples

Expand All @@ -32,7 +32,7 @@ dev:
$(MAKE) ci

.PHONY: install-pip
install-pip:
install-pip: venv
python -m pip install --upgrade pip

.PHONY: install-dev
Expand All @@ -47,3 +47,6 @@ install-deploy: install-pip
deploy: build
twine upload --verbose dist/*

.PHONY: venv
venv:
. .python/bin/activate
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Nordigen API Client

[![GitHub](https://img.shields.io/github/license/dogmatic69/nordigen-python)](LICENSE)
[![CodeFactor](https://www.codefactor.io/repository/github/dogmatic69/nordigen-python/badge)](https://www.codefactor.io/repository/github/dogmatic69/nordigen-python)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dogmatic69_nordigen-python&metric=alert_status)](https://sonarcloud.io/dashboard?id=dogmatic69_nordigen-python)
[![CI](https://github.com/dogmatic69/nordigen-python/actions/workflows/master.yaml/badge.svg)](https://github.com/dogmatic69/nordigen-python/actions/workflows/master.yaml)

Nordigen is a (always*) free banking API that takes advantage of the EU PSD2
regulations. They connect to banks in over 30 countries using real banking
API's (no screen scraping).
Expand Down Expand Up @@ -28,7 +33,7 @@ pip install nordigen-python

## Usage

Some more indepth working examples can be found in `./examples`. Also check out the test cases for usage examples.
Some more in-depth working examples can be found in `./examples`. Also check out the test cases for usage examples.

Create a client instance

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.2
0.2.0b1
34 changes: 28 additions & 6 deletions nordigen/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
from nordigen.client import AccountClient, AgreementsClient, AspspsClient, RequisitionsClient
import warnings

from apiclient import HeaderAuthentication, NoAuthentication

from nordigen.client import AccountClient, AgreementsClient, AspspsClient, AuthClient, RequisitionsClient
from nordigen.oauth import OAuthAuthentication


def Client(token=None, request_strategy=None, secret_id=None, secret_key=None, version=None):
if token:
warnings.warn("Use Client(secret_id=xxx, secret_key=xxx) instead of token", DeprecationWarning)

if not token and (not secret_id or not secret_key):
raise ValueError("secret_id and secret_key must be provided")

def Client(token, request_strategy=None):
def instance():
return instance

instance.aspsps = AspspsClient(token=token, request_strategy=request_strategy)
instance.agreements = AgreementsClient(token=token, request_strategy=request_strategy)
instance.account = AccountClient(token=token, request_strategy=request_strategy)
instance.requisitions = RequisitionsClient(token=token, request_strategy=request_strategy)
auth = HeaderAuthentication(scheme="Token", token=token)
if not token:
version = "v2" if not version else version
auth = OAuthAuthentication(
body={
"secret_id": secret_id,
"secret_key": secret_key,
},
client=AuthClient(auth=NoAuthentication(), request_strategy=request_strategy, version=version),
)

instance.aspsps = AspspsClient(auth=auth, request_strategy=request_strategy, version=version)
instance.agreements = AgreementsClient(auth=auth, request_strategy=request_strategy, version=version)
instance.account = AccountClient(auth=auth, request_strategy=request_strategy, version=version)
instance.requisitions = RequisitionsClient(auth=auth, request_strategy=request_strategy, version=version)

return instance
102 changes: 65 additions & 37 deletions nordigen/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import urllib

from apiclient import APIClient, HeaderAuthentication, JsonResponseHandler
from apiclient import APIClient, JsonResponseHandler


def next_page_by_url(response, previous_page_url):
Expand All @@ -19,7 +19,7 @@ def next_page_by_url(response, previous_page_url):


class NordigenClient(APIClient):
def __init__(self, token, scheme='https', host='ob.nordigen.com', base='/api', request_strategy=None):
def __init__(self, auth, scheme="https", host="ob.nordigen.com", base="/api", request_strategy=None, version="v2"):
"""Nordigen client base class.
Args:
Expand All @@ -32,10 +32,11 @@ def __init__(self, token, scheme='https', host='ob.nordigen.com', base='/api', r
self.request_strategy = request_strategy
self.scheme = scheme
self.host = host
self.base = base
version = f"/{version}" if version else ""
self.base = f"{base}{version}"

super(NordigenClient, self).__init__(
authentication_method=HeaderAuthentication(scheme='Token', token=token),
authentication_method=auth,
response_handler=JsonResponseHandler,
request_strategy=request_strategy,
)
Expand All @@ -50,73 +51,97 @@ def url(self, fragment, url_args={}):
Returns:
str
"""
url_args = ('?' + urllib.parse.urlencode(url_args)) if url_args else ''
return f'{self.scheme}://{self.host}{self.base}/{fragment}/{url_args}'
url_args = ("?" + urllib.parse.urlencode(url_args)) if url_args else ""
return f"{self.scheme}://{self.host}{self.base}/{fragment}/{url_args}"


class AuthClient(NordigenClient):
def token(self, secret_id, secret_key):
url = self.url(fragment="token/new")
return self.post(
url,
data={
"secret_id": secret_id,
"secret_key": secret_key,
},
)

def refresh(self, refresh_token):
url = self.url(fragment="token/refresh")
return self.post(
url,
data={
"refresh_token": refresh_token,
},
)


class AccountClient(NordigenClient):
def info(self, id):
url = self.url(fragment=f'accounts/{id}')
url = self.url(fragment=f"accounts/{id}")
return self.get(url)

def balances(self, id):
url = self.url(fragment=f'accounts/{id}/balances')
url = self.url(fragment=f"accounts/{id}/balances")
return self.get(url)

def details(self, id):
url = self.url(fragment=f'accounts/{id}/details')
url = self.url(fragment=f"accounts/{id}/details")
return self.get(url)

def transactions(self, id):
url = self.url(fragment=f'accounts/{id}/transactions')
url = self.url(fragment=f"accounts/{id}/transactions")
return self.get(url)


class AgreementsClient(NordigenClient):
def create(self, enduser_id, aspsp_id, historical_days=30):
url = self.url(fragment='agreements/enduser')
return self.post(url, data={
"max_historical_days": historical_days,
"enduser_id": enduser_id,
"aspsp_id": aspsp_id,
})
url = self.url(fragment="agreements/enduser")
return self.post(
url,
data={
"max_historical_days": historical_days,
"enduser_id": enduser_id,
"aspsp_id": aspsp_id,
},
)

def by_enduser_id(self, enduser_id, limit=None, offset=None):
url_args = dict(enduser_id=enduser_id, limit=limit, offset=offset)
url_args = {k: v for k, v in url_args.items() if v}

url = self.url(fragment='agreements/enduser', url_args=url_args)
url = self.url(fragment="agreements/enduser", url_args=url_args)
return self.get(url)

def by_id(self, id):
url = self.url(fragment=f'agreements/enduser/{id}')
url = self.url(fragment=f"agreements/enduser/{id}")
return self.get(url)

def remove(self, id):
url = self.url(fragment=f'agreements/enduser/{id}')
url = self.url(fragment=f"agreements/enduser/{id}")
return self.delete(url)

def accept(self, id):
url = self.url(fragment=f'agreements/enduser/{id}/accept')
return self.put(url, {
"user_agent": "user-agent",
"ip_address": "127.0.0.1"
})
url = self.url(fragment=f"agreements/enduser/{id}/accept")
return self.put(url, {"user_agent": "user-agent", "ip_address": "127.0.0.1"})

def text(self, id):
url = self.url(fragment=f'agreements/enduser/{id}/text')
url = self.url(fragment=f"agreements/enduser/{id}/text")
return self.get(url)


class AspspsClient(NordigenClient):
def by_country(self, country):
url = self.url(fragment='aspsps', url_args={
'country': country,
})
url = self.url(
fragment="aspsps",
url_args={
"country": country,
},
)
return self.get(url)

def by_id(self, id):
url = self.url(fragment=f'aspsps/{id}')
url = self.url(fragment=f"aspsps/{id}")
return self.get(url)


Expand All @@ -125,19 +150,19 @@ def list(self, limit=None, offset=None):
url_args = dict(limit=limit, offset=offset)
url_args = {k: v for k, v in url_args.items() if v}

url = self.url(fragment='requisitions', url_args=url_args)
url = self.url(fragment="requisitions", url_args=url_args)
return self.get(url)

def by_id(self, id):
url = self.url(fragment=f'requisitions/{id}')
url = self.url(fragment=f"requisitions/{id}")
return self.get(url)

def remove(self, id):
url = self.url(fragment=f'requisitions/{id}')
url = self.url(fragment=f"requisitions/{id}")
return self.delete(url)

def create(self, redirect, enduser_id, reference, agreements=[], language=None):
url = self.url(fragment='requisitions')
url = self.url(fragment="requisitions")
data = {
"redirect": redirect,
"agreements": agreements,
Expand All @@ -150,7 +175,10 @@ def create(self, redirect, enduser_id, reference, agreements=[], language=None):
return self.post(url, data=data)

def initiate(self, id, aspsp_id):
url = self.url(fragment=f'requisitions/{id}/links')
return self.post(url, {
"aspsp_id": aspsp_id,
})
url = self.url(fragment=f"requisitions/{id}/links")
return self.post(
url,
{
"aspsp_id": aspsp_id,
},
)
45 changes: 45 additions & 0 deletions nordigen/oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from time import time

from apiclient.authentication_methods import BaseAuthenticationMethod


class OAuthAuthentication(BaseAuthenticationMethod):
"""Authentication using secret_id and secret_key."""

_access_token = None
_token_expiration = None
_refresh_token = None
_refresh_expiration = None

def __init__(
self,
body,
client,
expiry_margin=10,
):
"""Initialize OAuthAuthentication."""
self._client = client
self._body = body
self._expiry_margin = expiry_margin

def get_headers(self):
self.refresh_token()
return {
"Authorization": f"Bearer {self._access_token}",
}

def refresh_token(self):
if self._token_expiration and self._token_expiration >= int(time()):
return True

if self._refresh_expiration and self._refresh_expiration >= int(time()):
ret = self._client.refresh(refresh_token=self._refresh_token)
self._access_token = ret.get("access")
self._token_expiration = int(time()) + int(ret.get("access_expires")) - self._expiry_margin
return True

ret = self._client.token(**self._body)
self._access_token = ret.get("access")
self._refresh_token = ret.get("refresh")
self._token_expiration = int(time()) + int(ret.get("access_expires")) - self._expiry_margin
self._refresh_expiration = int(time()) + int(ret.get("refresh_expires")) - self._expiry_margin
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[tool.black]
line-length=120
py36=true
py36=true
15 changes: 13 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,16 @@
from nordigen import Client


def test_client(token="secret-token", request_strategy=Mock(spec=BaseRequestStrategy)):
return Client(token=token, request_strategy=request_strategy)
def test_client(
token=None,
request_strategy=Mock(spec=BaseRequestStrategy),
secret_id="secret-id",
secret_key="secret-key",
):
args = dict(
token=token,
request_strategy=request_strategy,
secret_id=secret_id,
secret_key=secret_key,
)
return Client(**args)
Loading

0 comments on commit 1855cbb

Please sign in to comment.