Skip to content

Commit e0f7ffb

Browse files
committed
Create template demo and simplify all
1 parent d863885 commit e0f7ffb

File tree

8 files changed

+347
-19
lines changed

8 files changed

+347
-19
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ Easy to setup social authentication mechanism with support for several auth prov
44

55
## Examples
66

7-
- [airnominal](./examples/airnominal)
8-
- [dogeapi](./examples/DogeAPI)
7+
- [airnominal](./examples/airnominal) - [fastapi-sso](https://github.com/tomasvotava/fastapi-sso) based implementation
8+
- [dogeapi](./examples/DogeAPI) - [fastapi-allauth](https://github.com/K-villain/fastapi-allauth) based implementation
99

1010
Both can be run using the following command:
1111

examples/airnominal/.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
AIRNOMINAL_GITHUB_CLIENT_ID=eccd08d6736b7999a32a
22
AIRNOMINAL_GITHUB_CLIENT_SECRET=642999c1c5f2b3df8b877afdc78252ef5b594d31
33
AIRNOMINAL_GITHUB_REDIRECT_URL=http://127.0.0.1:8000/auth/callback
4-
AIRNMONIAL_MAIN_PAGE_REDIRECT_URL=http://127.0.0.1:8000/auth/status
4+
AIRNMONIAL_MAIN_PAGE_REDIRECT_URL=http://127.0.0.1:8000/
55

66
AIRNOMINAL_JWT_SECRET_KEY=test
77
AIRNOMINAL_JWT_ALGORITHM=HS256

examples/airnominal/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@
1010
from fastapi.security import OAuth2
1111
from fastapi.security.base import SecurityBase
1212
from fastapi.security.utils import get_authorization_scheme_param
13-
from fastapi_sso.sso.github import GithubSSO
1413
from jose import jwt
1514
from pydantic import BaseModel
1615
from starlette.requests import Request
1716
from starlette.status import HTTP_403_FORBIDDEN
1817

1918
from config import CLIENT_ID, CLIENT_SECRET, redirect_url, SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, \
2019
redirect_url_main_page
20+
from fastapi_sso.github import GithubSSO
2121

2222
router = APIRouter()
2323

examples/airnominal/fastapi_sso/__init__.py

Whitespace-only changes.
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
"""SSO login base dependency
2+
"""
3+
# pylint: disable=too-few-public-methods
4+
5+
import json
6+
import sys
7+
import warnings
8+
from typing import Any, Dict, List, Optional
9+
10+
import httpx
11+
import pydantic
12+
from oauthlib.oauth2 import WebApplicationClient
13+
from starlette.exceptions import HTTPException
14+
from starlette.requests import Request
15+
from starlette.responses import RedirectResponse
16+
17+
if sys.version_info >= (3, 8):
18+
from typing import TypedDict
19+
else:
20+
from typing_extensions import TypedDict
21+
22+
DiscoveryDocument = TypedDict(
23+
"DiscoveryDocument", {"authorization_endpoint": str, "token_endpoint": str, "userinfo_endpoint": str}
24+
)
25+
26+
27+
class UnsetStateWarning(UserWarning):
28+
"""Warning about unset state parameter"""
29+
30+
31+
class SSOLoginError(HTTPException):
32+
"""Raised when any login-related error ocurrs
33+
(such as when user is not verified or if there was an attempt for fake login)
34+
"""
35+
36+
37+
class OpenID(pydantic.BaseModel): # pylint: disable=no-member
38+
"""Class (schema) to represent information got from sso provider in a common form."""
39+
40+
id: Optional[str] = None
41+
email: Optional[str] = None
42+
first_name: Optional[str] = None
43+
last_name: Optional[str] = None
44+
display_name: Optional[str] = None
45+
picture: Optional[str] = None
46+
provider: Optional[str] = None
47+
48+
49+
# pylint: disable=too-many-instance-attributes
50+
class SSOBase:
51+
"""Base class (mixin) for all SSO providers"""
52+
53+
provider: str = NotImplemented
54+
client_id: str = NotImplemented
55+
client_secret: str = NotImplemented
56+
redirect_uri: Optional[str] = NotImplemented
57+
scope: List[str] = NotImplemented
58+
_oauth_client: Optional[WebApplicationClient] = None
59+
additional_headers: Optional[Dict[str, Any]] = None
60+
61+
def __init__(
62+
self,
63+
client_id: str,
64+
client_secret: str,
65+
redirect_uri: Optional[str] = None,
66+
allow_insecure_http: bool = False,
67+
use_state: bool = False,
68+
scope: Optional[List[str]] = None,
69+
):
70+
# pylint: disable=too-many-arguments
71+
self.client_id = client_id
72+
self.client_secret = client_secret
73+
self.redirect_uri = redirect_uri
74+
self.allow_insecure_http = allow_insecure_http
75+
# TODO: Remove use_state argument and attribute
76+
if use_state:
77+
warnings.warn(
78+
(
79+
"Argument 'use_state' of SSOBase's constructor is deprecated and will be removed in "
80+
"future releases. Use 'state' argument of individual methods instead."
81+
),
82+
DeprecationWarning,
83+
)
84+
self.scope = scope or self.scope
85+
self._refresh_token: Optional[str] = None
86+
self._state: Optional[str] = None
87+
88+
@property
89+
def state(self) -> Optional[str]:
90+
"""Gets state as it was returned from the server"""
91+
if self._state is None:
92+
warnings.warn(
93+
"'state' parameter is unset. This means the server either "
94+
"didn't return state (was this expected?) or 'verify_and_process' hasn't been called yet.",
95+
UnsetStateWarning,
96+
)
97+
return self._state
98+
99+
@property
100+
def oauth_client(self) -> WebApplicationClient:
101+
"""OAuth Client to help us generate requests and parse responses"""
102+
if self.client_id == NotImplemented:
103+
raise NotImplementedError(f"Provider {self.provider} not supported")
104+
if self._oauth_client is None:
105+
self._oauth_client = WebApplicationClient(self.client_id)
106+
return self._oauth_client
107+
108+
@property
109+
def access_token(self) -> Optional[str]:
110+
"""Access token from token endpoint"""
111+
return self.oauth_client.access_token
112+
113+
@property
114+
def refresh_token(self) -> Optional[str]:
115+
"""Get refresh token (if returned from provider)"""
116+
return self._refresh_token or self.oauth_client.refresh_token
117+
118+
@classmethod
119+
async def openid_from_response(cls, response: dict) -> OpenID:
120+
"""Return {OpenID} object from provider's user info endpoint response"""
121+
raise NotImplementedError(f"Provider {cls.provider} not supported")
122+
123+
async def get_discovery_document(self) -> DiscoveryDocument:
124+
"""Get discovery document containing handy urls"""
125+
raise NotImplementedError(f"Provider {self.provider} not supported")
126+
127+
@property
128+
async def authorization_endpoint(self) -> Optional[str]:
129+
"""Return `authorization_endpoint` from discovery document"""
130+
discovery = await self.get_discovery_document()
131+
return discovery.get("authorization_endpoint")
132+
133+
@property
134+
async def token_endpoint(self) -> Optional[str]:
135+
"""Return `token_endpoint` from discovery document"""
136+
discovery = await self.get_discovery_document()
137+
return discovery.get("token_endpoint")
138+
139+
@property
140+
async def userinfo_endpoint(self) -> Optional[str]:
141+
"""Return `userinfo_endpoint` from discovery document"""
142+
discovery = await self.get_discovery_document()
143+
return discovery.get("userinfo_endpoint")
144+
145+
async def get_login_url(
146+
self,
147+
*,
148+
redirect_uri: Optional[str] = None,
149+
params: Optional[Dict[str, Any]] = None,
150+
state: Optional[str] = None,
151+
) -> str:
152+
"""Return prepared login url. This is low-level, see {get_login_redirect} instead."""
153+
params = params or {}
154+
redirect_uri = redirect_uri or self.redirect_uri
155+
if redirect_uri is None:
156+
raise ValueError("redirect_uri must be provided, either at construction or request time")
157+
request_uri = self.oauth_client.prepare_request_uri(
158+
await self.authorization_endpoint, redirect_uri=redirect_uri, state=state, scope=self.scope, **params
159+
)
160+
return request_uri
161+
162+
async def get_login_redirect(
163+
self,
164+
*,
165+
redirect_uri: Optional[str] = None,
166+
params: Optional[Dict[str, Any]] = None,
167+
state: Optional[str] = None,
168+
) -> RedirectResponse:
169+
"""Return redirect response by Stalette to login page of Oauth SSO provider
170+
171+
Arguments:
172+
redirect_uri {Optional[str]} -- Override redirect_uri specified on this instance (default: None)
173+
params {Optional[Dict[str, Any]]} -- Add additional query parameters to the login request.
174+
state {Optional[str]} -- Add state parameter. This is useful if you want
175+
the server to return something specific back to you.
176+
177+
Returns:
178+
RedirectResponse -- Starlette response (may directly be returned from FastAPI)
179+
"""
180+
login_uri = await self.get_login_url(redirect_uri=redirect_uri, params=params, state=state)
181+
response = RedirectResponse(login_uri, 303)
182+
return response
183+
184+
async def verify_and_process(
185+
self,
186+
request: Request,
187+
*,
188+
params: Optional[Dict[str, Any]] = None,
189+
headers: Optional[Dict[str, Any]] = None,
190+
redirect_uri: Optional[str] = None,
191+
) -> Optional[OpenID]:
192+
"""Get FastAPI (Starlette) Request object and process login.
193+
This handler should be used for your /callback path.
194+
195+
Arguments:
196+
request {Request} -- FastAPI request object (or Starlette)
197+
params {Optional[Dict[str, Any]]} -- Optional additional query parameters to pass to the provider
198+
199+
Returns:
200+
Optional[OpenID] -- OpenID if the login was successfull
201+
"""
202+
headers = headers or {}
203+
code = request.query_params.get("code")
204+
if code is None:
205+
raise SSOLoginError(400, "'code' parameter was not found in callback request")
206+
self._state = request.query_params.get("state")
207+
return await self.process_login(
208+
code, request, params=params, additional_headers=headers, redirect_uri=redirect_uri
209+
)
210+
211+
async def process_login(
212+
self,
213+
code: str,
214+
request: Request,
215+
*,
216+
params: Optional[Dict[str, Any]] = None,
217+
additional_headers: Optional[Dict[str, Any]] = None,
218+
redirect_uri: Optional[str] = None,
219+
) -> Optional[OpenID]:
220+
"""This method should be called from callback endpoint to verify the user and request user info endpoint.
221+
This is low level, you should use {verify_and_process} instead.
222+
223+
Arguments:
224+
params {Optional[Dict[str, Any]]} -- Optional additional query parameters to pass to the provider
225+
additional_headers {Optional[Dict[str, Any]]} -- Optional additional headers to be added to all requests
226+
"""
227+
# pylint: disable=too-many-locals
228+
params = params or {}
229+
additional_headers = additional_headers or {}
230+
additional_headers.update(self.additional_headers or {})
231+
url = request.url
232+
scheme = url.scheme
233+
if not self.allow_insecure_http and scheme != "https":
234+
current_url = str(url).replace("http://", "https://")
235+
scheme = "https"
236+
else:
237+
current_url = str(url)
238+
current_path = f"{scheme}://{url.netloc}{url.path}"
239+
240+
token_url, headers, body = self.oauth_client.prepare_token_request(
241+
await self.token_endpoint,
242+
authorization_response=current_url,
243+
redirect_url=redirect_uri or self.redirect_uri or current_path,
244+
code=code,
245+
**params,
246+
) # type: ignore
247+
248+
if token_url is None:
249+
return None
250+
251+
headers.update(additional_headers)
252+
253+
auth = httpx.BasicAuth(self.client_id, self.client_secret)
254+
async with httpx.AsyncClient() as session:
255+
response = await session.post(token_url, headers=headers, content=body, auth=auth)
256+
content = response.json()
257+
self._refresh_token = content.get("refresh_token")
258+
self.oauth_client.parse_request_body_response(json.dumps(content))
259+
260+
uri, headers, _ = self.oauth_client.add_token(await self.userinfo_endpoint)
261+
response = await session.get(uri, headers=headers)
262+
content = response.json()
263+
264+
return await self.openid_from_response(content)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Github SSO Oauth Helper class"""
2+
3+
from .base import DiscoveryDocument, OpenID, SSOBase
4+
5+
6+
class GithubSSO(SSOBase):
7+
"""Class providing login via Github SSO"""
8+
9+
provider = "github"
10+
scope = ["user:email"]
11+
additional_headers = {"accept": "application/json"}
12+
13+
async def get_discovery_document(self) -> DiscoveryDocument:
14+
return {
15+
"authorization_endpoint": "https://github.com/login/oauth/authorize",
16+
"token_endpoint": "https://github.com/login/oauth/access_token",
17+
"userinfo_endpoint": "https://api.github.com/user",
18+
}
19+
20+
@classmethod
21+
async def openid_from_response(cls, response: dict) -> OpenID:
22+
return OpenID(
23+
email=response["email"],
24+
provider=cls.provider,
25+
id=response["id"],
26+
display_name=response["login"],
27+
picture=response["avatar_url"],
28+
)

examples/airnominal/main.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,44 @@
1-
import influxdb_client
2-
from fastapi import FastAPI
3-
from influxdb_client.client.write_api import SYNCHRONOUS
1+
import jwt
2+
from fastapi import FastAPI, Request, APIRouter
3+
from fastapi.responses import HTMLResponse
4+
from fastapi.templating import Jinja2Templates
5+
from starlette.authentication import AuthenticationBackend
6+
from starlette.middleware.authentication import AuthenticationMiddleware
47

58
from auth import router as auth_router
6-
from config import api_root_path
9+
from config import api_root_path, SECRET_KEY, ALGORITHM
710
from data_endpoint import router as data_router
811
from register import router as register_router
912

10-
# config for influx-db
11-
bucket = "Airnominal-data2"
12-
org = "Airnominal"
13-
token = "DFUh1vGGeC2UHrAY8UW-t_3ylXa5LLo7mID-vaZ8UFgaggNjqRpz_lxmNErbazdJQA7q_F8stomdWK_YVHaE1A=="
14-
url = "http://192.168.64.10:8086"
13+
router = APIRouter()
14+
templates = Jinja2Templates(directory="templates")
1515

16-
client = influxdb_client.InfluxDBClient(
17-
url=url,
18-
token=token,
19-
org=org
20-
)
2116

22-
write_api = client.write_api(write_options=SYNCHRONOUS)
17+
@router.get("/", response_class=HTMLResponse)
18+
async def root(request: Request):
19+
return templates.TemplateResponse("index.html", {"request": request, "user": request.user})
20+
2321

2422
app = FastAPI(root_path=api_root_path)
23+
app.include_router(router)
2524
app.include_router(auth_router)
2625
app.include_router(register_router)
2726
app.include_router(data_router)
27+
28+
29+
class BearerTokenAuthBackend(AuthenticationBackend):
30+
async def authenticate(self, request):
31+
authorization = request.cookies.get("Authorization")
32+
33+
if not authorization:
34+
return "", None
35+
36+
access_token = authorization.split(" ")[1]
37+
user = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM])
38+
39+
return authorization, user
40+
41+
42+
@app.on_event('startup')
43+
async def startup():
44+
app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend())

0 commit comments

Comments
 (0)