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.
Files changed (70) hide show
  1. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/auth.py +3 -0
  3. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/main.py +2 -0
  4. lambda_erp-0.1.33/api/oauth.py +481 -0
  5. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/frontend/src/api/client.ts +19 -0
  6. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/database.py +17 -0
  7. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/pyproject.toml +1 -1
  8. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/.gitignore +0 -0
  9. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/LICENSE +0 -0
  10. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/README.md +0 -0
  11. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/__init__.py +0 -0
  12. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/attachments.py +0 -0
  13. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/bootstrap.py +0 -0
  14. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/chat.py +0 -0
  15. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/deps.py +0 -0
  17. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/errors.py +0 -0
  18. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/pdf.py +0 -0
  19. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/providers.py +0 -0
  20. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/documents.py +0 -0
  27. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/masters.py +0 -0
  28. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/proposals.py +0 -0
  29. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/reports.py +0 -0
  30. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/routers/setup.py +0 -0
  31. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/services.py +0 -0
  32. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/templates/document.html +0 -0
  33. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/frontend/README.md +0 -0
  36. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/__init__.py +0 -0
  37. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/__init__.py +0 -0
  38. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/bank_transaction.py +0 -0
  39. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/budget.py +0 -0
  40. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  41. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/general_ledger.py +0 -0
  42. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/journal_entry.py +0 -0
  43. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/payment_entry.py +0 -0
  44. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/pos_invoice.py +0 -0
  45. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/purchase_invoice.py +0 -0
  46. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/revaluation.py +0 -0
  47. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/sales_invoice.py +0 -0
  48. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/accounting/subscription.py +0 -0
  49. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/buying/__init__.py +0 -0
  50. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/buying/purchase_order.py +0 -0
  51. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/__init__.py +0 -0
  52. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/currency.py +0 -0
  53. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/defaults.py +0 -0
  54. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/pricing_rule.py +0 -0
  55. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  56. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/exceptions.py +0 -0
  57. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/hooks.py +0 -0
  58. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/model.py +0 -0
  59. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/__init__.py +0 -0
  60. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/proposal.py +0 -0
  61. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/quotation.py +0 -0
  62. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/selling/sales_order.py +0 -0
  63. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/simulation.py +0 -0
  64. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/__init__.py +0 -0
  65. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/delivery_note.py +0 -0
  66. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/purchase_receipt.py +0 -0
  67. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/stock_entry.py +0 -0
  68. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/stock/stock_ledger.py +0 -0
  69. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/lambda_erp/utils.py +0 -0
  70. {lambda_erp-0.1.31 → lambda_erp-0.1.33}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.31
3
+ Version: 0.1.33
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.31"
3
+ version = "0.1.33"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
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