mpt-fastapi-oauth 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mpt_fastapi_oauth-0.1.0/PKG-INFO +68 -0
- mpt_fastapi_oauth-0.1.0/README.md +58 -0
- mpt_fastapi_oauth-0.1.0/mpt_fastapi_oauth.egg-info/PKG-INFO +68 -0
- mpt_fastapi_oauth-0.1.0/mpt_fastapi_oauth.egg-info/SOURCES.txt +13 -0
- mpt_fastapi_oauth-0.1.0/mpt_fastapi_oauth.egg-info/dependency_links.txt +1 -0
- mpt_fastapi_oauth-0.1.0/mpt_fastapi_oauth.egg-info/requires.txt +3 -0
- mpt_fastapi_oauth-0.1.0/mpt_fastapi_oauth.egg-info/top_level.txt +1 -0
- mpt_fastapi_oauth-0.1.0/mptauth/__init__.py +14 -0
- mpt_fastapi_oauth-0.1.0/mptauth/client.py +52 -0
- mpt_fastapi_oauth-0.1.0/mptauth/config.py +36 -0
- mpt_fastapi_oauth-0.1.0/mptauth/dependencies.py +33 -0
- mpt_fastapi_oauth-0.1.0/mptauth/router.py +76 -0
- mpt_fastapi_oauth-0.1.0/mptauth/tokens.py +26 -0
- mpt_fastapi_oauth-0.1.0/pyproject.toml +18 -0
- mpt_fastapi_oauth-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mpt-fastapi-oauth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic OAuth2 authorization-code login for FastAPI, with pluggable user storage and JWT sessions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.110
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: python-jose[cryptography]>=3.3
|
|
10
|
+
|
|
11
|
+
# mpt-fastapi-oauth
|
|
12
|
+
|
|
13
|
+
`mptauth` - generic OAuth2 (authorization-code) login for FastAPI.
|
|
14
|
+
|
|
15
|
+
Works against any spec-compliant OAuth2/OIDC provider (Google, GitHub,
|
|
16
|
+
Keycloak, Auth0, or a custom in-house IdP) by configuring its endpoints
|
|
17
|
+
explicitly. After the provider redirect completes, mptauth hands the user's
|
|
18
|
+
profile to a host-supplied `resolve_user` hook (so you control how/where users
|
|
19
|
+
are stored) and issues its own short-lived session JWT.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from fastapi import Depends, FastAPI
|
|
25
|
+
from mptauth import (
|
|
26
|
+
OAuth2Client,
|
|
27
|
+
OAuth2ProviderConfig,
|
|
28
|
+
TokenIssuer,
|
|
29
|
+
build_current_user_dependency,
|
|
30
|
+
build_oauth_router,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
provider = OAuth2ProviderConfig(
|
|
34
|
+
client_id="...",
|
|
35
|
+
client_secret="...",
|
|
36
|
+
base_url="https://provider.example.com",
|
|
37
|
+
redirect_uri="https://myapp.example.com/auth/callback",
|
|
38
|
+
scopes=("openid", "profile", "email"),
|
|
39
|
+
# authorize_path / token_path / userinfo_path default to the
|
|
40
|
+
# Django OAuth Toolkit-style "/o/authorize", "/o/token/",
|
|
41
|
+
# "/api/v1/account/me/" - override them if your provider differs
|
|
42
|
+
)
|
|
43
|
+
client = OAuth2Client(provider)
|
|
44
|
+
issuer = TokenIssuer(secret_key="change-me", expire_minutes=60 * 24)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_user(profile: dict) -> dict:
|
|
48
|
+
# Map the provider profile to local user claims. Look the user up (or
|
|
49
|
+
# create it) in your own storage here; mptauth doesn't dictate storage.
|
|
50
|
+
return {"sub": profile["email"], "name": profile.get("name")}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.include_router(build_oauth_router(client=client, issuer=issuer, resolve_user=resolve_user))
|
|
55
|
+
|
|
56
|
+
get_current_user = build_current_user_dependency(issuer)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/me")
|
|
60
|
+
def me(user: dict = Depends(get_current_user)):
|
|
61
|
+
return user
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This exposes:
|
|
65
|
+
|
|
66
|
+
- `GET /auth/login` - redirects the browser to the provider's authorize endpoint
|
|
67
|
+
- `GET /auth/callback` - exchanges the code, resolves the user, and returns `{"access_token", "token_type"}`
|
|
68
|
+
- `Depends(get_current_user)` - verifies the session JWT on protected routes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# mpt-fastapi-oauth
|
|
2
|
+
|
|
3
|
+
`mptauth` - generic OAuth2 (authorization-code) login for FastAPI.
|
|
4
|
+
|
|
5
|
+
Works against any spec-compliant OAuth2/OIDC provider (Google, GitHub,
|
|
6
|
+
Keycloak, Auth0, or a custom in-house IdP) by configuring its endpoints
|
|
7
|
+
explicitly. After the provider redirect completes, mptauth hands the user's
|
|
8
|
+
profile to a host-supplied `resolve_user` hook (so you control how/where users
|
|
9
|
+
are stored) and issues its own short-lived session JWT.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from fastapi import Depends, FastAPI
|
|
15
|
+
from mptauth import (
|
|
16
|
+
OAuth2Client,
|
|
17
|
+
OAuth2ProviderConfig,
|
|
18
|
+
TokenIssuer,
|
|
19
|
+
build_current_user_dependency,
|
|
20
|
+
build_oauth_router,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
provider = OAuth2ProviderConfig(
|
|
24
|
+
client_id="...",
|
|
25
|
+
client_secret="...",
|
|
26
|
+
base_url="https://provider.example.com",
|
|
27
|
+
redirect_uri="https://myapp.example.com/auth/callback",
|
|
28
|
+
scopes=("openid", "profile", "email"),
|
|
29
|
+
# authorize_path / token_path / userinfo_path default to the
|
|
30
|
+
# Django OAuth Toolkit-style "/o/authorize", "/o/token/",
|
|
31
|
+
# "/api/v1/account/me/" - override them if your provider differs
|
|
32
|
+
)
|
|
33
|
+
client = OAuth2Client(provider)
|
|
34
|
+
issuer = TokenIssuer(secret_key="change-me", expire_minutes=60 * 24)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_user(profile: dict) -> dict:
|
|
38
|
+
# Map the provider profile to local user claims. Look the user up (or
|
|
39
|
+
# create it) in your own storage here; mptauth doesn't dictate storage.
|
|
40
|
+
return {"sub": profile["email"], "name": profile.get("name")}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = FastAPI()
|
|
44
|
+
app.include_router(build_oauth_router(client=client, issuer=issuer, resolve_user=resolve_user))
|
|
45
|
+
|
|
46
|
+
get_current_user = build_current_user_dependency(issuer)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/me")
|
|
50
|
+
def me(user: dict = Depends(get_current_user)):
|
|
51
|
+
return user
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This exposes:
|
|
55
|
+
|
|
56
|
+
- `GET /auth/login` - redirects the browser to the provider's authorize endpoint
|
|
57
|
+
- `GET /auth/callback` - exchanges the code, resolves the user, and returns `{"access_token", "token_type"}`
|
|
58
|
+
- `Depends(get_current_user)` - verifies the session JWT on protected routes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mpt-fastapi-oauth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generic OAuth2 authorization-code login for FastAPI, with pluggable user storage and JWT sessions
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: fastapi>=0.110
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: python-jose[cryptography]>=3.3
|
|
10
|
+
|
|
11
|
+
# mpt-fastapi-oauth
|
|
12
|
+
|
|
13
|
+
`mptauth` - generic OAuth2 (authorization-code) login for FastAPI.
|
|
14
|
+
|
|
15
|
+
Works against any spec-compliant OAuth2/OIDC provider (Google, GitHub,
|
|
16
|
+
Keycloak, Auth0, or a custom in-house IdP) by configuring its endpoints
|
|
17
|
+
explicitly. After the provider redirect completes, mptauth hands the user's
|
|
18
|
+
profile to a host-supplied `resolve_user` hook (so you control how/where users
|
|
19
|
+
are stored) and issues its own short-lived session JWT.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from fastapi import Depends, FastAPI
|
|
25
|
+
from mptauth import (
|
|
26
|
+
OAuth2Client,
|
|
27
|
+
OAuth2ProviderConfig,
|
|
28
|
+
TokenIssuer,
|
|
29
|
+
build_current_user_dependency,
|
|
30
|
+
build_oauth_router,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
provider = OAuth2ProviderConfig(
|
|
34
|
+
client_id="...",
|
|
35
|
+
client_secret="...",
|
|
36
|
+
base_url="https://provider.example.com",
|
|
37
|
+
redirect_uri="https://myapp.example.com/auth/callback",
|
|
38
|
+
scopes=("openid", "profile", "email"),
|
|
39
|
+
# authorize_path / token_path / userinfo_path default to the
|
|
40
|
+
# Django OAuth Toolkit-style "/o/authorize", "/o/token/",
|
|
41
|
+
# "/api/v1/account/me/" - override them if your provider differs
|
|
42
|
+
)
|
|
43
|
+
client = OAuth2Client(provider)
|
|
44
|
+
issuer = TokenIssuer(secret_key="change-me", expire_minutes=60 * 24)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_user(profile: dict) -> dict:
|
|
48
|
+
# Map the provider profile to local user claims. Look the user up (or
|
|
49
|
+
# create it) in your own storage here; mptauth doesn't dictate storage.
|
|
50
|
+
return {"sub": profile["email"], "name": profile.get("name")}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.include_router(build_oauth_router(client=client, issuer=issuer, resolve_user=resolve_user))
|
|
55
|
+
|
|
56
|
+
get_current_user = build_current_user_dependency(issuer)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.get("/me")
|
|
60
|
+
def me(user: dict = Depends(get_current_user)):
|
|
61
|
+
return user
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This exposes:
|
|
65
|
+
|
|
66
|
+
- `GET /auth/login` - redirects the browser to the provider's authorize endpoint
|
|
67
|
+
- `GET /auth/callback` - exchanges the code, resolves the user, and returns `{"access_token", "token_type"}`
|
|
68
|
+
- `Depends(get_current_user)` - verifies the session JWT on protected routes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
mpt_fastapi_oauth.egg-info/PKG-INFO
|
|
4
|
+
mpt_fastapi_oauth.egg-info/SOURCES.txt
|
|
5
|
+
mpt_fastapi_oauth.egg-info/dependency_links.txt
|
|
6
|
+
mpt_fastapi_oauth.egg-info/requires.txt
|
|
7
|
+
mpt_fastapi_oauth.egg-info/top_level.txt
|
|
8
|
+
mptauth/__init__.py
|
|
9
|
+
mptauth/client.py
|
|
10
|
+
mptauth/config.py
|
|
11
|
+
mptauth/dependencies.py
|
|
12
|
+
mptauth/router.py
|
|
13
|
+
mptauth/tokens.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mptauth
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .client import OAuth2Client
|
|
2
|
+
from .config import OAuth2ProviderConfig
|
|
3
|
+
from .dependencies import build_current_user_dependency
|
|
4
|
+
from .router import UserResolver, build_oauth_router
|
|
5
|
+
from .tokens import TokenIssuer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"OAuth2Client",
|
|
9
|
+
"OAuth2ProviderConfig",
|
|
10
|
+
"TokenIssuer",
|
|
11
|
+
"UserResolver",
|
|
12
|
+
"build_current_user_dependency",
|
|
13
|
+
"build_oauth_router",
|
|
14
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .config import OAuth2ProviderConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OAuth2Client:
|
|
10
|
+
"""Minimal OAuth2 authorization-code flow client driven by `OAuth2ProviderConfig`."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, config: OAuth2ProviderConfig):
|
|
13
|
+
self.config = config
|
|
14
|
+
|
|
15
|
+
def build_authorize_url(self, state: str | None = None) -> tuple[str, str]:
|
|
16
|
+
"""Return `(authorize_url, state)`. Generates a random `state` if none is given."""
|
|
17
|
+
state = state or secrets.token_urlsafe(24)
|
|
18
|
+
params = {
|
|
19
|
+
"response_type": "code",
|
|
20
|
+
"client_id": self.config.client_id,
|
|
21
|
+
"redirect_uri": self.config.redirect_uri,
|
|
22
|
+
"scope": " ".join(self.config.scopes),
|
|
23
|
+
"state": state,
|
|
24
|
+
}
|
|
25
|
+
return f"{self.config.authorize_url}?{urlencode(params)}", state
|
|
26
|
+
|
|
27
|
+
async def fetch_token(self, code: str) -> dict:
|
|
28
|
+
"""Exchange an authorization `code` for a token response from the provider."""
|
|
29
|
+
async with httpx.AsyncClient(verify=self.config.verify_ssl) as http:
|
|
30
|
+
response = await http.post(
|
|
31
|
+
self.config.token_url,
|
|
32
|
+
data={
|
|
33
|
+
"grant_type": "authorization_code",
|
|
34
|
+
"code": code,
|
|
35
|
+
"redirect_uri": self.config.redirect_uri,
|
|
36
|
+
"client_id": self.config.client_id,
|
|
37
|
+
"client_secret": self.config.client_secret,
|
|
38
|
+
},
|
|
39
|
+
headers={"Accept": "application/json"},
|
|
40
|
+
)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
return response.json()
|
|
43
|
+
|
|
44
|
+
async def fetch_userinfo(self, access_token: str) -> dict:
|
|
45
|
+
"""Fetch the authenticated user's profile from the provider's userinfo endpoint."""
|
|
46
|
+
async with httpx.AsyncClient(verify=self.config.verify_ssl) as http:
|
|
47
|
+
response = await http.get(
|
|
48
|
+
self.config.userinfo_url,
|
|
49
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
50
|
+
)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
return response.json()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class OAuth2ProviderConfig:
|
|
7
|
+
"""Endpoints and credentials for an OAuth2 authorization-code provider.
|
|
8
|
+
|
|
9
|
+
Works with any spec-compliant provider (Google, GitHub, Keycloak, Auth0, a
|
|
10
|
+
custom in-house IdP, ...): the authorize/token/userinfo endpoints are
|
|
11
|
+
derived as `base_url + path`, so a host app only needs to supply the
|
|
12
|
+
provider's `base_url` and override the `*_path` fields when the provider
|
|
13
|
+
doesn't match the (Django OAuth Toolkit-style) defaults below.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
client_id: str
|
|
17
|
+
client_secret: str
|
|
18
|
+
base_url: str
|
|
19
|
+
redirect_uri: str
|
|
20
|
+
authorize_path: str = "/o/authorize"
|
|
21
|
+
token_path: str = "/o/token/"
|
|
22
|
+
userinfo_path: str = "/api/v1/account/me/"
|
|
23
|
+
scopes: Sequence[str] = field(default_factory=lambda: ("openid", "profile", "email"))
|
|
24
|
+
verify_ssl: bool = True
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def authorize_url(self) -> str:
|
|
28
|
+
return self.base_url.rstrip("/") + self.authorize_path
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def token_url(self) -> str:
|
|
32
|
+
return self.base_url.rstrip("/") + self.token_path
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def userinfo_url(self) -> str:
|
|
36
|
+
return self.base_url.rstrip("/") + self.userinfo_path
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Callable, Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, HTTPException, status
|
|
4
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
5
|
+
from jose import JWTError
|
|
6
|
+
|
|
7
|
+
from .tokens import TokenIssuer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_current_user_dependency(issuer: TokenIssuer) -> Callable[..., dict]:
|
|
11
|
+
"""Build a `Depends()`-able that decodes the bearer session token issued by `issuer`.
|
|
12
|
+
|
|
13
|
+
Returns the decoded JWT payload (whatever claims `resolve_user` placed on it
|
|
14
|
+
at login time, e.g. `sub`, `email`, `name`).
|
|
15
|
+
"""
|
|
16
|
+
bearer_scheme = HTTPBearer(auto_error=False)
|
|
17
|
+
|
|
18
|
+
def get_current_user(
|
|
19
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
|
|
20
|
+
) -> dict:
|
|
21
|
+
unauthorized = HTTPException(
|
|
22
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
23
|
+
detail="Could not validate credentials",
|
|
24
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
25
|
+
)
|
|
26
|
+
if credentials is None:
|
|
27
|
+
raise unauthorized
|
|
28
|
+
try:
|
|
29
|
+
return issuer.decode_access_token(credentials.credentials)
|
|
30
|
+
except JWTError:
|
|
31
|
+
raise unauthorized
|
|
32
|
+
|
|
33
|
+
return get_current_user
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import secrets
|
|
3
|
+
from typing import Awaitable, Callable, Optional, Union
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
6
|
+
from fastapi.responses import JSONResponse, RedirectResponse
|
|
7
|
+
|
|
8
|
+
from .client import OAuth2Client
|
|
9
|
+
from .tokens import TokenIssuer
|
|
10
|
+
|
|
11
|
+
UserResolver = Callable[[dict], Union[dict, Awaitable[dict]]]
|
|
12
|
+
"""Maps a provider userinfo profile to the local user claims stored in the session JWT.
|
|
13
|
+
|
|
14
|
+
The returned dict must contain a `sub` claim identifying the user. Use this hook
|
|
15
|
+
to look up or create a local user record from the provider's profile - mptauth
|
|
16
|
+
does not dictate how/where users are stored.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
_STATE_COOKIE = "mptauth_state"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_oauth_router(
|
|
23
|
+
*,
|
|
24
|
+
client: OAuth2Client,
|
|
25
|
+
issuer: TokenIssuer,
|
|
26
|
+
resolve_user: UserResolver,
|
|
27
|
+
prefix: str = "/auth",
|
|
28
|
+
success_redirect: Optional[str] = None,
|
|
29
|
+
) -> APIRouter:
|
|
30
|
+
"""Build login/callback routes implementing the OAuth2 authorization-code flow.
|
|
31
|
+
|
|
32
|
+
`GET {prefix}/login` redirects the browser to the provider's authorize endpoint.
|
|
33
|
+
`GET {prefix}/callback` exchanges the returned code, resolves the local user via
|
|
34
|
+
`resolve_user`, and issues a session JWT via `issuer`.
|
|
35
|
+
|
|
36
|
+
If `success_redirect` is set, the callback redirects there with `?token=...`;
|
|
37
|
+
otherwise it returns `{"access_token": ..., "token_type": "bearer"}` as JSON.
|
|
38
|
+
"""
|
|
39
|
+
router = APIRouter(prefix=prefix, tags=["auth"])
|
|
40
|
+
|
|
41
|
+
@router.get("/login")
|
|
42
|
+
def login() -> RedirectResponse:
|
|
43
|
+
url, state = client.build_authorize_url()
|
|
44
|
+
response = RedirectResponse(url)
|
|
45
|
+
response.set_cookie(_STATE_COOKIE, state, httponly=True, max_age=600, samesite="lax")
|
|
46
|
+
return response
|
|
47
|
+
|
|
48
|
+
@router.get("/callback")
|
|
49
|
+
async def callback(request: Request, code: str, state: str):
|
|
50
|
+
expected_state = request.cookies.get(_STATE_COOKIE)
|
|
51
|
+
if not expected_state or not secrets.compare_digest(state, expected_state):
|
|
52
|
+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid OAuth state")
|
|
53
|
+
|
|
54
|
+
token = await client.fetch_token(code)
|
|
55
|
+
provider_access_token = token.get("access_token")
|
|
56
|
+
if not provider_access_token:
|
|
57
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Provider did not return an access token")
|
|
58
|
+
|
|
59
|
+
profile = await client.fetch_userinfo(provider_access_token)
|
|
60
|
+
|
|
61
|
+
user = resolve_user(profile)
|
|
62
|
+
if inspect.isawaitable(user):
|
|
63
|
+
user = await user
|
|
64
|
+
if "sub" not in user:
|
|
65
|
+
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "resolve_user must return a 'sub' claim")
|
|
66
|
+
|
|
67
|
+
session_token = issuer.create_access_token(user)
|
|
68
|
+
|
|
69
|
+
if success_redirect:
|
|
70
|
+
response = RedirectResponse(f"{success_redirect}?token={session_token}")
|
|
71
|
+
else:
|
|
72
|
+
response = JSONResponse({"access_token": session_token, "token_type": "bearer"})
|
|
73
|
+
response.delete_cookie(_STATE_COOKIE)
|
|
74
|
+
return response
|
|
75
|
+
|
|
76
|
+
return router
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from jose import jwt
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenIssuer:
|
|
8
|
+
"""Issues and verifies the host application's own signed session JWTs.
|
|
9
|
+
|
|
10
|
+
Kept separate from the OAuth2 provider's tokens: once a user is
|
|
11
|
+
authenticated via the provider, the app should rely on its own short-lived
|
|
12
|
+
session token rather than calling the provider on every request.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, secret_key: str, algorithm: str = "HS256", expire_minutes: int = 60 * 24):
|
|
16
|
+
self.secret_key = secret_key
|
|
17
|
+
self.algorithm = algorithm
|
|
18
|
+
self.expire_minutes = expire_minutes
|
|
19
|
+
|
|
20
|
+
def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
|
21
|
+
payload = data.copy()
|
|
22
|
+
payload["exp"] = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=self.expire_minutes))
|
|
23
|
+
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
|
24
|
+
|
|
25
|
+
def decode_access_token(self, token: str) -> dict:
|
|
26
|
+
return jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mpt-fastapi-oauth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generic OAuth2 authorization-code login for FastAPI, with pluggable user storage and JWT sessions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.110",
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"python-jose[cryptography]>=3.3",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["setuptools>=68"]
|
|
15
|
+
build-backend = "setuptools.build_meta"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["mptauth*"]
|