lambda-erp 0.1.31__tar.gz → 0.1.33__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.
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/PKG-INFO +1 -1
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/auth.py +3 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/main.py +2 -0
- lambda_erp-0.1.33/api/oauth.py +481 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/frontend/src/api/client.ts +19 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/database.py +17 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/pyproject.toml +1 -1
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/.gitignore +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/LICENSE +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/README.md +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/attachments.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/chat.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/deps.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/errors.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/pdf.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/providers.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/remarks_md.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/services.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/templates/document.html +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/frontend/README.md +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.31 → lambda_erp-0.1.33}/terraform/README.md +0 -0
|
@@ -260,11 +260,14 @@ def auth_setup_status():
|
|
|
260
260
|
count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
|
|
261
261
|
first_run = count == 0
|
|
262
262
|
public_signup = _setting_enabled(db, "allow_public_signup")
|
|
263
|
+
# Lazy import avoids a circular import (api.oauth imports from api.auth).
|
|
264
|
+
from api.oauth import configured_providers
|
|
263
265
|
return {
|
|
264
266
|
"has_users": count > 0,
|
|
265
267
|
"first_run": first_run,
|
|
266
268
|
"public_signup": public_signup,
|
|
267
269
|
"registration_open": first_run or public_signup,
|
|
270
|
+
"oauth_providers": configured_providers(),
|
|
268
271
|
}
|
|
269
272
|
|
|
270
273
|
|
|
@@ -15,6 +15,7 @@ from lambda_erp.database import setup
|
|
|
15
15
|
|
|
16
16
|
from api.errors import register_exception_handlers
|
|
17
17
|
from api.auth import router as auth_router, COOKIE_NAME, decode_token
|
|
18
|
+
from api.oauth import router as oauth_router
|
|
18
19
|
from api.attachments import router as attachments_router
|
|
19
20
|
from api.chat import chat_websocket, router as chat_router
|
|
20
21
|
from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals
|
|
@@ -87,6 +88,7 @@ register_exception_handlers(app)
|
|
|
87
88
|
|
|
88
89
|
# Routers
|
|
89
90
|
app.include_router(auth_router, prefix="/api")
|
|
91
|
+
app.include_router(oauth_router, prefix="/api")
|
|
90
92
|
app.include_router(attachments_router, prefix="/api")
|
|
91
93
|
app.include_router(documents.router, prefix="/api")
|
|
92
94
|
app.include_router(proposals.router, prefix="/api")
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Social login (Google / Apple) for Lambda ERP.
|
|
2
|
+
|
|
3
|
+
OAuth/OIDC is layered on top of the existing password auth without changing what
|
|
4
|
+
a session *is*: once a provider has proven the user's identity, we mint the very
|
|
5
|
+
same `lambda_erp_token` JWT cookie that the password flow issues (see
|
|
6
|
+
`api/auth.py`). Everything downstream — `get_current_user`, roles, the frontend
|
|
7
|
+
auth context — is untouched, and the password flow keeps working unchanged.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
- Providers are OIDC. We read each provider's discovery document for its
|
|
11
|
+
authorization/token/JWKS endpoints, so the same code path serves Google and
|
|
12
|
+
Apple. Only Apple's client-secret differs (a short-lived ES256 JWT signed with
|
|
13
|
+
the developer key, built in `_client_secret`).
|
|
14
|
+
- No extra dependency: httpx (already a dep) does discovery + token exchange,
|
|
15
|
+
python-jose (already a dep) validates the ID token against the provider JWKS
|
|
16
|
+
and signs Apple's client secret.
|
|
17
|
+
- CSRF / flow state travels in the OAuth `state` parameter as a JWT signed with
|
|
18
|
+
our own secret and a 10-minute expiry (carrying mode/uid/invite/nonce). We do
|
|
19
|
+
not use a state cookie: Apple posts the callback cross-site (form_post), which
|
|
20
|
+
a SameSite=Lax cookie would drop, and a signed+short-lived state avoids that
|
|
21
|
+
minefield while still being unforgeable. The OIDC `nonce` is echoed in the ID
|
|
22
|
+
token and checked, binding the token to this flow.
|
|
23
|
+
- A provider is simply *disabled* (its buttons hidden) whenever its env vars are
|
|
24
|
+
absent, so local/dev runs need no OAuth setup.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
import secrets as _secrets
|
|
32
|
+
|
|
33
|
+
import httpx
|
|
34
|
+
from jose import jwt, JWTError
|
|
35
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
36
|
+
from fastapi.responses import RedirectResponse
|
|
37
|
+
|
|
38
|
+
from lambda_erp.database import get_db
|
|
39
|
+
from lambda_erp.utils import now
|
|
40
|
+
from api.auth import (
|
|
41
|
+
SECRET_KEY,
|
|
42
|
+
ALGORITHM,
|
|
43
|
+
create_access_token,
|
|
44
|
+
set_auth_cookie,
|
|
45
|
+
get_current_user,
|
|
46
|
+
validate_assignable_role,
|
|
47
|
+
_setting_enabled,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
51
|
+
|
|
52
|
+
# A non-matchable password placeholder for OAuth-created users. It does not start
|
|
53
|
+
# with the bcrypt prefix, so `verify_password` can never accept it — the User row
|
|
54
|
+
# simply has no usable password until/unless one is set. Keeps `hashed_password`
|
|
55
|
+
# NOT NULL without a table-rebuild migration.
|
|
56
|
+
OAUTH_PASSWORD_SENTINEL = "oauth$no-password"
|
|
57
|
+
|
|
58
|
+
STATE_TTL_SECONDS = 600
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Provider registry
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
PROVIDERS = {
|
|
65
|
+
"google": {
|
|
66
|
+
"label": "Google",
|
|
67
|
+
"issuer": "https://accounts.google.com",
|
|
68
|
+
"discovery": "https://accounts.google.com/.well-known/openid-configuration",
|
|
69
|
+
"scope": "openid email profile",
|
|
70
|
+
"response_mode": None, # default `query` (GET callback)
|
|
71
|
+
},
|
|
72
|
+
"apple": {
|
|
73
|
+
"label": "Apple",
|
|
74
|
+
"issuer": "https://appleid.apple.com",
|
|
75
|
+
"discovery": "https://appleid.apple.com/.well-known/openid-configuration",
|
|
76
|
+
# Requesting name/email requires Apple's form_post response mode, which
|
|
77
|
+
# POSTs the callback back to us.
|
|
78
|
+
"scope": "openid email name",
|
|
79
|
+
"response_mode": "form_post",
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _client_id(provider: str) -> str | None:
|
|
85
|
+
if provider == "google":
|
|
86
|
+
return os.environ.get("GOOGLE_OAUTH_CLIENT_ID")
|
|
87
|
+
if provider == "apple":
|
|
88
|
+
return os.environ.get("APPLE_OAUTH_CLIENT_ID")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _client_secret(provider: str) -> str | None:
|
|
93
|
+
"""The client_secret to present at the token endpoint.
|
|
94
|
+
|
|
95
|
+
Google: a static string from env. Apple: there is no static secret — it is a
|
|
96
|
+
JWT we sign with the Sign-in-with-Apple key (ES256, <=6-month expiry), built
|
|
97
|
+
fresh on each exchange so nothing needs manual rotation.
|
|
98
|
+
"""
|
|
99
|
+
if provider == "google":
|
|
100
|
+
return os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")
|
|
101
|
+
if provider == "apple":
|
|
102
|
+
team_id = os.environ.get("APPLE_TEAM_ID")
|
|
103
|
+
key_id = os.environ.get("APPLE_KEY_ID")
|
|
104
|
+
private_key = os.environ.get("APPLE_PRIVATE_KEY")
|
|
105
|
+
client_id = os.environ.get("APPLE_OAUTH_CLIENT_ID")
|
|
106
|
+
if not (team_id and key_id and private_key and client_id):
|
|
107
|
+
return None
|
|
108
|
+
issued = int(time.time())
|
|
109
|
+
claims = {
|
|
110
|
+
"iss": team_id,
|
|
111
|
+
"iat": issued,
|
|
112
|
+
"exp": issued + 15777000, # ~6 months, Apple's max
|
|
113
|
+
"aud": "https://appleid.apple.com",
|
|
114
|
+
"sub": client_id,
|
|
115
|
+
}
|
|
116
|
+
return jwt.encode(claims, private_key, algorithm="ES256", headers={"kid": key_id})
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_configured(provider: str) -> bool:
|
|
121
|
+
return bool(_client_id(provider) and _client_secret(provider))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def configured_providers() -> list[str]:
|
|
125
|
+
"""Providers that have complete credentials — drives which buttons show."""
|
|
126
|
+
return [p for p in PROVIDERS if is_configured(p)]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# OIDC discovery + JWKS (cached in-process)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
_discovery_cache: dict[str, dict] = {}
|
|
134
|
+
_jwks_cache: dict[str, dict] = {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _discovery(provider: str) -> dict:
|
|
138
|
+
if provider not in _discovery_cache:
|
|
139
|
+
url = PROVIDERS[provider]["discovery"]
|
|
140
|
+
resp = httpx.get(url, timeout=10)
|
|
141
|
+
resp.raise_for_status()
|
|
142
|
+
_discovery_cache[provider] = resp.json()
|
|
143
|
+
return _discovery_cache[provider]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _jwks(provider: str) -> dict:
|
|
147
|
+
if provider not in _jwks_cache:
|
|
148
|
+
url = _discovery(provider)["jwks_uri"]
|
|
149
|
+
resp = httpx.get(url, timeout=10)
|
|
150
|
+
resp.raise_for_status()
|
|
151
|
+
_jwks_cache[provider] = resp.json()
|
|
152
|
+
return _jwks_cache[provider]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _jwk_for(provider: str, kid: str) -> dict:
|
|
156
|
+
for _ in range(2):
|
|
157
|
+
for key in _jwks(provider).get("keys", []):
|
|
158
|
+
if key.get("kid") == kid:
|
|
159
|
+
return key
|
|
160
|
+
# Unknown kid — provider may have rotated keys; drop cache and retry once.
|
|
161
|
+
_jwks_cache.pop(provider, None)
|
|
162
|
+
raise HTTPException(status_code=502, detail="Unknown signing key from identity provider")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Redirect URI + flow state
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _redirect_uri(request: Request, provider: str) -> str:
|
|
171
|
+
"""The registered callback URL. Must match byte-for-byte between the auth
|
|
172
|
+
request and the token exchange, and match what's registered with the
|
|
173
|
+
provider. Prefer an explicit base (OAUTH_REDIRECT_BASE, e.g.
|
|
174
|
+
https://erp.lambda.dev) so proxies can't perturb it; else derive it from the
|
|
175
|
+
request, honoring the TLS-terminating proxy's forwarded headers."""
|
|
176
|
+
base = os.environ.get("OAUTH_REDIRECT_BASE")
|
|
177
|
+
if not base:
|
|
178
|
+
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
|
179
|
+
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
|
180
|
+
base = f"{proto}://{host}"
|
|
181
|
+
return f"{base.rstrip('/')}/api/auth/{provider}/callback"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _encode_state(**claims) -> str:
|
|
185
|
+
claims = {**claims, "exp": int(time.time()) + STATE_TTL_SECONDS}
|
|
186
|
+
return jwt.encode(claims, SECRET_KEY, algorithm=ALGORITHM)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _decode_state(state: str) -> dict:
|
|
190
|
+
try:
|
|
191
|
+
return jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
|
|
192
|
+
except JWTError:
|
|
193
|
+
raise HTTPException(status_code=400, detail="Invalid or expired sign-in state")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# ID-token verification
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _verify_id_token(provider: str, id_token: str, nonce: str) -> dict:
|
|
202
|
+
try:
|
|
203
|
+
header = jwt.get_unverified_header(id_token)
|
|
204
|
+
except JWTError:
|
|
205
|
+
raise HTTPException(status_code=400, detail="Malformed identity token")
|
|
206
|
+
key = _jwk_for(provider, header.get("kid"))
|
|
207
|
+
try:
|
|
208
|
+
claims = jwt.decode(
|
|
209
|
+
id_token,
|
|
210
|
+
key,
|
|
211
|
+
algorithms=[header.get("alg", "RS256")],
|
|
212
|
+
audience=_client_id(provider),
|
|
213
|
+
issuer=PROVIDERS[provider]["issuer"],
|
|
214
|
+
# Apple omits at_hash; we don't pass the access token, so disable it.
|
|
215
|
+
options={"verify_at_hash": False},
|
|
216
|
+
)
|
|
217
|
+
except JWTError as exc:
|
|
218
|
+
raise HTTPException(status_code=400, detail=f"Identity token rejected: {exc}")
|
|
219
|
+
if nonce and claims.get("nonce") != nonce:
|
|
220
|
+
raise HTTPException(status_code=400, detail="Sign-in nonce mismatch")
|
|
221
|
+
return claims
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _email_is_verified(claims: dict) -> bool:
|
|
225
|
+
val = claims.get("email_verified")
|
|
226
|
+
return val is True or str(val).lower() == "true"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# User lookup / creation
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_by_identity(db, provider: str, subject: str) -> dict | None:
|
|
235
|
+
rows = db.sql(
|
|
236
|
+
'SELECT user_name FROM "User OAuth Identity" WHERE provider = ? AND subject = ?',
|
|
237
|
+
[provider, subject],
|
|
238
|
+
)
|
|
239
|
+
if not rows:
|
|
240
|
+
return None
|
|
241
|
+
user_name = rows[0]["user_name"]
|
|
242
|
+
users = db.sql(
|
|
243
|
+
'SELECT name, email, full_name, role, enabled FROM "User" WHERE name = ?',
|
|
244
|
+
[user_name],
|
|
245
|
+
)
|
|
246
|
+
return dict(users[0]) if users else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _link_identity(db, user_name: str, provider: str, subject: str, email: str | None):
|
|
250
|
+
db.insert("User OAuth Identity", {
|
|
251
|
+
"name": f"OAI-{uuid.uuid4().hex[:8]}",
|
|
252
|
+
"user_name": user_name,
|
|
253
|
+
"provider": provider,
|
|
254
|
+
"subject": subject,
|
|
255
|
+
"email": (email or "").lower() or None,
|
|
256
|
+
"creation": now(),
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _consume_invite(db, invite_token: str, email: str) -> str:
|
|
261
|
+
rows = db.sql('SELECT token, email, role, used FROM "Invite" WHERE token = ?', [invite_token])
|
|
262
|
+
invite = rows[0] if rows else None
|
|
263
|
+
if not invite:
|
|
264
|
+
raise HTTPException(status_code=404, detail="Invalid invite token")
|
|
265
|
+
if invite["used"]:
|
|
266
|
+
raise HTTPException(status_code=410, detail="Invite already used")
|
|
267
|
+
if invite["email"].lower() != email.lower():
|
|
268
|
+
raise HTTPException(status_code=403, detail="This invite was issued for a different email")
|
|
269
|
+
role = validate_assignable_role(invite["role"])
|
|
270
|
+
db.sql('UPDATE "Invite" SET used = 1 WHERE token = ?', [invite_token])
|
|
271
|
+
db.conn.commit()
|
|
272
|
+
return role
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _create_user(db, email: str, full_name: str, invite_token: str | None) -> dict:
|
|
276
|
+
"""Create a User for a first-time OAuth sign-in, honoring the same signup
|
|
277
|
+
gating as the password `register` flow (first-user-admin / invite / public
|
|
278
|
+
signup). Returns the new user dict or raises the appropriate HTTP error."""
|
|
279
|
+
user_count = db.sql('SELECT COUNT(*) as cnt FROM "User"')[0]["cnt"]
|
|
280
|
+
if user_count == 0:
|
|
281
|
+
role = "admin"
|
|
282
|
+
elif invite_token:
|
|
283
|
+
role = _consume_invite(db, invite_token, email)
|
|
284
|
+
elif _setting_enabled(db, "allow_public_signup"):
|
|
285
|
+
role = "viewer"
|
|
286
|
+
else:
|
|
287
|
+
raise HTTPException(status_code=403, detail="Registration requires an invite")
|
|
288
|
+
|
|
289
|
+
user_name = f"USR-{uuid.uuid4().hex[:8]}"
|
|
290
|
+
db.insert("User", {
|
|
291
|
+
"name": user_name,
|
|
292
|
+
"email": email.lower(),
|
|
293
|
+
"full_name": full_name or email.split("@")[0],
|
|
294
|
+
"hashed_password": OAUTH_PASSWORD_SENTINEL,
|
|
295
|
+
"role": role,
|
|
296
|
+
"enabled": 1,
|
|
297
|
+
"creation": now(),
|
|
298
|
+
"modified": now(),
|
|
299
|
+
})
|
|
300
|
+
return {"name": user_name, "email": email.lower(), "full_name": full_name, "role": role}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Endpoints
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _require_provider(provider: str):
|
|
309
|
+
if provider not in PROVIDERS:
|
|
310
|
+
raise HTTPException(status_code=404, detail="Unknown provider")
|
|
311
|
+
if not is_configured(provider):
|
|
312
|
+
raise HTTPException(status_code=404, detail=f"{PROVIDERS[provider]['label']} sign-in is not configured")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.get("/oauth/providers")
|
|
316
|
+
def oauth_providers():
|
|
317
|
+
"""Public: which social-login buttons the frontend should show."""
|
|
318
|
+
return {"providers": [
|
|
319
|
+
{"id": p, "label": PROVIDERS[p]["label"]} for p in configured_providers()
|
|
320
|
+
]}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.get("/oauth/identities")
|
|
324
|
+
def list_identities(user: dict = Depends(get_current_user)):
|
|
325
|
+
"""The signed-in user's linked providers (for a 'Linked accounts' UI)."""
|
|
326
|
+
db = get_db()
|
|
327
|
+
rows = db.sql(
|
|
328
|
+
'SELECT provider, email, creation FROM "User OAuth Identity" WHERE user_name = ? ORDER BY creation',
|
|
329
|
+
[user["name"]],
|
|
330
|
+
)
|
|
331
|
+
return [dict(r) for r in rows]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/{provider}/login")
|
|
335
|
+
def oauth_login(provider: str, request: Request, link: int = 0, invite: str | None = None):
|
|
336
|
+
"""Kick off the OAuth flow. With ?link=1 (while authenticated) the callback
|
|
337
|
+
attaches the provider to the current account instead of logging in."""
|
|
338
|
+
_require_provider(provider)
|
|
339
|
+
cfg = PROVIDERS[provider]
|
|
340
|
+
|
|
341
|
+
mode = "login"
|
|
342
|
+
uid = None
|
|
343
|
+
if link:
|
|
344
|
+
# Linking requires a real active session; capture who is linking. The
|
|
345
|
+
# shared public_manager demo account must never be a link target.
|
|
346
|
+
current = get_current_user(request)
|
|
347
|
+
if current["role"] == "public_manager":
|
|
348
|
+
raise HTTPException(status_code=403, detail="Demo accounts cannot link a provider")
|
|
349
|
+
mode = "link"
|
|
350
|
+
uid = current["name"]
|
|
351
|
+
|
|
352
|
+
nonce = _secrets.token_urlsafe(16)
|
|
353
|
+
state = _encode_state(
|
|
354
|
+
provider=provider, mode=mode, uid=uid, invite=invite,
|
|
355
|
+
nonce=nonce, sid=_secrets.token_urlsafe(8),
|
|
356
|
+
)
|
|
357
|
+
redirect_uri = _redirect_uri(request, provider)
|
|
358
|
+
params = {
|
|
359
|
+
"client_id": _client_id(provider),
|
|
360
|
+
"redirect_uri": redirect_uri,
|
|
361
|
+
"response_type": "code",
|
|
362
|
+
"scope": cfg["scope"],
|
|
363
|
+
"state": state,
|
|
364
|
+
"nonce": nonce,
|
|
365
|
+
}
|
|
366
|
+
if cfg["response_mode"]:
|
|
367
|
+
params["response_mode"] = cfg["response_mode"]
|
|
368
|
+
|
|
369
|
+
authorization_endpoint = _discovery(provider)["authorization_endpoint"]
|
|
370
|
+
url = str(httpx.URL(authorization_endpoint, params=params))
|
|
371
|
+
return RedirectResponse(url=url, status_code=303)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def _read_callback_params(request: Request) -> dict:
|
|
375
|
+
"""Callback args arrive in the query string (Google, GET) or as form fields
|
|
376
|
+
(Apple form_post, POST)."""
|
|
377
|
+
if request.method == "POST":
|
|
378
|
+
form = await request.form()
|
|
379
|
+
return dict(form)
|
|
380
|
+
return dict(request.query_params)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _login_redirect(request: Request, user_name: str) -> RedirectResponse:
|
|
384
|
+
token = create_access_token(user_name)
|
|
385
|
+
resp = RedirectResponse(url="/", status_code=303)
|
|
386
|
+
set_auth_cookie(request, resp, token)
|
|
387
|
+
return resp
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _apple_full_name(params: dict) -> str | None:
|
|
391
|
+
"""Apple returns the name only on the *first* authorization, as a JSON blob
|
|
392
|
+
in the `user` form field."""
|
|
393
|
+
raw = params.get("user")
|
|
394
|
+
if not raw:
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
name = json.loads(raw).get("name", {})
|
|
398
|
+
parts = [name.get("firstName"), name.get("lastName")]
|
|
399
|
+
return " ".join(p for p in parts if p) or None
|
|
400
|
+
except (ValueError, AttributeError):
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@router.api_route("/{provider}/callback", methods=["GET", "POST"])
|
|
405
|
+
async def oauth_callback(provider: str, request: Request):
|
|
406
|
+
_require_provider(provider)
|
|
407
|
+
params = await _read_callback_params(request)
|
|
408
|
+
|
|
409
|
+
if params.get("error"):
|
|
410
|
+
return RedirectResponse(url=f"/login?oauth_error={params.get('error')}", status_code=303)
|
|
411
|
+
|
|
412
|
+
code = params.get("code")
|
|
413
|
+
state = params.get("state")
|
|
414
|
+
if not code or not state:
|
|
415
|
+
return RedirectResponse(url="/login?oauth_error=missing_code", status_code=303)
|
|
416
|
+
|
|
417
|
+
flow = _decode_state(state)
|
|
418
|
+
if flow.get("provider") != provider:
|
|
419
|
+
raise HTTPException(status_code=400, detail="State/provider mismatch")
|
|
420
|
+
|
|
421
|
+
# Exchange the code for tokens.
|
|
422
|
+
token_endpoint = _discovery(provider)["token_endpoint"]
|
|
423
|
+
data = {
|
|
424
|
+
"grant_type": "authorization_code",
|
|
425
|
+
"code": code,
|
|
426
|
+
"redirect_uri": _redirect_uri(request, provider),
|
|
427
|
+
"client_id": _client_id(provider),
|
|
428
|
+
"client_secret": _client_secret(provider),
|
|
429
|
+
}
|
|
430
|
+
token_resp = httpx.post(token_endpoint, data=data, timeout=10)
|
|
431
|
+
if token_resp.status_code != 200:
|
|
432
|
+
return RedirectResponse(url="/login?oauth_error=token_exchange_failed", status_code=303)
|
|
433
|
+
id_token = token_resp.json().get("id_token")
|
|
434
|
+
if not id_token:
|
|
435
|
+
return RedirectResponse(url="/login?oauth_error=no_id_token", status_code=303)
|
|
436
|
+
|
|
437
|
+
claims = _verify_id_token(provider, id_token, flow.get("nonce", ""))
|
|
438
|
+
subject = claims.get("sub")
|
|
439
|
+
email = (claims.get("email") or "").lower()
|
|
440
|
+
if not subject:
|
|
441
|
+
raise HTTPException(status_code=400, detail="Identity token missing subject")
|
|
442
|
+
|
|
443
|
+
db = get_db()
|
|
444
|
+
|
|
445
|
+
# --- Link mode: attach this provider identity to the current account ------
|
|
446
|
+
if flow.get("mode") == "link":
|
|
447
|
+
uid = flow.get("uid")
|
|
448
|
+
existing = _find_by_identity(db, provider, subject)
|
|
449
|
+
if existing and existing["name"] != uid:
|
|
450
|
+
return RedirectResponse(url="/login?oauth_error=identity_taken", status_code=303)
|
|
451
|
+
if not existing:
|
|
452
|
+
_link_identity(db, uid, provider, subject, email)
|
|
453
|
+
return RedirectResponse(url="/?linked=" + provider, status_code=303)
|
|
454
|
+
|
|
455
|
+
# --- Login mode -----------------------------------------------------------
|
|
456
|
+
user = _find_by_identity(db, provider, subject)
|
|
457
|
+
if user:
|
|
458
|
+
if not user.get("enabled"):
|
|
459
|
+
return RedirectResponse(url="/login?oauth_error=account_disabled", status_code=303)
|
|
460
|
+
return _login_redirect(request, user["name"])
|
|
461
|
+
|
|
462
|
+
# No identity yet. We need a provider-verified email to go further.
|
|
463
|
+
if not email or not _email_is_verified(claims):
|
|
464
|
+
return RedirectResponse(url="/login?oauth_error=email_unverified", status_code=303)
|
|
465
|
+
|
|
466
|
+
# An email match to an existing account is an intentional conflict, not an
|
|
467
|
+
# auto-link (that would be an account-takeover vector). The user must sign in
|
|
468
|
+
# and link from settings instead.
|
|
469
|
+
clash = db.sql('SELECT name FROM "User" WHERE email = ?', [email])
|
|
470
|
+
if clash:
|
|
471
|
+
return RedirectResponse(url="/login?oauth_error=email_exists", status_code=303)
|
|
472
|
+
|
|
473
|
+
full_name = claims.get("name") or _apple_full_name(params) or ""
|
|
474
|
+
try:
|
|
475
|
+
new_user = _create_user(db, email, full_name, flow.get("invite"))
|
|
476
|
+
except HTTPException as exc:
|
|
477
|
+
reason = "registration_closed" if exc.status_code == 403 else "invite_invalid"
|
|
478
|
+
return RedirectResponse(url=f"/login?oauth_error={reason}", status_code=303)
|
|
479
|
+
|
|
480
|
+
_link_identity(db, new_user["name"], provider, subject, email)
|
|
481
|
+
return _login_redirect(request, new_user["name"])
|
|
@@ -9,6 +9,14 @@ export function configureApiBase(base: string) {
|
|
|
9
9
|
BASE = base.replace(/\/+$/, "");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// The API origin as a browser-navigable URL. Social login is a full-page
|
|
13
|
+
// redirect (not a fetch), so the button must point the browser straight at the
|
|
14
|
+
// backend endpoint — resolving BASE (which may be relative like "/api") against
|
|
15
|
+
// the current origin.
|
|
16
|
+
export function apiUrl(path: string): string {
|
|
17
|
+
return new URL(`${BASE}${path}`, window.location.origin).toString();
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export class ApiError extends Error {
|
|
13
21
|
constructor(public status: number, message: string) {
|
|
14
22
|
super(message);
|
|
@@ -393,7 +401,18 @@ export const api = {
|
|
|
393
401
|
first_run: boolean;
|
|
394
402
|
public_signup: boolean;
|
|
395
403
|
registration_open: boolean;
|
|
404
|
+
oauth_providers: string[];
|
|
396
405
|
}>("/auth/setup-status"),
|
|
406
|
+
// Social login is a full-page navigation, not a fetch — build the URL the
|
|
407
|
+
// browser should be sent to. `link` starts a "link to my account" flow.
|
|
408
|
+
oauthLoginUrl: (provider: string, opts?: { link?: boolean; invite?: string }) => {
|
|
409
|
+
const qs = new URLSearchParams();
|
|
410
|
+
if (opts?.link) qs.set("link", "1");
|
|
411
|
+
if (opts?.invite) qs.set("invite", opts.invite);
|
|
412
|
+
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
|
413
|
+
return apiUrl(`/auth/${encodeURIComponent(provider)}/login${suffix}`);
|
|
414
|
+
},
|
|
415
|
+
oauthListIdentities: () => request<{ provider: string; email: string | null; creation: string }[]>("/auth/oauth/identities"),
|
|
397
416
|
authRegister: (data: { email: string; full_name: string; password: string; invite_token?: string }) =>
|
|
398
417
|
request<any>("/auth/register", { method: "POST", body: JSON.stringify(data) }),
|
|
399
418
|
authLogin: (email: string, password: string) =>
|
|
@@ -1286,6 +1286,23 @@ class Database:
|
|
|
1286
1286
|
FOREIGN KEY (created_by) REFERENCES "User"(name)
|
|
1287
1287
|
)""",
|
|
1288
1288
|
|
|
1289
|
+
# Third-party (Google / Apple) sign-in identities. One User may hold
|
|
1290
|
+
# several (password + Google + Apple); a provider account maps to at
|
|
1291
|
+
# most one User via the UNIQUE(provider, subject). `subject` is the
|
|
1292
|
+
# provider's stable per-user id (OIDC `sub`), so it survives the user
|
|
1293
|
+
# changing their provider-side email. Password stays on User (an
|
|
1294
|
+
# OAuth-only user just carries a sentinel, non-matchable hash).
|
|
1295
|
+
"""CREATE TABLE IF NOT EXISTS "User OAuth Identity" (
|
|
1296
|
+
name TEXT PRIMARY KEY,
|
|
1297
|
+
user_name TEXT NOT NULL,
|
|
1298
|
+
provider TEXT NOT NULL,
|
|
1299
|
+
subject TEXT NOT NULL,
|
|
1300
|
+
email TEXT,
|
|
1301
|
+
creation TEXT,
|
|
1302
|
+
UNIQUE(provider, subject),
|
|
1303
|
+
FOREIGN KEY (user_name) REFERENCES "User"(name)
|
|
1304
|
+
)""",
|
|
1305
|
+
|
|
1289
1306
|
"""CREATE TABLE IF NOT EXISTS "Settings" (
|
|
1290
1307
|
key TEXT PRIMARY KEY,
|
|
1291
1308
|
value TEXT
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|