Skip to content
This repository was archived by the owner on Oct 25, 2023. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM python:3-alpine
ENV PYTHONDONTWRITEBYECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apk add --no-cache gcc g++ make
COPY . /tmp/aiohttp-oauth2
COPY ./example/. .
RUN pip install -r requirements.txt
RUN pip install --upgrade /tmp/aiohttp-oauth2
EXPOSE 8080
CMD gunicorn app:app_factory --bind=0.0.0.0:8080 --worker-class aiohttp.GunicornWebWorker
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ $ pip install -r requirements.txt
$ PYTHONPATH=".." python github.py
```

# OAuth2 Setup

As part of the oauth2 spec, you will need to register your application with the oauth2 provider. To do so, you need to know your host (and port), along with the callback URL. Don't forget to include the prefix you used for your subapp.

| Path | Type |
| ---- | ---- |
| {path\_prefix}/auth | This is where you need to send your user to log in to the specific provider |
| {path\_prefix}/callback | This is what handles the oauth2 callback (exchange a code for an access token) |

# Tips

## Incorrect URL scheme (missing `https`)
Expand Down
18 changes: 15 additions & 3 deletions aiohttp_oauth2/client/contrib.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@
from .app import oauth2_app


coinbase = partial(
oauth2_app,
authorize_url="https://www.coinbase.com/oauth/authorize",
token_url="https://api.coinbase.com/oauth/token",
)

digital_ocean = partial(
oauth2_app,
authorize_url="https://cloud.digitalocean.com/v1/oauth/authorize",
token_url="https://cloud.digitalocean.com/v1/oauth/token",
)

github = partial(
oauth2_app,
authorize_url="https://github.com/login/oauth/authorize",
Expand All @@ -23,8 +35,8 @@
json_data=False,
)

twitter = partial(
twitch = partial(
oauth2_app,
authorize_url="https://api.twitter.com/oauth/authorize",
token_url="https://api.twitter.com/oauth2/token",
authorize_url="https://id.twitch.tv/oauth2/authorize",
token_url="https://id.twitch.tv/oauth2/token",
)
21 changes: 21 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: "3.4"

services:
app:
image: aiohttp-oauth2
build: .
environment:
- COINBASE_CLIENT_ID
- COINBASE_CLIENT_SECRET
- DIGITALOCEAN_CLIENT_ID
- DIGITALOCEAN_CLIENT_SECRET
- GITHUB_CLIENT_ID
- GITHUB_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- TWITCH_CLIENT_ID
- TWITCH_CLIENT_SECRET
ports:
- "8080:8080"
volumes:
- ${PWD}/example:/app
203 changes: 203 additions & 0 deletions example/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

import jinja2
from aiohttp import web
from aiohttp_jinja2 import setup as jinja2_setup, template
from aiohttp_session import SimpleCookieStorage, get_session, setup as session_setup
from aiohttp_oauth2.client.contrib import (
coinbase,
digital_ocean as digitalocean,
github,
google,
twitch,
)


@dataclass
class SocialUser:
name: str
id: str
img: str


@dataclass
class Provider:
func: Callable
name: str
on_login: Callable[[web.Request, Any], web.Response]
scopes: Optional[List[str]] = field(default_factory=list)

def __str__(self) -> str:
return self.name

@property
def url(self) -> str:
return f"/auth/{self}"

@property
def client_id(self) -> str:
return os.getenv(f"{self}_CLIENT_ID".upper())

@property
def client_secret(self) -> str:
return os.getenv(f"{self}_CLIENT_SECRET".upper())


async def on_coinbase_login(request: web.Request, access_token):
session = await get_session(request)

async with request.app["session"].get(
"https://api.coinbase.com/v2/user",
headers={
"Authorization": f"Bearer {access_token['access_token']}",
"CB-VERSION": "2019-11-15",
},
) as r:
body = (await r.json())["data"]
session["coinbase_user"] = asdict(SocialUser(
name=body["name"],
img=body["avatar_url"],
id=body["id"],
))

return web.HTTPTemporaryRedirect(location="/")


async def on_digitalocean_login(request: web.Request, access_token):
session = await get_session(request)

session["digitalocean_user"] = asdict(SocialUser(
name=access_token["info"]["name"],
img="",
id=access_token["info"]["uuid"],
))

return web.HTTPTemporaryRedirect(location="/")


async def on_github_login(request: web.Request, github_token):
session = await get_session(request)

async with request.app["session"].get(
"https://api.github.com/user",
headers={"Authorization": f"Bearer {github_token['access_token']}"},
) as r:
user = await r.json()
session["github_user"] = asdict(SocialUser(
id=user["id"],
name=user["login"],
img=user["avatar_url"],
))

return web.HTTPTemporaryRedirect(location="/")


async def on_google_login(request: web.Request, google_token: str):
session = await get_session(request)

async with request.app["session"].get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers={"Authorization": f"Bearer {google_token['access_token']}"},
) as r:
google_user = await r.json()
session["google_user"] = asdict(SocialUser(
id=google_user["sub"],
img=google_user["picture"],
name=google_user["name"],
))

return web.HTTPTemporaryRedirect(location="/")


async def on_twitch_login(request: web.Request, access_token):
session = await get_session(request)

async with request.app["session"].get(
"https://api.twitch.tv/helix/users",
headers={
"Authorization": f"Bearer {access_token['access_token']}",
"Client-ID": os.getenv("TWITCH_CLIENT_ID"),
},
) as r:
twitch_user = (await r.json())["data"][0]
session["twitch_user"] = asdict(SocialUser(
id=twitch_user["id"],
name=twitch_user["login"],
img=twitch_user["profile_image_url"],
))

return web.HTTPTemporaryRedirect(location="/")


async def app_factory() -> web.Application:
app = web.Application()

providers = [
Provider(coinbase, "coinbase", on_coinbase_login, ["wallet:user:read"]),
Provider(digitalocean, "digitalocean", on_digitalocean_login),
Provider(github, "github", on_github_login),
Provider(google, "google", on_google_login, ["profile", "email"]),
Provider(twitch, "twitch", on_twitch_login),
]

jinja2_setup(
app,
loader=jinja2.FileSystemLoader([Path(__file__).parent / "templates"]),
extensions=["jinja2.ext.with_"],
)
session_setup(app, SimpleCookieStorage())

for provider in providers[:]:
if provider.client_id and provider.client_secret:
print("Adding provider", provider)
app.add_subapp(
provider.url,
provider.func(provider.client_id, provider.client_secret, on_login=provider.on_login, scopes=provider.scopes or [])
)
else:
providers.remove(provider)
print("Missing credentials for", provider)

app["providers"] = providers

app.add_routes([
web.get("/", index),
web.get("/auth/logout", logout),
])

return app


@template("index.html")
async def index(request: web.Request) -> Dict[str, Any]:
session = await get_session(request)

context = {
"users": {},
"providers": request.app["providers"],
}

for provider in request.app["providers"]:
social_user = None
if (u := session.get(f"{provider}_user")):
social_user = SocialUser(**u)
context["users"][str(provider)] = social_user

return context


async def logout(request: web.Request):
session = await get_session(request)
if (provider := request.query.get("provider")):
session.pop(f"{provider}_user")
else:
session.invalidate()
return web.HTTPTemporaryRedirect(location="/")


if __name__ == "__main__":
web.run_app(app_factory(), host="0.0.0.0", port=8080)
1 change: 1 addition & 0 deletions examples/requirements.txt → example/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
aiohttp-jinja2==1.1.1
aiohttp-session==2.7.0
gunicorn==20.0.4
63 changes: 63 additions & 0 deletions example/templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

<title>aiohttp-oauth2 examples</title>

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-3">
<a class="navbar-brand" href="/">aiohttp-oauth2</a>

<ul class="navbar-nav mr-auto"></ul>

<form class="form-inline my-2 my-lg-0">
<a href="/auth/logout" class="btn btn-outline-success my-2 my-sm-0 {% if not users|length %}disabled{% endif %}">Logout</a>
</form>
</nav>
<div class="container">
<div class="alert alert-primary" role="alert">
No tokens are saved from this, your account is retrieved during the request cycle and added to the session just for demo purposes.
</div>

<div class="card-deck">
{% for provider in providers %}
{% with user=users[provider.name] %}
<div class="card">
{% if user and user.img %}
<img src="{{ user.img }}" class="card-img-top"/>
{% endif %}

<div class="card-body">
<h5 class="card-title">
{{ provider.name | title }}
</h5>
<p class="card-text">
{% if user %}
{{ user.name }} <span class="text-muted">({{ user.id }})</span>
{% else %}
<span class="text-muted">Not logged in</span>
{% endif %}
</p>
</div>
<div class="card-footer">
{% if user %}
<a class="btn btn-outline-primary btn-sm float-right" href="/auth/logout?provider={{ provider.name }}">Logout</a>
{% else %}
<a class="btn btn-outline-primary btn-sm float-right" href="{{ provider.url }}/auth">Login</a>
{% endif %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>

<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
</body>
</html>
19 changes: 19 additions & 0 deletions example/templates/partials/social_card.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="card">
{% if img %}<img src="{{ img }}" class="card-img-top"/>{% endif %}

<div class="card-body">
<h5 class="card-title">
{{ provider | title }}
</h5>
<p class="card-text">
{% if username %}
{{ username }}
{% else %}
<span class="text-muted">Not logged in</span>
{% endif %}
</p>
</div>
<div class="card-footer">
<a class="btn btn-outline-primary btn-sm float-right {% if username %}disabled{% endif %}" href="{{ login_url }}">Login</a>
</div>
</div>
Loading