Arslan1997 commited on
Commit
013ff92
·
1 Parent(s): 7299acc

fixed auth

Browse files
.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
- from datetime import UTC, datetime
 
 
2
 
3
- from fastapi import APIRouter, Depends, HTTPException
 
 
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 create_access_token, create_refresh_token, get_current_user, load_user_from_token
 
 
 
 
 
 
 
 
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 _finalize_login(db: Session, user: User, created: bool) -> dict:
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) return <Navigate to="/auth" replace />;
 
 
 
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
- if (user) navigate("/app", { replace: true });
72
- }, [user, navigate]);
 
 
 
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
- <GoogleButton onCredential={onGoogle} />
 
 
 
 
 
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"}