Spaces:
Runtime error
Runtime error
| import uuid | |
| from datetime import datetime, timezone, timedelta | |
| from decimal import Decimal | |
| from fastapi import HTTPException | |
| from google.oauth2 import id_token as google_id_token | |
| from google.auth.transport import requests as google_requests | |
| import jwt | |
| from sqlalchemy import select | |
| from sqlalchemy.orm import Session | |
| from app.models.database import Tenant | |
| from app.utils.api_key import generate_api_key | |
| from app.utils.registration_rate_limit import enforce_registration_rate_limit | |
| PARTIAL_TOKEN_SCOPE = "totp_pending" | |
| PARTIAL_TOKEN_EXPIRE_MINUTES = 5 | |
| FULL_TOKEN_SCOPE = "admin" | |
| FULL_TOKEN_EXPIRE_HOURS = 24 | |
| SUPER_ADMIN_TOKEN_SCOPE = "super_admin" | |
| SUPER_ADMIN_TOKEN_EXPIRE_HOURS = 1 | |
| def verify_google_token(token: str, google_client_id: str) -> dict: | |
| """Verify a Google ID token; return {"email", "google_id", "name"}. | |
| Raises ValueError when the token is invalid OR its email is not | |
| verified by Google — `verify_oauth2_token` checks signature/audience/ | |
| expiry only, so an unverified email would otherwise be able to | |
| register a tenant squatting someone else's address. | |
| """ | |
| payload = google_id_token.verify_oauth2_token( | |
| token, | |
| google_requests.Request(), | |
| google_client_id, | |
| ) | |
| if payload.get("email_verified") is not True: | |
| raise ValueError("Google email not verified") | |
| return { | |
| # Lowercased at the boundary so every consumer (login lookup, | |
| # collision pre-check, linking, registration store) compares and | |
| # stores one canonical form (B9.1). | |
| "email": payload["email"].lower(), | |
| "google_id": payload["sub"], | |
| "name": payload.get("name"), | |
| } | |
| def lookup_tenant_for_google( | |
| email: str, | |
| google_id: str, | |
| db: Session, | |
| ) -> Tenant | None: | |
| """Find the tenant for a verified Google login WITHOUT registering one. | |
| Returns the matched tenant (binding google_id on a provisioned/gifted | |
| account that has none yet), or None when no tenant exists for either the | |
| email or the google_id. The caller decides what to do with None — B12 | |
| Layer 1 requires explicit confirmation before a new tenant is created, so | |
| google_auth must NOT auto-create here. The caller must run collision | |
| detection BEFORE calling this function. | |
| """ | |
| email = email.lower() # defensive — canonical form regardless of caller | |
| tenant = db.scalars(select(Tenant).where(Tenant.owner_email == email)).first() | |
| if tenant is None: | |
| # The Google account's email may have changed since registration — | |
| # google_id is the stable identifier (and unique on tenants, so a | |
| # blind create would violate the constraint). | |
| tenant = db.scalars( | |
| select(Tenant).where(Tenant.google_id == google_id) | |
| ).first() | |
| if tenant is not None: | |
| if tenant.google_id is not None and tenant.google_id != google_id: | |
| raise HTTPException(status_code=401, detail="Unauthorized") | |
| if tenant.google_id is None: | |
| tenant.google_id = google_id | |
| db.commit() | |
| db.refresh(tenant) | |
| return tenant | |
| return None | |
| def register_tenant_for_google( | |
| email: str, | |
| google_id: str, | |
| name: str | None, | |
| db: Session, | |
| client_ip: str, | |
| ) -> Tenant: | |
| """Create a new tenant for a verified Google identity (rate-limited). | |
| Caller must have already confirmed registration intent (B12 Layer 1) and | |
| run collision detection. Creates a credits-mode, zero-balance tenant. | |
| """ | |
| email = email.lower() | |
| enforce_registration_rate_limit(client_ip) | |
| _, hashed_key = generate_api_key() | |
| tenant = Tenant( | |
| name=name or email.split("@")[0], | |
| owner_email=email, | |
| google_id=google_id, | |
| api_key=hashed_key, | |
| is_active=True, | |
| billing_mode="credits", | |
| balance_usd=Decimal("0"), | |
| ) | |
| db.add(tenant) | |
| db.commit() | |
| db.refresh(tenant) | |
| return tenant | |
| def lookup_or_register_tenant( | |
| email: str, | |
| google_id: str, | |
| name: str | None, | |
| db: Session, | |
| client_ip: str, | |
| ) -> tuple[Tenant, bool]: | |
| """Find the tenant for a verified Google login, registering one if none exists. | |
| Retained as a composition of lookup + register for callers that want the | |
| legacy auto-create behavior. B12 Layer 1 deliberately does NOT use this in | |
| google_auth anymore — login looks up only; creation goes through the | |
| confirm-register endpoint. The caller must run collision detection first. | |
| """ | |
| tenant = lookup_tenant_for_google(email, google_id, db) | |
| if tenant is not None: | |
| return tenant, False | |
| tenant = register_tenant_for_google(email, google_id, name, db, client_ip) | |
| return tenant, True | |
| def issue_partial_jwt(tenant_id: uuid.UUID, jwt_secret_key: str) -> str: | |
| """Issue a 5-minute JWT with scope=totp_pending.""" | |
| payload = { | |
| "sub": str(tenant_id), | |
| "scope": PARTIAL_TOKEN_SCOPE, | |
| "exp": datetime.now(timezone.utc) + timedelta(minutes=PARTIAL_TOKEN_EXPIRE_MINUTES), | |
| } | |
| return jwt.encode(payload, jwt_secret_key, algorithm="HS256") | |
| def decode_partial_jwt(token: str, jwt_secret_key: str) -> str: | |
| """Decode a partial JWT; return tenant_id as str. Raise ValueError on any failure.""" | |
| try: | |
| payload = jwt.decode(token, jwt_secret_key, algorithms=["HS256"]) | |
| except jwt.PyJWTError as exc: | |
| raise ValueError("Invalid or expired token") from exc | |
| if payload.get("scope") != PARTIAL_TOKEN_SCOPE: | |
| raise ValueError("Invalid scope") | |
| return payload["sub"] | |
| def issue_full_jwt(tenant_id: uuid.UUID, jwt_secret_key: str) -> str: | |
| """Issue a 24-hour admin JWT on successful TOTP verification.""" | |
| payload = { | |
| "sub": str(tenant_id), | |
| "scope": FULL_TOKEN_SCOPE, | |
| "exp": datetime.now(timezone.utc) + timedelta(hours=FULL_TOKEN_EXPIRE_HOURS), | |
| } | |
| return jwt.encode(payload, jwt_secret_key, algorithm="HS256") | |
| def decode_full_jwt(token: str, jwt_secret_key: str) -> str: | |
| """Decode an admin JWT; return tenant_id as str. Raise ValueError on any failure.""" | |
| try: | |
| payload = jwt.decode(token, jwt_secret_key, algorithms=["HS256"]) | |
| except jwt.PyJWTError as exc: | |
| raise ValueError("Invalid or expired token") from exc | |
| if payload.get("scope") != FULL_TOKEN_SCOPE: | |
| raise ValueError("Invalid scope") | |
| return payload["sub"] | |
| def issue_super_admin_jwt(email: str, jwt_secret_key: str) -> str: | |
| """Issue a 1-hour super-admin JWT with sub=email.""" | |
| payload = { | |
| "sub": email, | |
| "scope": SUPER_ADMIN_TOKEN_SCOPE, | |
| "exp": datetime.now(timezone.utc) + timedelta(hours=SUPER_ADMIN_TOKEN_EXPIRE_HOURS), | |
| } | |
| return jwt.encode(payload, jwt_secret_key, algorithm="HS256") | |
| def decode_super_admin_jwt(token: str, jwt_secret_key: str) -> str: | |
| """Decode a super-admin JWT; return email as str. Raise ValueError on any failure.""" | |
| try: | |
| payload = jwt.decode(token, jwt_secret_key, algorithms=["HS256"]) | |
| except jwt.PyJWTError as exc: | |
| raise ValueError("Invalid or expired token") from exc | |
| if payload.get("scope") != SUPER_ADMIN_TOKEN_SCOPE: | |
| raise ValueError("Invalid scope") | |
| email = payload.get("sub") | |
| if not email: | |
| raise ValueError("Missing sub claim") | |
| return email | |