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.
@@ -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,3 @@
1
+ fastapi>=0.110
2
+ httpx>=0.27
3
+ python-jose[cryptography]>=3.3
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+