Skip to content

Commit

Permalink
Merge pull request #7 from hotosm/feat/raw-token-return
Browse files Browse the repository at this point in the history
Return the raw OSM OAuth2 token in addition to `access_token`
  • Loading branch information
spwoodcock authored Aug 9, 2024
2 parents fc6499f + 48ed472 commit 5578444
Show file tree
Hide file tree
Showing 10 changed files with 344 additions and 46 deletions.
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Tests

on:
push:
paths:
- osm_login_python/**
- tests/**
- pyproject.toml
branches: [main]
# Allow manual trigger (workflow_dispatch)
workflow_dispatch:

jobs:
pytest:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install deps
run: |
pip install pdm==2.17.3
pdm install
- name: Run tests
run: |
pdm run pytest
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ __pypackages__

# MkDocs
site

# Coverage dir
.coverage
77 changes: 56 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
# osm-login-python

Library to provide osm login oauth2.0 exchange to python projects

Makes it very easier to implement osm authentication login to
their project with oauth2.0
Package to manage OAuth 2.0 login for OSM in Python.

📖 [Documentation](https://hotosm.github.io/osm-login-python/)

[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/hotosm/osm-login-python/main.svg)](https://results.pre-commit.ci/latest/github/hotosm/osm-login-python/main)

![coverage badge](./docs/coverage.svg)

## Install with [pip](https://pypi.org/project/osm-login-python/)

```bash
Expand All @@ -28,30 +27,55 @@ osm_auth=Auth(
client_secret=YOUR_OSM_CLIENT_SECRET,
secret_key=YOUR_OSM_SECRET_KEY,
login_redirect_uri=YOUR_OSM_LOGIN_REDIRECT_URI,
scope=YOUR_OSM_SCOPE,
scope=YOUR_OSM_SCOPE_LIST,
)
```

## Usage

Provides 3 Functions inside Auth class :
Three functions are provided:

1. login() -- Returns the login url for OSM.

- The user must then access this URL and authorize the OAuth application
to login.
- The user will be redirected to the configured `login_redirect_uri` after
successful login with OSM.
- The web page must then call the `callback()` function below, sending the
current URL to the function (which includes the OAuth authorization code).

2. callback() -- Returns the encoded and serialized data:

- `user_data` a JSON of OSM user data.
- `oauth_token` a string OSM OAuth token.
- Both are encoded and serialized as an additional safety measure when used
in URLs.

3. deserialize_data() -- returns decoded and deserialized data from `callback()`.

1. login() -- Returns the login url for osm
2. callback() -- Returns the access token for the user
3. deserialize_access_token() -- returns the user data
> [!NOTE]
> This package is primarily intended to return OSM user data.
>
> It is also possible to obtain the `oauth_token` as described above, for making
> authenticated requests against the OSM API from within a secure **backend**
> service.
>
> To use the OAuth token in a **frontend** please use caution and adhere
> to best practice security, such as embedding in a secure httpOnly cookie
> (do not store in localStorage, sessionStorage, or unsecure cookies).
## Example

On django :
In Django:

```python
import json
from django.conf import settings
from osm_login_python.core import Auth
from django.http import JsonResponse
import json

# initialize osm_auth with our credentials
osm_auth=Auth(
osm_auth = Auth(
osm_url=YOUR_OSM_URL,
client_id=YOUR_OSM_CLIENT_ID,
client_secret=YOUR_OSM_CLIENT_SECRET,
Expand All @@ -61,29 +85,40 @@ osm_auth=Auth(
)

def login(request):
login_url=osm_auth.login()
login_url = osm_auth.login()
return JsonResponse(login_url)

def callback(request):
# Generating token through osm_auth library method
token=osm_auth.callback(request.build_absolute_uri())
token = osm_auth.callback(request.build_absolute_uri())
return JsonResponse(token)

def get_my_data(request,access_token: str):
user_data=osm_auth.deserialize_access_token(access_token)
def get_my_data(request, serialized_user_data: str):
user_data = osm_auth.deserialize_data(serialized_user_data)
return JsonResponse(user_data)
```

- Check Django integration example here
- Django integration example here
<https://github.com/hotosm/fAIr/tree/master/backend/login>

- Check FastAPI integration example here
- FastAPI integration example here
<https://github.com/hotosm/export-tool-api/tree/develop/API/auth>

### Version Control

Use [commitizen](https://pypi.org/project/commitizen/) for version control
Use [commitizen](https://pypi.org/project/commitizen/) for version control.

### Test Coverage

Generate a coverage badge:

```bash
pdm install
pdm run coverage run -m pytest
# pdm run coverage report
pdm run coverage coverage-badge -o docs/coverage.svg
```

### Contirbute
### Contribute

Contributions are welcome !
Contributions are welcome!
21 changes: 21 additions & 0 deletions docs/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions osm_login_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Login(BaseModel):


class Token(BaseModel):
"""Model to return the access token."""
"""Model to return the user data and OSM OAuth token."""

access_token: str
user_data: str
oauth_token: str
77 changes: 59 additions & 18 deletions osm_login_python/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import base64
import json
import logging
from typing import Any

from itsdangerous import BadSignature, SignatureExpired
from itsdangerous.url_safe import URLSafeSerializer
Expand All @@ -18,6 +19,10 @@ class Auth:

def __init__(self, osm_url, client_id, client_secret, secret_key, login_redirect_uri, scope):
"""Set object params and get OAuth2 session."""
# Strip trailing slash so our URL forming works
if osm_url.endswith("/"):
osm_url = osm_url.rstrip("/")

self.osm_url = osm_url
self.client_secret = client_secret
self.secret_key = secret_key
Expand All @@ -42,66 +47,102 @@ def login(
login_url, _ = self.oauth.authorization_url(authorize_url)
return json.loads(Login(login_url=login_url).model_dump_json())

def callback(self, callback_url: str) -> str:
def _serialize_encode_data(self, data: Any) -> str:
"""Convert data to a serialized base64 encoded string.
This encodes the data in a URL safe format using a secret key.
The data can only be decoded using the secret key.
Args:
data(Any): String or JSON data to be serialized.
Returns:
encoded_data(str): The serialized and base64 encoded data.
"""
serializer = URLSafeSerializer(self.secret_key)
serialized_data = serializer.dumps(data)
# NOTE here the serialized data is (further) base64 encoded
encoded_data = base64.b64encode(bytes(serialized_data, "utf-8")).decode("utf-8")
return encoded_data

def callback(self, callback_url: str) -> dict:
"""Performs token exchange between OSM and the callback website.
Core will use Oauth secret key from configuration while deserializing token,
provides access token that can be used for authorized endpoints.
The returned data will be individually serialized and encoded, so it can
only be used from within the same module.
The returned dictionary / JSON will contain:
- `user_data`, containing OSM user details.
- `oauth_token`, containing the OSM OAuth token for API calls.
To use these values, we must run them through the `deserialize_data`
function to deserialize and decode the data using the `secret_key`
variable set.
NOTE 'oauth_token' should not be stored in a frontend and can be discarded
if not required. It could, however, be stored in a secure httpOnly cookie
in the frontend if required, for subsequent API calls.
Args:
callback_url(str): Absolute URL should be passed which
is catched from login_redirect_uri.
is returned from login_redirect_uri.
Returns:
access_token(str): The decoded access token.
dict: The encoded user details and encoded OSM access token.
"""
token_url = f"{self.osm_url}/oauth2/token"
self.oauth.fetch_token(
token = self.oauth.fetch_token(
token_url,
authorization_response=callback_url,
client_secret=self.client_secret,
)
# NOTE this is the actual token for the OSM API
osm_access_token = token.get("access_token")

user_api_url = f"{self.osm_url}/api/0.6/user/details.json"
# NOTE the osm token is included automatically in requests from self.oauth
resp = self.oauth.get(user_api_url)
if resp.status_code != 200:
raise ValueError("Invalid response from OSM")
data = resp.json().get("user")
serializer = URLSafeSerializer(self.secret_key)
user_data = {
"id": data.get("id"),
"username": data.get("display_name"),
"img_url": data.get("img").get("href") if data.get("img") else None,
}
token = serializer.dumps(user_data)
access_token = base64.b64encode(bytes(token, "utf-8")).decode("utf-8")
token = Token(access_token=access_token)
return json.loads(token.model_dump_json())

def deserialize_access_token(self, access_token: str) -> dict:
encoded_user_data = self._serialize_encode_data(user_data)
encoded_osm_token = self._serialize_encode_data(osm_access_token)

# The actual response from this endpoint {"user_data": xxx, "oauth_token": xxx}
token = Token(user_data=encoded_user_data, oauth_token=encoded_osm_token)
return token.model_dump()

def deserialize_data(self, data: str) -> dict:
"""Returns the userdata as JSON from access token.
Can be used for login required decorator or to check
the access token provided.
Args:
access_token(str): The access token from Auth.callback()
data(str): The user_data or oauth_token from Auth.callback()
Returns:
user_data(dict): A JSON of user data from OSM.
deserialized_data(dict): A deserialized JSON data.
"""
deserializer = URLSafeSerializer(self.secret_key)

try:
decoded_token = base64.b64decode(access_token)
decoded_data = base64.b64decode(data)
except Exception as e:
log.error(e)
log.error(f"Could not decode token: {access_token}")
log.error(f"Could not decode token: {data}")
raise ValueError("Could not decode token") from e

try:
user_data = deserializer.loads(decoded_token)
deserialized_data = deserializer.loads(decoded_data)
except (SignatureExpired, BadSignature) as e:
log.error(e)
raise ValueError("Auth token is invalid or expired") from e

return user_data
return deserialized_data
Loading

0 comments on commit 5578444

Please sign in to comment.