Skip to content

Commit de42f8b

Browse files
committed
Add explicit workspace auth handoff
1 parent 43d02aa commit de42f8b

9 files changed

Lines changed: 339 additions & 3 deletions

File tree

backend/auth_models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,25 @@ class GoogleSignInExchangeRequestModel(BaseModel):
2323
@classmethod
2424
def _strip_text(cls, value):
2525
return str(value or "").strip()
26+
27+
28+
class WorkspaceHandoffStartRequestModel(BaseModel):
29+
model_config = ConfigDict(extra="forbid")
30+
31+
target_url: str = Field(default="", max_length=500)
32+
33+
@field_validator("target_url", mode="before")
34+
@classmethod
35+
def _strip_target_url(cls, value):
36+
return str(value or "").strip()
37+
38+
39+
class WorkspaceHandoffExchangeRequestModel(BaseModel):
40+
model_config = ConfigDict(extra="forbid")
41+
42+
handoff_token: str = Field(min_length=1, max_length=200)
43+
44+
@field_validator("handoff_token", mode="before")
45+
@classmethod
46+
def _strip_handoff_token(cls, value):
47+
return str(value or "").strip()

backend/routers/auth.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
from backend.auth_models import (
44
GoogleSignInExchangeRequestModel,
55
GoogleSignInStartRequestModel,
6+
WorkspaceHandoffExchangeRequestModel,
7+
WorkspaceHandoffStartRequestModel,
68
)
79
from backend.request_auth import get_optional_auth_tokens
810
from backend.services.auth_cookies import (
911
clear_auth_cookies,
1012
set_auth_cookies,
1113
)
14+
from backend.services.auth_handoff_service import (
15+
exchange_workspace_handoff,
16+
start_workspace_handoff,
17+
)
1218
from backend.services.auth_session_service import (
1319
exchange_google_code,
1420
restore_authenticated_session,
@@ -95,6 +101,37 @@ def restore_session_route(
95101
_raise_http_error(error)
96102

97103

104+
@router.post("/workspace-handoff/start")
105+
def start_workspace_handoff_route(
106+
payload: WorkspaceHandoffStartRequestModel,
107+
auth_tokens=Depends(get_optional_auth_tokens),
108+
):
109+
access_token, refresh_token = auth_tokens
110+
try:
111+
return start_workspace_handoff(
112+
access_token=access_token or "",
113+
refresh_token=refresh_token or "",
114+
target_url=payload.target_url,
115+
)
116+
except AppError as error:
117+
_raise_http_error(error)
118+
119+
120+
@router.post("/workspace-handoff/exchange")
121+
def exchange_workspace_handoff_route(
122+
payload: WorkspaceHandoffExchangeRequestModel,
123+
response: Response,
124+
):
125+
try:
126+
result = exchange_workspace_handoff(
127+
handoff_token=payload.handoff_token,
128+
)
129+
_apply_session_cookies(response, result)
130+
return _scrub_session_tokens(result)
131+
except AppError as error:
132+
_raise_http_error(error)
133+
134+
98135
@router.post("/session/sign-out")
99136
def sign_out_route(
100137
response: Response,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
import threading
4+
import time
5+
import uuid
6+
from dataclasses import dataclass, field
7+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
8+
9+
from src.errors import InputValidationError
10+
11+
from backend.services.auth_session_service import restore_authenticated_session
12+
13+
14+
HANDOFF_TTL_SECONDS = 60
15+
16+
17+
@dataclass
18+
class WorkspaceHandoffRecord:
19+
access_token: str
20+
refresh_token: str
21+
created_at: float = field(default_factory=time.time)
22+
23+
24+
_HANDOFFS: dict[str, WorkspaceHandoffRecord] = {}
25+
_LOCK = threading.Lock()
26+
27+
28+
def _prune_handoffs() -> None:
29+
cutoff = time.time() - HANDOFF_TTL_SECONDS
30+
stale_tokens = [
31+
token
32+
for token, record in _HANDOFFS.items()
33+
if record.created_at < cutoff
34+
]
35+
for token in stale_tokens:
36+
_HANDOFFS.pop(token, None)
37+
38+
39+
def _append_handoff_query(target_url: str, handoff_token: str) -> str:
40+
url = str(target_url or "").strip()
41+
parsed = urlsplit(url)
42+
query_items = [
43+
(key, value)
44+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
45+
if key != "handoff"
46+
]
47+
query_items.append(("handoff", handoff_token))
48+
return urlunsplit(
49+
(
50+
parsed.scheme,
51+
parsed.netloc,
52+
parsed.path,
53+
urlencode(query_items),
54+
parsed.fragment,
55+
)
56+
)
57+
58+
59+
def start_workspace_handoff(
60+
*,
61+
access_token: str,
62+
refresh_token: str,
63+
target_url: str,
64+
) -> dict:
65+
normalized_access = str(access_token or "").strip()
66+
normalized_refresh = str(refresh_token or "").strip()
67+
normalized_target = str(target_url or "").strip()
68+
69+
if not normalized_access or not normalized_refresh:
70+
raise InputValidationError("Sign in with Google before entering the workspace.")
71+
if not normalized_target:
72+
raise InputValidationError("A workspace target URL is required.")
73+
74+
with _LOCK:
75+
_prune_handoffs()
76+
handoff_token = uuid.uuid4().hex
77+
_HANDOFFS[handoff_token] = WorkspaceHandoffRecord(
78+
access_token=normalized_access,
79+
refresh_token=normalized_refresh,
80+
)
81+
82+
return {
83+
"status": "ready",
84+
"redirect_url": _append_handoff_query(normalized_target, handoff_token),
85+
}
86+
87+
88+
def exchange_workspace_handoff(*, handoff_token: str) -> dict:
89+
normalized_token = str(handoff_token or "").strip()
90+
if not normalized_token:
91+
raise InputValidationError("A workspace handoff token is required.")
92+
93+
with _LOCK:
94+
_prune_handoffs()
95+
record = _HANDOFFS.pop(normalized_token, None)
96+
97+
if record is None:
98+
raise InputValidationError(
99+
"That workspace handoff expired. Open the workspace from the landing page again."
100+
)
101+
102+
return restore_authenticated_session(
103+
access_token=record.access_token,
104+
refresh_token=record.refresh_token,
105+
)

frontend/src/components/landing-page.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
exchangeGoogleCode,
88
restoreAuthSession,
99
signOutAuthSession,
10+
startWorkspaceHandoff,
1011
startGoogleSignIn,
1112
} from "@/lib/api";
1213
import type { AuthSessionResponse } from "@/lib/api-types";
@@ -147,6 +148,24 @@ export function LandingPage() {
147148
}
148149
}
149150

151+
async function handleEnterWorkspace() {
152+
setAuthActionLoading(true);
153+
setAuthError(null);
154+
try {
155+
const response = await startWorkspaceHandoff(
156+
"https://app.job-application-copilot.xyz",
157+
);
158+
window.location.href = response.redirect_url;
159+
} catch (error) {
160+
setAuthError(
161+
error instanceof Error
162+
? error.message
163+
: "Workspace handoff failed unexpectedly.",
164+
);
165+
setAuthActionLoading(false);
166+
}
167+
}
168+
150169
const isSignedIn = authStatus === "signed_in";
151170
const signedInLabel =
152171
authSession?.app_user.display_name ||
@@ -207,9 +226,14 @@ export function LandingPage() {
207226

208227
<div className="hero-actions">
209228
{isSignedIn ? (
210-
<Link href="https://app.job-application-copilot.xyz" className="primary-button">
211-
Enter workspace
212-
</Link>
229+
<button
230+
className="primary-button"
231+
disabled={authActionLoading}
232+
onClick={() => void handleEnterWorkspace()}
233+
type="button"
234+
>
235+
{authActionLoading ? "Opening workspace..." : "Enter workspace"}
236+
</button>
213237
) : (
214238
<button
215239
className="primary-button"

frontend/src/hooks/useWorkspaceSession.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
} from "react";
2727

2828
import {
29+
exchangeWorkspaceHandoff,
2930
exchangeGoogleCode,
3031
loadSavedWorkspace,
3132
restoreAuthSession,
@@ -132,6 +133,7 @@ export function useWorkspaceSession({
132133
clearLegacyAuthTokens();
133134

134135
const params = new URLSearchParams(window.location.search);
136+
const handoffToken = params.get("handoff");
135137
const authCode = params.get("code");
136138
const authFlow = params.get("auth_flow") ?? "";
137139
const authErrorDescription =
@@ -147,6 +149,39 @@ export function useWorkspaceSession({
147149
return;
148150
}
149151

152+
if (handoffToken) {
153+
setAuthStatus("restoring");
154+
setAuthError(null);
155+
try {
156+
const response = await exchangeWorkspaceHandoff(handoffToken);
157+
if (!cancelled) {
158+
setAuthSession(response);
159+
setAuthStatus("signed_in");
160+
setNotice({
161+
level: "success",
162+
message: `Signed in as ${
163+
response.app_user.display_name ||
164+
response.app_user.email ||
165+
"your account"
166+
}.`,
167+
});
168+
}
169+
} catch (error) {
170+
if (!cancelled) {
171+
setAuthSession(null);
172+
setAuthStatus("signed_out");
173+
setAuthError(
174+
error instanceof Error
175+
? error.message
176+
: "Workspace handoff failed unexpectedly.",
177+
);
178+
}
179+
} finally {
180+
clearAuthQueryParams();
181+
}
182+
return;
183+
}
184+
150185
if (authCode) {
151186
setAuthStatus("restoring");
152187
setAuthError(null);

frontend/src/lib/api-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,11 @@ export type GoogleSignInStartResponse = {
521521
redirect_url: string;
522522
};
523523

524+
export type WorkspaceHandoffStartResponse = {
525+
status: string;
526+
redirect_url: string;
527+
};
528+
524529
export type SavedWorkspaceMeta = {
525530
job_title: string;
526531
expires_at: string;

frontend/src/lib/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
WorkspaceArtifactExportResponse,
2424
WorkspaceArtifactPreviewRequest,
2525
WorkspaceArtifactPreviewResponse,
26+
WorkspaceHandoffStartResponse,
2627
AssistantStreamEvent,
2728
WorkspaceAssistantRequest,
2829
WorkspaceAssistantResponse,
@@ -176,6 +177,26 @@ export async function signOutAuthSession() {
176177
});
177178
}
178179

180+
export async function startWorkspaceHandoff(targetUrl: string) {
181+
return request<WorkspaceHandoffStartResponse>("/auth/workspace-handoff/start", {
182+
method: "POST",
183+
headers: {
184+
"Content-Type": "application/json",
185+
},
186+
body: JSON.stringify({ target_url: targetUrl }),
187+
});
188+
}
189+
190+
export async function exchangeWorkspaceHandoff(handoffToken: string) {
191+
return request<AuthSessionResponse>("/auth/workspace-handoff/exchange", {
192+
method: "POST",
193+
headers: {
194+
"Content-Type": "application/json",
195+
},
196+
body: JSON.stringify({ handoff_token: handoffToken }),
197+
});
198+
}
199+
179200
export async function uploadResumeFile(file: File) {
180201
const payload = await fileToUploadPayload(file);
181202
return request<WorkspaceResumeUploadResponse>("/workspace/resume/upload", {

frontend/src/lib/auth-session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export function clearAuthQueryParams() {
3333
url.searchParams.delete("auth_flow");
3434
url.searchParams.delete("error");
3535
url.searchParams.delete("error_description");
36+
url.searchParams.delete("handoff");
3637
window.history.replaceState({}, document.title, url.toString());
3738
}
3839

0 commit comments

Comments
 (0)