flyfun-common 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.
- flyfun_common-0.1.0/.gitignore +8 -0
- flyfun_common-0.1.0/LICENSE +21 -0
- flyfun_common-0.1.0/PKG-INFO +80 -0
- flyfun_common-0.1.0/README.md +49 -0
- flyfun_common-0.1.0/pyproject.toml +46 -0
- flyfun_common-0.1.0/src/flyfun_common/__init__.py +1 -0
- flyfun_common-0.1.0/src/flyfun_common/admin.py +92 -0
- flyfun_common-0.1.0/src/flyfun_common/auth/__init__.py +26 -0
- flyfun_common-0.1.0/src/flyfun_common/auth/config.py +120 -0
- flyfun_common-0.1.0/src/flyfun_common/auth/jwt_utils.py +28 -0
- flyfun_common-0.1.0/src/flyfun_common/auth/router.py +372 -0
- flyfun_common-0.1.0/src/flyfun_common/costs.py +79 -0
- flyfun_common-0.1.0/src/flyfun_common/credentials.py +46 -0
- flyfun_common-0.1.0/src/flyfun_common/db/__init__.py +44 -0
- flyfun_common-0.1.0/src/flyfun_common/db/deps.py +127 -0
- flyfun_common-0.1.0/src/flyfun_common/db/engine.py +100 -0
- flyfun_common-0.1.0/src/flyfun_common/db/models.py +83 -0
- flyfun_common-0.1.0/src/flyfun_common/encryption.py +43 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Brice Rosenzweig
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyfun-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared user management and auth for flyfun services
|
|
5
|
+
Project-URL: Homepage, https://flyfun.aero
|
|
6
|
+
Project-URL: Repository, https://github.com/roznet/flyfun-common
|
|
7
|
+
Author-email: Brice Rosenzweig <brice@ro-z.net>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: auth,fastapi,flyfun,oauth
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: authlib>=1.3
|
|
21
|
+
Requires-Dist: cryptography>=42.0
|
|
22
|
+
Requires-Dist: fastapi>=0.109
|
|
23
|
+
Requires-Dist: httpx
|
|
24
|
+
Requires-Dist: pyjwt>=2.8
|
|
25
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# flyfun-common
|
|
33
|
+
|
|
34
|
+
Shared user management and authentication library for [flyfun](https://flyfun.aero) services.
|
|
35
|
+
|
|
36
|
+
Provides OAuth login (Google, Apple), JWT session management, user database models, and API token administration — all as reusable FastAPI components.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install flyfun-common
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from fastapi import FastAPI
|
|
48
|
+
from flyfun_common.auth import create_auth_router
|
|
49
|
+
from flyfun_common.db import init_db
|
|
50
|
+
|
|
51
|
+
app = FastAPI()
|
|
52
|
+
|
|
53
|
+
# Initialize database
|
|
54
|
+
init_db()
|
|
55
|
+
|
|
56
|
+
# Mount the auth router
|
|
57
|
+
app.include_router(create_auth_router())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
All configuration is via environment variables:
|
|
63
|
+
|
|
64
|
+
| Variable | Required | Description |
|
|
65
|
+
|----------|----------|-------------|
|
|
66
|
+
| `JWT_SECRET` | Production | Secret key for signing JWT tokens |
|
|
67
|
+
| `DATABASE_URL` | No | SQLAlchemy database URL (defaults to local SQLite) |
|
|
68
|
+
| `ENVIRONMENT` | No | `production` or `development` (default) |
|
|
69
|
+
| `COOKIE_DOMAIN` | No | Cookie domain for cross-subdomain SSO |
|
|
70
|
+
| `GOOGLE_CLIENT_ID` | No | Google OAuth client ID |
|
|
71
|
+
| `GOOGLE_CLIENT_SECRET` | No | Google OAuth client secret |
|
|
72
|
+
| `APPLE_CLIENT_ID` | No | Apple Sign In service ID |
|
|
73
|
+
| `APPLE_TEAM_ID` | No | Apple Developer Team ID |
|
|
74
|
+
| `APPLE_KEY_ID` | No | Apple Sign In key ID |
|
|
75
|
+
| `APPLE_PRIVATE_KEY` | No | Apple Sign In private key (PEM) |
|
|
76
|
+
| `CREDENTIAL_ENCRYPTION_KEY` | Production | Fernet key for encrypting stored credentials |
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# flyfun-common
|
|
2
|
+
|
|
3
|
+
Shared user management and authentication library for [flyfun](https://flyfun.aero) services.
|
|
4
|
+
|
|
5
|
+
Provides OAuth login (Google, Apple), JWT session management, user database models, and API token administration — all as reusable FastAPI components.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install flyfun-common
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from flyfun_common.auth import create_auth_router
|
|
18
|
+
from flyfun_common.db import init_db
|
|
19
|
+
|
|
20
|
+
app = FastAPI()
|
|
21
|
+
|
|
22
|
+
# Initialize database
|
|
23
|
+
init_db()
|
|
24
|
+
|
|
25
|
+
# Mount the auth router
|
|
26
|
+
app.include_router(create_auth_router())
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
All configuration is via environment variables:
|
|
32
|
+
|
|
33
|
+
| Variable | Required | Description |
|
|
34
|
+
|----------|----------|-------------|
|
|
35
|
+
| `JWT_SECRET` | Production | Secret key for signing JWT tokens |
|
|
36
|
+
| `DATABASE_URL` | No | SQLAlchemy database URL (defaults to local SQLite) |
|
|
37
|
+
| `ENVIRONMENT` | No | `production` or `development` (default) |
|
|
38
|
+
| `COOKIE_DOMAIN` | No | Cookie domain for cross-subdomain SSO |
|
|
39
|
+
| `GOOGLE_CLIENT_ID` | No | Google OAuth client ID |
|
|
40
|
+
| `GOOGLE_CLIENT_SECRET` | No | Google OAuth client secret |
|
|
41
|
+
| `APPLE_CLIENT_ID` | No | Apple Sign In service ID |
|
|
42
|
+
| `APPLE_TEAM_ID` | No | Apple Developer Team ID |
|
|
43
|
+
| `APPLE_KEY_ID` | No | Apple Sign In key ID |
|
|
44
|
+
| `APPLE_PRIVATE_KEY` | No | Apple Sign In private key (PEM) |
|
|
45
|
+
| `CREDENTIAL_ENCRYPTION_KEY` | Production | Fernet key for encrypting stored credentials |
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flyfun-common"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared user management and auth for flyfun services"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Brice Rosenzweig", email = "brice@ro-z.net" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["flyfun", "auth", "oauth", "fastapi"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Framework :: FastAPI",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"fastapi>=0.109",
|
|
28
|
+
"sqlalchemy>=2.0",
|
|
29
|
+
"PyJWT>=2.8",
|
|
30
|
+
"authlib>=1.3",
|
|
31
|
+
"httpx",
|
|
32
|
+
"cryptography>=42.0",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest", "pytest-asyncio", "httpx"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://flyfun.aero"
|
|
40
|
+
Repository = "https://github.com/roznet/flyfun-common"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
exclude = ["tests/", "designs/", ".pytest_cache/"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
exclude = ["tests/", "designs/"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared user management and auth for flyfun services."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Admin helper utilities: token generation, HMAC verification, user management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac as hmac_mod
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from base64 import urlsafe_b64encode
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from flyfun_common.db.models import ApiTokenRow, UserPreferencesRow, UserRow
|
|
16
|
+
|
|
17
|
+
TOKEN_PREFIX = "ff_"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_api_token(prefix: str = TOKEN_PREFIX) -> str:
|
|
21
|
+
"""Generate a random API token with the given prefix (~48 chars total)."""
|
|
22
|
+
return prefix + urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hash_token(token: str) -> str:
|
|
26
|
+
"""SHA-256 hash a plaintext token for storage."""
|
|
27
|
+
return hashlib.sha256(token.encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_approval_hmac(
|
|
31
|
+
user_id: str, ts: str, sig: str, secret: str, expiry: int
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Verify an HMAC-signed approval link. Raises HTTPException on failure."""
|
|
34
|
+
expected = hmac_mod.new(
|
|
35
|
+
secret.encode(), f"approve:{user_id}:{ts}".encode(), hashlib.sha256
|
|
36
|
+
).hexdigest()
|
|
37
|
+
|
|
38
|
+
if not hmac_mod.compare_digest(sig, expected):
|
|
39
|
+
raise HTTPException(status_code=403, detail="Invalid approval link")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
link_time = int(ts)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise HTTPException(status_code=400, detail="Invalid timestamp")
|
|
45
|
+
|
|
46
|
+
age = time.time() - link_time
|
|
47
|
+
if age > expiry:
|
|
48
|
+
raise HTTPException(status_code=410, detail="Approval link expired")
|
|
49
|
+
if age < 0:
|
|
50
|
+
raise HTTPException(status_code=400, detail="Invalid timestamp")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_agent_user(
|
|
54
|
+
db: Session, name: str, prefix: str = TOKEN_PREFIX
|
|
55
|
+
) -> tuple[UserRow, str]:
|
|
56
|
+
"""Create a bot/agent user with an initial API token.
|
|
57
|
+
|
|
58
|
+
Returns (user, plaintext_token). The plaintext token cannot be retrieved later.
|
|
59
|
+
"""
|
|
60
|
+
user_id = f"agent-{uuid.uuid4().hex[:12]}"
|
|
61
|
+
user = UserRow(
|
|
62
|
+
id=user_id,
|
|
63
|
+
provider="api_token",
|
|
64
|
+
provider_sub=uuid.uuid4().hex,
|
|
65
|
+
email="",
|
|
66
|
+
display_name=name,
|
|
67
|
+
approved=True,
|
|
68
|
+
)
|
|
69
|
+
db.add(user)
|
|
70
|
+
db.flush()
|
|
71
|
+
db.add(UserPreferencesRow(user_id=user_id))
|
|
72
|
+
|
|
73
|
+
plaintext = generate_api_token(prefix)
|
|
74
|
+
db.add(
|
|
75
|
+
ApiTokenRow(
|
|
76
|
+
user_id=user_id,
|
|
77
|
+
token_hash=hash_token(plaintext),
|
|
78
|
+
name=name,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
db.flush()
|
|
82
|
+
return user, plaintext
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def approve_user(db: Session, user_id: str) -> UserRow:
|
|
86
|
+
"""Approve a user account. Raises HTTPException if not found."""
|
|
87
|
+
user = db.query(UserRow).filter(UserRow.id == user_id).first()
|
|
88
|
+
if not user:
|
|
89
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
90
|
+
user.approved = True
|
|
91
|
+
db.flush()
|
|
92
|
+
return user
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Auth utilities: JWT, OAuth, config."""
|
|
2
|
+
|
|
3
|
+
from flyfun_common.auth.config import (
|
|
4
|
+
COOKIE_NAME,
|
|
5
|
+
COOKIE_DOMAIN,
|
|
6
|
+
SUPPORTED_PROVIDERS,
|
|
7
|
+
is_dev_mode,
|
|
8
|
+
get_jwt_secret,
|
|
9
|
+
get_registered_providers,
|
|
10
|
+
create_oauth,
|
|
11
|
+
)
|
|
12
|
+
from flyfun_common.auth.jwt_utils import create_token, decode_token
|
|
13
|
+
from flyfun_common.auth.router import create_auth_router
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"COOKIE_NAME",
|
|
17
|
+
"COOKIE_DOMAIN",
|
|
18
|
+
"SUPPORTED_PROVIDERS",
|
|
19
|
+
"is_dev_mode",
|
|
20
|
+
"get_jwt_secret",
|
|
21
|
+
"get_registered_providers",
|
|
22
|
+
"create_oauth",
|
|
23
|
+
"create_token",
|
|
24
|
+
"decode_token",
|
|
25
|
+
"create_auth_router",
|
|
26
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Shared auth configuration: cookie, JWT secret, OAuth providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from authlib.integrations.starlette_client import OAuth
|
|
8
|
+
|
|
9
|
+
# Unified cookie name — same across all flyfun services
|
|
10
|
+
COOKIE_NAME = "flyfun_auth"
|
|
11
|
+
|
|
12
|
+
# Cookie domain — set to .flyfun.aero in prod for cross-subdomain SSO
|
|
13
|
+
COOKIE_DOMAIN: str | None = None # computed at runtime
|
|
14
|
+
|
|
15
|
+
_DEV_JWT_SECRET = "dev-insecure-jwt-secret-do-not-use-in-production"
|
|
16
|
+
|
|
17
|
+
# Providers that can be registered
|
|
18
|
+
SUPPORTED_PROVIDERS = ("google", "apple")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_dev_mode() -> bool:
|
|
22
|
+
return os.environ.get("ENVIRONMENT", "development") != "production"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_cookie_domain() -> str | None:
|
|
26
|
+
"""Return .flyfun.aero in production (enables SSO), None in dev."""
|
|
27
|
+
if is_dev_mode():
|
|
28
|
+
return None
|
|
29
|
+
return os.environ.get("COOKIE_DOMAIN", ".flyfun.aero")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_jwt_secret() -> str:
|
|
33
|
+
secret = os.environ.get("JWT_SECRET")
|
|
34
|
+
if secret:
|
|
35
|
+
if not is_dev_mode() and secret == _DEV_JWT_SECRET:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"Production must use a unique JWT_SECRET, not the dev default"
|
|
38
|
+
)
|
|
39
|
+
return secret
|
|
40
|
+
if is_dev_mode():
|
|
41
|
+
return _DEV_JWT_SECRET
|
|
42
|
+
raise ValueError("JWT_SECRET environment variable must be set in production")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _apple_client_secret() -> str:
|
|
46
|
+
"""Generate a short-lived JWT client_secret for Sign in with Apple.
|
|
47
|
+
|
|
48
|
+
Apple requires the client_secret to be an ES256-signed JWT containing:
|
|
49
|
+
- iss: Team ID
|
|
50
|
+
- sub: Client ID (Service ID)
|
|
51
|
+
- aud: https://appleid.apple.com
|
|
52
|
+
- iat/exp: issued/expiry (max 180 days)
|
|
53
|
+
|
|
54
|
+
Signed with the private key from Apple Developer Console.
|
|
55
|
+
"""
|
|
56
|
+
import time
|
|
57
|
+
|
|
58
|
+
import jwt # PyJWT
|
|
59
|
+
|
|
60
|
+
team_id = os.environ.get("APPLE_TEAM_ID", "")
|
|
61
|
+
key_id = os.environ.get("APPLE_KEY_ID", "")
|
|
62
|
+
client_id = os.environ.get("APPLE_CLIENT_ID", "")
|
|
63
|
+
private_key = os.environ.get("APPLE_PRIVATE_KEY", "")
|
|
64
|
+
|
|
65
|
+
if not all([team_id, key_id, client_id, private_key]):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"Apple Sign In requires APPLE_TEAM_ID, APPLE_KEY_ID, "
|
|
68
|
+
"APPLE_CLIENT_ID, and APPLE_PRIVATE_KEY environment variables"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Replace literal \n with actual newlines (env vars can't contain real newlines)
|
|
72
|
+
private_key = private_key.replace("\\n", "\n")
|
|
73
|
+
|
|
74
|
+
now = int(time.time())
|
|
75
|
+
payload = {
|
|
76
|
+
"iss": team_id,
|
|
77
|
+
"sub": client_id,
|
|
78
|
+
"aud": "https://appleid.apple.com",
|
|
79
|
+
"iat": now,
|
|
80
|
+
"exp": now + 86400 * 180, # 180 days (Apple's max)
|
|
81
|
+
}
|
|
82
|
+
return jwt.encode(payload, private_key, algorithm="ES256", headers={"kid": key_id})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_oauth() -> OAuth:
|
|
86
|
+
"""Create OAuth registry with available providers.
|
|
87
|
+
|
|
88
|
+
Providers are registered only if their client_id env var is set.
|
|
89
|
+
"""
|
|
90
|
+
oauth = OAuth()
|
|
91
|
+
|
|
92
|
+
# Google
|
|
93
|
+
if os.environ.get("GOOGLE_CLIENT_ID"):
|
|
94
|
+
oauth.register(
|
|
95
|
+
name="google",
|
|
96
|
+
client_id=os.environ.get("GOOGLE_CLIENT_ID"),
|
|
97
|
+
client_secret=os.environ.get("GOOGLE_CLIENT_SECRET", ""),
|
|
98
|
+
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
99
|
+
client_kwargs={"scope": "openid email profile"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Apple — Sign in with Apple (OIDC)
|
|
103
|
+
if os.environ.get("APPLE_CLIENT_ID"):
|
|
104
|
+
oauth.register(
|
|
105
|
+
name="apple",
|
|
106
|
+
client_id=os.environ.get("APPLE_CLIENT_ID"),
|
|
107
|
+
client_secret=_apple_client_secret(),
|
|
108
|
+
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
|
109
|
+
client_kwargs={
|
|
110
|
+
"scope": "openid email name",
|
|
111
|
+
"response_mode": "form_post",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return oauth
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_registered_providers(oauth: OAuth) -> list[str]:
|
|
119
|
+
"""Return the list of provider names that were registered."""
|
|
120
|
+
return [p for p in SUPPORTED_PROVIDERS if hasattr(oauth, p)]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""JWT token creation and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
JWT_ALGORITHM = "HS256"
|
|
10
|
+
JWT_EXPIRY_DAYS = 7
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_token(user_id: str, email: str, name: str, secret: str) -> str:
|
|
14
|
+
"""Create a signed JWT with user claims and 7-day expiry."""
|
|
15
|
+
now = datetime.now(timezone.utc)
|
|
16
|
+
payload = {
|
|
17
|
+
"sub": user_id,
|
|
18
|
+
"email": email,
|
|
19
|
+
"name": name,
|
|
20
|
+
"iat": now,
|
|
21
|
+
"exp": now + timedelta(days=JWT_EXPIRY_DAYS),
|
|
22
|
+
}
|
|
23
|
+
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def decode_token(token: str, secret: str) -> dict:
|
|
27
|
+
"""Decode and validate a JWT."""
|
|
28
|
+
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
|