Spaces:
Runtime error
Runtime error
Commit ·
013ff92
1
Parent(s): 7299acc
fixed auth
Browse files- .env.example +6 -0
- backend/app/auth.py +13 -0
- backend/app/config.py +8 -0
- backend/app/routers/auth.py +159 -4
- backend/tests/test_auth.py +63 -0
- docker-compose.yml +2 -0
- frontend/src/App.tsx +2 -0
- frontend/src/api/auth.ts +13 -1
- frontend/src/api/client.ts +1 -1
- frontend/src/components/AppShell.tsx +6 -2
- frontend/src/pages/AuthCallback.tsx +42 -0
- frontend/src/pages/AuthPage.tsx +40 -4
- frontend/src/styles/app.css +12 -0
- frontend/tsconfig.tsbuildinfo +1 -1
.env.example
CHANGED
|
@@ -8,7 +8,13 @@ INTERNAL_TOKEN=replace-with-random-token
|
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
| 10 |
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
GOOGLE_CLIENT_ID=
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
|
| 14 |
EMAIL_PROVIDER=console
|
|
|
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
| 10 |
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
| 11 |
+
# Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
|
| 12 |
+
# Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
|
| 13 |
+
# (default {API_URL}/api/auth/google/callback, e.g. http://localhost:8000/api/auth/google/callback)
|
| 14 |
+
# to "Authorized redirect URIs" on the OAuth client. Never prefix the secret with VITE_.
|
| 15 |
GOOGLE_CLIENT_ID=
|
| 16 |
+
GOOGLE_CLIENT_SECRET=
|
| 17 |
+
GOOGLE_REDIRECT_URI=
|
| 18 |
|
| 19 |
# ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
|
| 20 |
EMAIL_PROVIDER=console
|
backend/app/auth.py
CHANGED
|
@@ -32,6 +32,19 @@ def create_refresh_token(user_id: uuid.UUID, token_version: int) -> str:
|
|
| 32 |
return _encode(user_id, token_version, "refresh", timedelta(days=settings.jwt_refresh_expiration_days))
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def decode_token_full(token: str, expected_type: str = "access") -> dict:
|
| 36 |
try:
|
| 37 |
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
|
|
|
| 32 |
return _encode(user_id, token_version, "refresh", timedelta(days=settings.jwt_refresh_expiration_days))
|
| 33 |
|
| 34 |
|
| 35 |
+
def create_login_ticket(user_id: uuid.UUID, token_version: int, created: bool) -> str:
|
| 36 |
+
"""Short lived handoff from the OAuth callback redirect to the SPA, swapped for real tokens."""
|
| 37 |
+
now = datetime.now(UTC)
|
| 38 |
+
payload = {"sub": str(user_id), "tv": token_version, "typ": "ticket", "new": created, "iat": now,
|
| 39 |
+
"exp": now + timedelta(seconds=60)}
|
| 40 |
+
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def encode_signed(payload: dict, ttl: timedelta) -> str:
|
| 44 |
+
return jwt.encode({**payload, "exp": datetime.now(UTC) + ttl}, settings.jwt_secret,
|
| 45 |
+
algorithm=settings.jwt_algorithm)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
def decode_token_full(token: str, expected_type: str = "access") -> dict:
|
| 49 |
try:
|
| 50 |
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
backend/app/config.py
CHANGED
|
@@ -30,6 +30,10 @@ class Settings(BaseSettings):
|
|
| 30 |
jwt_expiration_hours: int = 72
|
| 31 |
jwt_refresh_expiration_days: int = 30
|
| 32 |
google_client_id: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# Email (Resend)
|
| 35 |
email_provider: str = "console" # resend | console
|
|
@@ -80,6 +84,10 @@ class Settings(BaseSettings):
|
|
| 80 |
return self.r2_endpoint_url
|
| 81 |
return f"https://{self.r2_account_id}.r2.cloudflarestorage.com"
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
@property
|
| 84 |
def cors_origin_list(self) -> list[str]:
|
| 85 |
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
|
|
|
| 30 |
jwt_expiration_hours: int = 72
|
| 31 |
jwt_refresh_expiration_days: int = 30
|
| 32 |
google_client_id: str = ""
|
| 33 |
+
# Setting the secret switches Google sign in from the GIS popup to the redirect flow
|
| 34 |
+
# (server side code exchange). The redirect URI must be listed on the OAuth client.
|
| 35 |
+
google_client_secret: str = ""
|
| 36 |
+
google_redirect_uri: str = "" # defaults to {api_url}/api/auth/google/callback
|
| 37 |
|
| 38 |
# Email (Resend)
|
| 39 |
email_provider: str = "console" # resend | console
|
|
|
|
| 84 |
return self.r2_endpoint_url
|
| 85 |
return f"https://{self.r2_account_id}.r2.cloudflarestorage.com"
|
| 86 |
|
| 87 |
+
@property
|
| 88 |
+
def google_redirect_url(self) -> str:
|
| 89 |
+
return self.google_redirect_uri or f"{self.api_url.rstrip('/')}/api/auth/google/callback"
|
| 90 |
+
|
| 91 |
@property
|
| 92 |
def cors_origin_list(self) -> list[str]:
|
| 93 |
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
backend/app/routers/auth.py
CHANGED
|
@@ -1,12 +1,24 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
from google.auth.transport import requests as google_requests
|
| 5 |
from google.oauth2 import id_token as google_id_token
|
| 6 |
from pydantic import BaseModel, EmailStr
|
| 7 |
from sqlalchemy.orm import Session
|
| 8 |
|
| 9 |
-
from app.auth import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.config import settings
|
| 11 |
from app.db import get_db
|
| 12 |
from app.models import AuthProvider, User, VerificationPurpose
|
|
@@ -32,12 +44,15 @@ def _serialize_user(user: User) -> dict:
|
|
| 32 |
}
|
| 33 |
|
| 34 |
|
| 35 |
-
def
|
| 36 |
user.last_login_at = datetime.now(UTC)
|
| 37 |
if created and not user.welcome_email_sent_at:
|
| 38 |
if email_service.send_welcome(user.email, user.name, str(user.id)):
|
| 39 |
user.welcome_email_sent_at = datetime.now(UTC)
|
| 40 |
db.commit()
|
|
|
|
|
|
|
|
|
|
| 41 |
return {
|
| 42 |
"access_token": create_access_token(user.id, user.token_version),
|
| 43 |
"refresh_token": create_refresh_token(user.id, user.token_version),
|
|
@@ -46,6 +61,11 @@ def _finalize_login(db: Session, user: User, created: bool) -> dict:
|
|
| 46 |
}
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# Google
|
| 50 |
|
| 51 |
|
|
@@ -74,6 +94,141 @@ def google_login(body: GoogleIn, db: Session = Depends(get_db)):
|
|
| 74 |
return _finalize_login(db, resolved.user, resolved.created)
|
| 75 |
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# Email signup (6 digit code)
|
| 78 |
|
| 79 |
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
from datetime import UTC, datetime, timedelta
|
| 3 |
+
from urllib.parse import urlencode
|
| 4 |
|
| 5 |
+
import httpx
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 7 |
+
from fastapi.responses import RedirectResponse
|
| 8 |
from google.auth.transport import requests as google_requests
|
| 9 |
from google.oauth2 import id_token as google_id_token
|
| 10 |
from pydantic import BaseModel, EmailStr
|
| 11 |
from sqlalchemy.orm import Session
|
| 12 |
|
| 13 |
+
from app.auth import (
|
| 14 |
+
create_access_token,
|
| 15 |
+
create_login_ticket,
|
| 16 |
+
create_refresh_token,
|
| 17 |
+
decode_token_full,
|
| 18 |
+
encode_signed,
|
| 19 |
+
get_current_user,
|
| 20 |
+
load_user_from_token,
|
| 21 |
+
)
|
| 22 |
from app.config import settings
|
| 23 |
from app.db import get_db
|
| 24 |
from app.models import AuthProvider, User, VerificationPurpose
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
|
| 47 |
+
def _record_login(db: Session, user: User, created: bool) -> None:
|
| 48 |
user.last_login_at = datetime.now(UTC)
|
| 49 |
if created and not user.welcome_email_sent_at:
|
| 50 |
if email_service.send_welcome(user.email, user.name, str(user.id)):
|
| 51 |
user.welcome_email_sent_at = datetime.now(UTC)
|
| 52 |
db.commit()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _login_result(user: User, created: bool) -> dict:
|
| 56 |
return {
|
| 57 |
"access_token": create_access_token(user.id, user.token_version),
|
| 58 |
"refresh_token": create_refresh_token(user.id, user.token_version),
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
+
def _finalize_login(db: Session, user: User, created: bool) -> dict:
|
| 65 |
+
_record_login(db, user, created)
|
| 66 |
+
return _login_result(user, created)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
# Google
|
| 70 |
|
| 71 |
|
|
|
|
| 94 |
return _finalize_login(db, resolved.user, resolved.created)
|
| 95 |
|
| 96 |
|
| 97 |
+
def _safe_next(path: str | None) -> str:
|
| 98 |
+
"""Only same site paths, so the redirect flow cannot be turned into an open redirect."""
|
| 99 |
+
if not path or not path.startswith("/") or path.startswith("//") or "\\" in path:
|
| 100 |
+
return "/app"
|
| 101 |
+
return path
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/providers")
|
| 105 |
+
def providers():
|
| 106 |
+
"""Which Google flow the auth page should render: 'redirect', 'popup', or null when off."""
|
| 107 |
+
if not settings.google_client_id:
|
| 108 |
+
return {"google": None}
|
| 109 |
+
return {"google": "redirect" if settings.google_client_secret else "popup"}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Google redirect flow (authorization code, exchanged server side)
|
| 113 |
+
|
| 114 |
+
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
| 115 |
+
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
| 116 |
+
STATE_COOKIE = "ns_oauth_state"
|
| 117 |
+
STATE_TTL = timedelta(minutes=10)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _frontend(path: str, **fragment: str) -> RedirectResponse:
|
| 121 |
+
url = f"{settings.frontend_url.rstrip('/')}{path}"
|
| 122 |
+
if fragment:
|
| 123 |
+
url += "#" + urlencode(fragment)
|
| 124 |
+
response = RedirectResponse(url, status_code=302)
|
| 125 |
+
response.delete_cookie(STATE_COOKIE, path="/api/auth/google")
|
| 126 |
+
return response
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _auth_error(message: str) -> RedirectResponse:
|
| 130 |
+
return _frontend("/auth", error=message)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def exchange_google_code(code: str) -> dict:
|
| 134 |
+
"""Trade the authorization code for tokens and return the verified ID token claims."""
|
| 135 |
+
res = httpx.post(
|
| 136 |
+
GOOGLE_TOKEN_URL,
|
| 137 |
+
data={
|
| 138 |
+
"code": code,
|
| 139 |
+
"client_id": settings.google_client_id,
|
| 140 |
+
"client_secret": settings.google_client_secret,
|
| 141 |
+
"redirect_uri": settings.google_redirect_url,
|
| 142 |
+
"grant_type": "authorization_code",
|
| 143 |
+
},
|
| 144 |
+
timeout=10,
|
| 145 |
+
)
|
| 146 |
+
res.raise_for_status()
|
| 147 |
+
return google_id_token.verify_oauth2_token(
|
| 148 |
+
res.json()["id_token"], google_requests.Request(), settings.google_client_id
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
@router.get("/google/start")
|
| 153 |
+
def google_start(next: str | None = None):
|
| 154 |
+
if not (settings.google_client_id and settings.google_client_secret):
|
| 155 |
+
return _auth_error("Google sign in is not configured.")
|
| 156 |
+
nonce = secrets.token_urlsafe(24)
|
| 157 |
+
state = encode_signed({"typ": "oauth_state", "nonce": nonce, "next": _safe_next(next)}, STATE_TTL)
|
| 158 |
+
params = {
|
| 159 |
+
"client_id": settings.google_client_id,
|
| 160 |
+
"redirect_uri": settings.google_redirect_url,
|
| 161 |
+
"response_type": "code",
|
| 162 |
+
"scope": "openid email profile",
|
| 163 |
+
"state": state,
|
| 164 |
+
"nonce": nonce,
|
| 165 |
+
"prompt": "select_account",
|
| 166 |
+
}
|
| 167 |
+
response = RedirectResponse(f"{GOOGLE_AUTH_URL}?{urlencode(params)}", status_code=302)
|
| 168 |
+
# Binds the state to this browser (login CSRF). Lax so it survives the top level return from Google.
|
| 169 |
+
response.set_cookie(
|
| 170 |
+
STATE_COOKIE,
|
| 171 |
+
nonce,
|
| 172 |
+
max_age=int(STATE_TTL.total_seconds()),
|
| 173 |
+
path="/api/auth/google",
|
| 174 |
+
httponly=True,
|
| 175 |
+
secure=settings.env != "development",
|
| 176 |
+
samesite="lax",
|
| 177 |
+
)
|
| 178 |
+
return response
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@router.get("/google/callback")
|
| 182 |
+
def google_callback(
|
| 183 |
+
request: Request,
|
| 184 |
+
code: str | None = None,
|
| 185 |
+
state: str | None = None,
|
| 186 |
+
error: str | None = None,
|
| 187 |
+
db: Session = Depends(get_db),
|
| 188 |
+
):
|
| 189 |
+
if error:
|
| 190 |
+
return _auth_error("Google sign in was cancelled." if error == "access_denied" else "Google sign in failed.")
|
| 191 |
+
if not code or not state:
|
| 192 |
+
return _auth_error("Google sign in failed. Try again.")
|
| 193 |
+
try:
|
| 194 |
+
saved = decode_token_full(state, expected_type="oauth_state")
|
| 195 |
+
except HTTPException:
|
| 196 |
+
return _auth_error("Google sign in expired. Try again.")
|
| 197 |
+
cookie = request.cookies.get(STATE_COOKIE)
|
| 198 |
+
if not cookie or not secrets.compare_digest(cookie, saved.get("nonce", "")):
|
| 199 |
+
return _auth_error("Google sign in expired. Try again.")
|
| 200 |
+
try:
|
| 201 |
+
info = exchange_google_code(code)
|
| 202 |
+
except (httpx.HTTPError, KeyError, ValueError):
|
| 203 |
+
return _auth_error("Google sign in failed. Try again.")
|
| 204 |
+
if info.get("nonce") != saved["nonce"]:
|
| 205 |
+
return _auth_error("Google sign in failed. Try again.")
|
| 206 |
+
if not info.get("email_verified"):
|
| 207 |
+
return _auth_error("Google email is not verified.")
|
| 208 |
+
try:
|
| 209 |
+
resolved = ident.resolve_or_create_google_user(
|
| 210 |
+
db, email=info["email"], google_id=info["sub"], name=info.get("name"), avatar_url=info.get("picture")
|
| 211 |
+
)
|
| 212 |
+
except ident.IdentityError as exc:
|
| 213 |
+
db.rollback()
|
| 214 |
+
return _auth_error(exc.message)
|
| 215 |
+
_record_login(db, resolved.user, resolved.created)
|
| 216 |
+
ticket = create_login_ticket(resolved.user.id, resolved.user.token_version, resolved.created)
|
| 217 |
+
# Fragment, not query: it never reaches servers or referrers, and the ticket dies in 60s.
|
| 218 |
+
return _frontend("/auth/callback", ticket=ticket, next=saved["next"])
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class TicketIn(BaseModel):
|
| 222 |
+
ticket: str
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.post("/ticket")
|
| 226 |
+
def redeem_ticket(body: TicketIn, db: Session = Depends(get_db)):
|
| 227 |
+
user = load_user_from_token(db, body.ticket, expected_type="ticket")
|
| 228 |
+
created = bool(decode_token_full(body.ticket, expected_type="ticket").get("new"))
|
| 229 |
+
return _login_result(user, created)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
# Email signup (6 digit code)
|
| 233 |
|
| 234 |
|
backend/tests/test_auth.py
CHANGED
|
@@ -78,3 +78,66 @@ def test_delete_account(client):
|
|
| 78 |
assert client.post("/api/auth/delete-account", headers=headers).status_code == 200
|
| 79 |
r = client.post("/api/auth/email/login", json={"email": "writer@example.com", "password": "stardust-42"})
|
| 80 |
assert r.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
assert client.post("/api/auth/delete-account", headers=headers).status_code == 200
|
| 79 |
r = client.post("/api/auth/email/login", json={"email": "writer@example.com", "password": "stardust-42"})
|
| 80 |
assert r.status_code == 403
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _google_redirect(client, monkeypatch, claims: dict, next_path="/app/notebooks"):
|
| 84 |
+
from urllib.parse import parse_qs, urlparse
|
| 85 |
+
|
| 86 |
+
from app.config import settings
|
| 87 |
+
from app.routers import auth as auth_router
|
| 88 |
+
|
| 89 |
+
monkeypatch.setattr(settings, "google_client_id", "cid")
|
| 90 |
+
monkeypatch.setattr(settings, "google_client_secret", "secret")
|
| 91 |
+
assert client.get("/api/auth/providers").json() == {"google": "redirect"}
|
| 92 |
+
|
| 93 |
+
r = client.get("/api/auth/google/start", params={"next": next_path}, follow_redirects=False)
|
| 94 |
+
assert r.status_code == 302
|
| 95 |
+
q = parse_qs(urlparse(r.headers["location"]).query)
|
| 96 |
+
assert q["redirect_uri"] == ["http://localhost:8000/api/auth/google/callback"]
|
| 97 |
+
monkeypatch.setattr(auth_router, "exchange_google_code", lambda code: {"nonce": q["nonce"][0], **claims})
|
| 98 |
+
r = client.get("/api/auth/google/callback", params={"code": "abc", "state": q["state"][0]},
|
| 99 |
+
follow_redirects=False)
|
| 100 |
+
assert r.status_code == 302
|
| 101 |
+
location = urlparse(r.headers["location"])
|
| 102 |
+
return location, parse_qs(location.fragment)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_google_redirect_flow(client, monkeypatch):
|
| 106 |
+
claims = {"email": "Star@Example.com", "email_verified": True, "sub": "g-1", "name": "Star"}
|
| 107 |
+
location, frag = _google_redirect(client, monkeypatch, claims)
|
| 108 |
+
assert location.path == "/auth/callback"
|
| 109 |
+
assert frag["next"] == ["/app/notebooks"]
|
| 110 |
+
r = client.post("/api/auth/ticket", json={"ticket": frag["ticket"][0]})
|
| 111 |
+
assert r.status_code == 200
|
| 112 |
+
data = r.json()
|
| 113 |
+
assert data["created"] is True and data["user"]["email"] == "star@example.com"
|
| 114 |
+
me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {data['access_token']}"})
|
| 115 |
+
assert me.json()["auth_provider"] == "google"
|
| 116 |
+
# A ticket is not a session token.
|
| 117 |
+
assert client.get("/api/auth/me", headers={"Authorization": f"Bearer {frag['ticket'][0]}"}).status_code == 401
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_google_redirect_rejects_open_redirect_and_email_accounts(client, monkeypatch):
|
| 121 |
+
_signup(client, email="taken@example.com")
|
| 122 |
+
claims = {"email": "taken@example.com", "email_verified": True, "sub": "g-2"}
|
| 123 |
+
location, frag = _google_redirect(client, monkeypatch, claims, next_path="//evil.example")
|
| 124 |
+
assert location.path == "/auth"
|
| 125 |
+
assert "password" in frag["error"][0]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_google_callback_requires_state_cookie(client, monkeypatch):
|
| 129 |
+
from datetime import timedelta
|
| 130 |
+
|
| 131 |
+
from app.auth import encode_signed
|
| 132 |
+
|
| 133 |
+
state = encode_signed({"typ": "oauth_state", "nonce": "n", "next": "/app"}, timedelta(minutes=5))
|
| 134 |
+
r = client.get("/api/auth/google/callback", params={"code": "abc", "state": state}, follow_redirects=False)
|
| 135 |
+
assert r.headers["location"].startswith("http://localhost:5173/auth#error=")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def test_safe_next():
|
| 139 |
+
from app.routers.auth import _safe_next
|
| 140 |
+
|
| 141 |
+
assert _safe_next("/app/notebooks/1?x=1") == "/app/notebooks/1?x=1"
|
| 142 |
+
for bad in (None, "", "https://evil.example", "//evil.example", r"/\evil.example"):
|
| 143 |
+
assert _safe_next(bad) == "/app"
|
docker-compose.yml
CHANGED
|
@@ -30,6 +30,8 @@ services:
|
|
| 30 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 31 |
RENDERER_URL: http://renderer:3100
|
| 32 |
API_URL: http://api:8000
|
|
|
|
|
|
|
| 33 |
CORPUS_CACHE_DIR: /cache/corpus
|
| 34 |
volumes: [apicache:/cache]
|
| 35 |
ports: ["8000:8000"]
|
|
|
|
| 30 |
R2_ENDPOINT_URL: ${R2_ENDPOINT_URL:-http://minio:9000}
|
| 31 |
RENDERER_URL: http://renderer:3100
|
| 32 |
API_URL: http://api:8000
|
| 33 |
+
# API_URL above is the in-network name; Google must redirect the browser to the published port.
|
| 34 |
+
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:8000/api/auth/google/callback}
|
| 35 |
CORPUS_CACHE_DIR: /cache/corpus
|
| 36 |
volumes: [apicache:/cache]
|
| 37 |
ports: ["8000:8000"]
|
frontend/src/App.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import AppShell, { ComingSoon } from "./components/AppShell";
|
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
| 5 |
import SkyCanvas from "./components/SkyCanvas";
|
|
|
|
| 6 |
import AuthPage from "./pages/AuthPage";
|
| 7 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 8 |
import Landing from "./pages/Landing";
|
|
@@ -32,6 +33,7 @@ export default function App() {
|
|
| 32 |
<Route path="/blogs" element={<Blog />} />
|
| 33 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 34 |
<Route path="/auth" element={<AuthPage />} />
|
|
|
|
| 35 |
<Route path="/app" element={<AppShell />}>
|
| 36 |
<Route index element={<MissionControl />} />
|
| 37 |
<Route path="notebooks" element={<MissionControl />} />
|
|
|
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
| 5 |
import SkyCanvas from "./components/SkyCanvas";
|
| 6 |
+
import AuthCallback from "./pages/AuthCallback";
|
| 7 |
import AuthPage from "./pages/AuthPage";
|
| 8 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 9 |
import Landing from "./pages/Landing";
|
|
|
|
| 33 |
<Route path="/blogs" element={<Blog />} />
|
| 34 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 35 |
<Route path="/auth" element={<AuthPage />} />
|
| 36 |
+
<Route path="/auth/callback" element={<AuthCallback />} />
|
| 37 |
<Route path="/app" element={<AppShell />}>
|
| 38 |
<Route index element={<MissionControl />} />
|
| 39 |
<Route path="notebooks" element={<MissionControl />} />
|
frontend/src/api/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { api, post } from "./client";
|
| 2 |
|
| 3 |
export type User = {
|
| 4 |
id: string;
|
|
@@ -10,9 +10,21 @@ export type User = {
|
|
| 10 |
|
| 11 |
export type LoginResult = { access_token: string; refresh_token: string; user: User; created: boolean };
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export const authApi = {
|
| 14 |
me: () => api<User>("/api/auth/me"),
|
|
|
|
| 15 |
google: (credential: string) => post<LoginResult>("/api/auth/google", { credential }),
|
|
|
|
|
|
|
|
|
|
| 16 |
registerStart: (email: string, password: string, name?: string) =>
|
| 17 |
post<{ ok: true }>("/api/auth/email/register/start", { email, password, name }),
|
| 18 |
registerVerify: (email: string, code: string) =>
|
|
|
|
| 1 |
+
import { api, BASE, post } from "./client";
|
| 2 |
|
| 3 |
export type User = {
|
| 4 |
id: string;
|
|
|
|
| 10 |
|
| 11 |
export type LoginResult = { access_token: string; refresh_token: string; user: User; created: boolean };
|
| 12 |
|
| 13 |
+
export type Providers = { google: "redirect" | "popup" | null };
|
| 14 |
+
|
| 15 |
+
/** Only same site paths, mirroring the backend check, so `next` cannot become an open redirect. */
|
| 16 |
+
export function safeNext(path: string | null | undefined): string {
|
| 17 |
+
if (!path || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return "/app";
|
| 18 |
+
return path;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
export const authApi = {
|
| 22 |
me: () => api<User>("/api/auth/me"),
|
| 23 |
+
providers: () => api<Providers>("/api/auth/providers"),
|
| 24 |
google: (credential: string) => post<LoginResult>("/api/auth/google", { credential }),
|
| 25 |
+
/** Full page navigation target for the redirect flow (server side code exchange). */
|
| 26 |
+
googleStartUrl: (next: string) => `${BASE}/api/auth/google/start?${new URLSearchParams({ next })}`,
|
| 27 |
+
redeemTicket: (ticket: string) => post<LoginResult>("/api/auth/ticket", { ticket }),
|
| 28 |
registerStart: (email: string, password: string, name?: string) =>
|
| 29 |
post<{ ok: true }>("/api/auth/email/register/start", { email, password, name }),
|
| 30 |
registerVerify: (email: string, code: string) =>
|
frontend/src/api/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
const BASE = import.meta.env.VITE_API_URL ?? "";
|
| 2 |
const ACCESS = "ns_access";
|
| 3 |
const REFRESH = "ns_refresh";
|
| 4 |
|
|
|
|
| 1 |
+
export const BASE = import.meta.env.VITE_API_URL ?? "";
|
| 2 |
const ACCESS = "ns_access";
|
| 3 |
const REFRESH = "ns_refresh";
|
| 4 |
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { NavLink, Outlet, Navigate } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import Logo from "./Logo";
|
| 4 |
import SkyCanvas from "./SkyCanvas";
|
|
@@ -33,8 +33,12 @@ const NAV = [
|
|
| 33 |
|
| 34 |
export default function AppShell() {
|
| 35 |
const { user, loading, logout } = useAuth();
|
|
|
|
| 36 |
if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
|
| 37 |
-
if (!user)
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
return (
|
| 40 |
<div className="shell">
|
|
|
|
| 1 |
+
import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import Logo from "./Logo";
|
| 4 |
import SkyCanvas from "./SkyCanvas";
|
|
|
|
| 33 |
|
| 34 |
export default function AppShell() {
|
| 35 |
const { user, loading, logout } = useAuth();
|
| 36 |
+
const location = useLocation();
|
| 37 |
if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
|
| 38 |
+
if (!user) {
|
| 39 |
+
const next = new URLSearchParams({ next: location.pathname + location.search });
|
| 40 |
+
return <Navigate to={`/auth?${next}`} replace />;
|
| 41 |
+
}
|
| 42 |
|
| 43 |
return (
|
| 44 |
<div className="shell">
|
frontend/src/pages/AuthCallback.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef } from "react";
|
| 2 |
+
import { useNavigate } from "react-router-dom";
|
| 3 |
+
import { authApi, safeNext } from "../api/auth";
|
| 4 |
+
import { useAuth } from "../hooks/useAuth";
|
| 5 |
+
|
| 6 |
+
/** Landing spot for the Google redirect flow: swaps the one minute ticket in the URL fragment for a session. */
|
| 7 |
+
export default function AuthCallback() {
|
| 8 |
+
const { complete } = useAuth();
|
| 9 |
+
const navigate = useNavigate();
|
| 10 |
+
const started = useRef(false);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
if (started.current) return; // StrictMode runs effects twice; redeem once
|
| 14 |
+
started.current = true;
|
| 15 |
+
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
| 16 |
+
const ticket = fragment.get("ticket");
|
| 17 |
+
const next = safeNext(fragment.get("next"));
|
| 18 |
+
window.history.replaceState(null, "", window.location.pathname); // keep the ticket out of history
|
| 19 |
+
if (!ticket) {
|
| 20 |
+
navigate("/auth", { replace: true });
|
| 21 |
+
return;
|
| 22 |
+
}
|
| 23 |
+
authApi
|
| 24 |
+
.redeemTicket(ticket)
|
| 25 |
+
.then((result) => {
|
| 26 |
+
complete(result);
|
| 27 |
+
navigate(next, { replace: true });
|
| 28 |
+
})
|
| 29 |
+
.catch(() => {
|
| 30 |
+
const error = new URLSearchParams({ error: "Google sign in expired. Try again." });
|
| 31 |
+
navigate(`/auth#${error}`, { replace: true });
|
| 32 |
+
});
|
| 33 |
+
}, [complete, navigate]);
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="boot">
|
| 37 |
+
<div className="orbit-loader">
|
| 38 |
+
<span />
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
frontend/src/pages/AuthPage.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
-
import { authApi, type LoginResult } from "../api/auth";
|
| 4 |
import { ApiError } from "../api/client";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
|
@@ -25,6 +25,20 @@ const GOOGLE_CLIENT_ID = (import.meta.env.VITE_GOOGLE_CLIENT_ID || import.meta.e
|
|
| 25 |
| string
|
| 26 |
| undefined;
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
function GoogleButton({ onCredential }: { onCredential: (c: string) => void }) {
|
| 29 |
const ref = useRef<HTMLDivElement>(null);
|
| 30 |
useEffect(() => {
|
|
@@ -64,12 +78,29 @@ export default function AuthPage() {
|
|
| 64 |
const [error, setError] = useState<string | null>(null);
|
| 65 |
const [notice, setNotice] = useState<string | null>(null);
|
| 66 |
const [busy, setBusy] = useState(false);
|
|
|
|
| 67 |
const { complete, user } = useAuth();
|
| 68 |
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
|
|
|
| 70 |
useEffect(() => {
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
const run = async (fn: () => Promise<LoginResult | void>) => {
|
| 75 |
setBusy(true);
|
|
@@ -133,7 +164,12 @@ export default function AuthPage() {
|
|
| 133 |
|
| 134 |
{(mode === "signin" || mode === "signup") && (
|
| 135 |
<>
|
| 136 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
<div className="auth-divider mono">
|
| 138 |
<span>or with email</span>
|
| 139 |
</div>
|
|
|
|
| 1 |
import { useEffect, useRef, useState, type FormEvent } from "react";
|
| 2 |
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
| 3 |
+
import { authApi, safeNext, type LoginResult, type Providers } from "../api/auth";
|
| 4 |
import { ApiError } from "../api/client";
|
| 5 |
import Logo from "../components/Logo";
|
| 6 |
import SkyCanvas from "../components/SkyCanvas";
|
|
|
|
| 25 |
| string
|
| 26 |
| undefined;
|
| 27 |
|
| 28 |
+
function GoogleRedirectButton({ next }: { next: string }) {
|
| 29 |
+
return (
|
| 30 |
+
<a className="btn google-redirect-btn" href={authApi.googleStartUrl(next)}>
|
| 31 |
+
<svg width="18" height="18" viewBox="0 0 48 48" aria-hidden="true">
|
| 32 |
+
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.4-.4-3.5z" />
|
| 33 |
+
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" />
|
| 34 |
+
<path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.5 39.6 16.2 44 24 44z" />
|
| 35 |
+
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.4-.4-3.5z" />
|
| 36 |
+
</svg>
|
| 37 |
+
Continue with Google
|
| 38 |
+
</a>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
function GoogleButton({ onCredential }: { onCredential: (c: string) => void }) {
|
| 43 |
const ref = useRef<HTMLDivElement>(null);
|
| 44 |
useEffect(() => {
|
|
|
|
| 78 |
const [error, setError] = useState<string | null>(null);
|
| 79 |
const [notice, setNotice] = useState<string | null>(null);
|
| 80 |
const [busy, setBusy] = useState(false);
|
| 81 |
+
const [providers, setProviders] = useState<Providers | null>(null);
|
| 82 |
const { complete, user } = useAuth();
|
| 83 |
const navigate = useNavigate();
|
| 84 |
+
const next = safeNext(params.get("next"));
|
| 85 |
+
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (user) navigate(next, { replace: true });
|
| 88 |
+
}, [user, navigate, next]);
|
| 89 |
+
|
| 90 |
+
useEffect(() => {
|
| 91 |
+
authApi
|
| 92 |
+
.providers()
|
| 93 |
+
.then(setProviders)
|
| 94 |
+
.catch(() => setProviders({ google: GOOGLE_CLIENT_ID ? "popup" : null }));
|
| 95 |
+
}, []);
|
| 96 |
|
| 97 |
+
// The Google redirect flow lands back here with #error=... when it fails.
|
| 98 |
useEffect(() => {
|
| 99 |
+
const failure = new URLSearchParams(window.location.hash.slice(1)).get("error");
|
| 100 |
+
if (!failure) return;
|
| 101 |
+
setError(failure);
|
| 102 |
+
window.history.replaceState(null, "", window.location.pathname + window.location.search);
|
| 103 |
+
}, []);
|
| 104 |
|
| 105 |
const run = async (fn: () => Promise<LoginResult | void>) => {
|
| 106 |
setBusy(true);
|
|
|
|
| 164 |
|
| 165 |
{(mode === "signin" || mode === "signup") && (
|
| 166 |
<>
|
| 167 |
+
{providers?.google === "redirect" && <GoogleRedirectButton next={next} />}
|
| 168 |
+
{providers?.google === "popup" && <GoogleButton onCredential={onGoogle} />}
|
| 169 |
+
{providers && !providers.google && (
|
| 170 |
+
<p className="mono muted auth-hint">Set GOOGLE_CLIENT_ID in .env to enable Google sign in.</p>
|
| 171 |
+
)}
|
| 172 |
+
{!providers && <div className="google-btn" />}
|
| 173 |
<div className="auth-divider mono">
|
| 174 |
<span>or with email</span>
|
| 175 |
</div>
|
frontend/src/styles/app.css
CHANGED
|
@@ -726,6 +726,18 @@
|
|
| 726 |
min-height: 44px;
|
| 727 |
}
|
| 728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
.auth-hint {
|
| 730 |
text-align: center;
|
| 731 |
}
|
|
|
|
| 726 |
min-height: 44px;
|
| 727 |
}
|
| 728 |
|
| 729 |
+
.google-redirect-btn {
|
| 730 |
+
width: 100%;
|
| 731 |
+
min-height: 44px;
|
| 732 |
+
background: var(--white);
|
| 733 |
+
color: var(--black);
|
| 734 |
+
border-color: var(--white);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.google-redirect-btn:hover {
|
| 738 |
+
box-shadow: var(--glow-md);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
.auth-hint {
|
| 742 |
text-align: center;
|
| 743 |
}
|
frontend/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/stream.ts","./src/components/appshell.tsx","./src/components/logo.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/skycanvas.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx"],"version":"5.6.3"}
|
|
|
|
| 1 |
+
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/client.ts","./src/api/stream.ts","./src/components/appshell.tsx","./src/components/logo.tsx","./src/components/pricingtiers.tsx","./src/components/publicchrome.tsx","./src/components/skycanvas.tsx","./src/components/icons/icons.tsx","./src/content/blogposts.ts","./src/content/seotypes.ts","./src/hooks/useauth.tsx","./src/pages/authcallback.tsx","./src/pages/authpage.tsx","./src/pages/blog.tsx","./src/pages/landing.tsx","./src/pages/missioncontrol.tsx","./src/pages/notebookview.tsx"],"version":"5.6.3"}
|