From 3841b8aec240cec43dd240acbbfb4699eea5fac5 Mon Sep 17 00:00:00 2001 From: Pepe Garcia Date: Thu, 11 Dec 2025 10:08:39 +0000 Subject: [PATCH 1/2] feat: add personal access token authentication --- src/api/api.ts | 54 +++++++++++ src/browser/components/welcome-dialog.tsx | 108 ++++++++++++++++++++++ src/browser/contexts/auth.tsx | 57 ++++++++++++ 3 files changed, 219 insertions(+) diff --git a/src/api/api.ts b/src/api/api.ts index 5b815a0..5c90066 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -91,6 +91,60 @@ const api = new Hono() } catch (err) { return c.json({ error: (err as Error).message }, 500); } + }) + + // Validate Personal Access Token + // Tests the token against GitHub API and returns user info and scope validation + .post("/auth/validate-token", async (c) => { + try { + const body = await c.req.json(); + const { token } = body; + + // Validate token format + if (!token || typeof token !== "string") { + return c.json({ valid: false, error: "Invalid token format" }, 400); + } + + // Test token against GitHub API + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Pulldash", + }, + }); + + if (!response.ok) { + const statusCode = response.status === 401 ? 401 : 500; + return c.json( + { + valid: false, + error: + response.status === 401 + ? "Invalid or expired token" + : "GitHub API error", + }, + statusCode + ); + } + + const userData = await response.json(); + + // Check token scopes (if available in headers) + const scopes = response.headers.get("x-oauth-scopes") || ""; + const hasRequiredScopes = scopes.includes("repo"); + + return c.json({ + valid: true, + user: userData.login, + hasRequiredScopes, + }); + } catch (err) { + return c.json( + { valid: false, error: "Validation failed" }, + 500 + ); + } }); export default api; diff --git a/src/browser/components/welcome-dialog.tsx b/src/browser/components/welcome-dialog.tsx index b18f35e..4b7e4b9 100644 --- a/src/browser/components/welcome-dialog.tsx +++ b/src/browser/components/welcome-dialog.tsx @@ -174,6 +174,112 @@ const reviewFiles = [ }, ]; +// ============================================================================ +// PAT Authentication Section +// ============================================================================ + +function PATAuthSection() { + const { loginWithPAT } = useAuth(); + const [showPATInput, setShowPATInput] = useState(false); + const [patToken, setPatToken] = useState(""); + const [patError, setPatError] = useState(null); + const [isValidatingPAT, setIsValidatingPAT] = useState(false); + + const handlePATLogin = async () => { + setPatError(null); + setIsValidatingPAT(true); + + try { + await loginWithPAT(patToken); + // Success - dialog should close automatically via auth context + } catch (error) { + setPatError(error instanceof Error ? error.message : "Authentication failed"); + } finally { + setIsValidatingPAT(false); + } + }; + + return ( +
+ + + {showPATInput && ( +
+
+ + setPatToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxx" + className="w-full px-3 py-2 border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isValidatingPAT} + onKeyDown={(e) => { + if (e.key === "Enter" && patToken && !isValidatingPAT) { + handlePATLogin(); + } + }} + /> +
+ + {patError && ( +
+ {patError} +
+ )} + +
+

Required scopes:

+
    +
  • + repo - Access repositories +
  • +
  • + read:user - Read user profile +
  • +
+ + Create a token on GitHub + + +
+ + +
+ )} +
+ ); +} + // ============================================================================ // Stage 1: Live PR Updates Animation // ============================================================================ @@ -1101,6 +1207,8 @@ export function WelcomeDialog() { )} + +

All GitHub API calls are made directly from your device. Pulldash does not store your GitHub token. diff --git a/src/browser/contexts/auth.tsx b/src/browser/contexts/auth.tsx index 90e1872..08f3817 100644 --- a/src/browser/contexts/auth.tsx +++ b/src/browser/contexts/auth.tsx @@ -61,6 +61,7 @@ interface AuthState { interface AuthContextValue extends AuthState { startDeviceAuth: () => Promise; cancelDeviceAuth: () => void; + loginWithPAT: (token: string) => Promise; logout: () => void; // Enable anonymous browsing mode enableAnonymousMode: () => void; @@ -369,6 +370,61 @@ export function AuthProvider({ children }: { children: ReactNode }) { })); }, [abortController]); + const loginWithPAT = useCallback(async (token: string): Promise => { + // Basic format validation + const trimmedToken = token.trim(); + if (!trimmedToken) { + throw new Error("Token cannot be empty"); + } + + // Validate token format (GitHub PAT prefixes) + if ( + !trimmedToken.startsWith("ghp_") && + !trimmedToken.startsWith("github_pat_") + ) { + throw new Error( + 'Invalid token format. GitHub tokens should start with "ghp_" or "github_pat_"' + ); + } + + // Validate token with backend + const response = await fetch("/api/auth/validate-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: trimmedToken }), + }); + + const result = await response.json(); + + if (!result.valid) { + throw new Error(result.error || "Token validation failed"); + } + + // Warn if token doesn't have required scopes + if (!result.hasRequiredScopes) { + console.warn('Token may not have required "repo" scope'); + } + + // Store token (same mechanism as device flow) + storeToken(trimmedToken); + setStoredAnonymousMode(false); + setState({ + isAuthenticated: true, + isLoading: false, + token: trimmedToken, + deviceAuth: { + status: "idle", + userCode: null, + verificationUri: null, + error: null, + }, + isAnonymous: false, + isRateLimited: false, + }); + + console.log("Successfully authenticated with PAT as user:", result.user); + }, []); + const logout = useCallback(() => { clearStoredToken(); setStoredAnonymousMode(false); @@ -399,6 +455,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ...state, startDeviceAuth, cancelDeviceAuth, + loginWithPAT, logout, enableAnonymousMode, canWrite: state.isAuthenticated && !state.isAnonymous, From b4cd3d2ee3c0db149c6db2943e8343b1785e10b7 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:01:03 +0000 Subject: [PATCH 2/2] refactor: move PAT validation to client-side - Remove /api/auth/validate-token server endpoint - Validate PAT directly against GitHub API from client (CORS supported) - Enforce repo scope requirement with clear error message - Improve PAT input UI with cleaner layout and better UX - Add escape key to cancel, autofocus input field --- src/api/api.ts | 54 -------- src/browser/components/welcome-dialog.tsx | 157 ++++++++++++---------- src/browser/contexts/auth.tsx | 36 +++-- 3 files changed, 108 insertions(+), 139 deletions(-) diff --git a/src/api/api.ts b/src/api/api.ts index 5c90066..5b815a0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -91,60 +91,6 @@ const api = new Hono() } catch (err) { return c.json({ error: (err as Error).message }, 500); } - }) - - // Validate Personal Access Token - // Tests the token against GitHub API and returns user info and scope validation - .post("/auth/validate-token", async (c) => { - try { - const body = await c.req.json(); - const { token } = body; - - // Validate token format - if (!token || typeof token !== "string") { - return c.json({ valid: false, error: "Invalid token format" }, 400); - } - - // Test token against GitHub API - const response = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github.v3+json", - "User-Agent": "Pulldash", - }, - }); - - if (!response.ok) { - const statusCode = response.status === 401 ? 401 : 500; - return c.json( - { - valid: false, - error: - response.status === 401 - ? "Invalid or expired token" - : "GitHub API error", - }, - statusCode - ); - } - - const userData = await response.json(); - - // Check token scopes (if available in headers) - const scopes = response.headers.get("x-oauth-scopes") || ""; - const hasRequiredScopes = scopes.includes("repo"); - - return c.json({ - valid: true, - user: userData.login, - hasRequiredScopes, - }); - } catch (err) { - return c.json( - { valid: false, error: "Validation failed" }, - 500 - ); - } }); export default api; diff --git a/src/browser/components/welcome-dialog.tsx b/src/browser/components/welcome-dialog.tsx index 4b7e4b9..90c87e0 100644 --- a/src/browser/components/welcome-dialog.tsx +++ b/src/browser/components/welcome-dialog.tsx @@ -191,91 +191,106 @@ function PATAuthSection() { try { await loginWithPAT(patToken); - // Success - dialog should close automatically via auth context } catch (error) { - setPatError(error instanceof Error ? error.message : "Authentication failed"); + setPatError( + error instanceof Error ? error.message : "Authentication failed" + ); } finally { setIsValidatingPAT(false); } }; - return ( -

+ if (!showPATInput) { + return ( + ); + } - {showPATInput && ( -
-
- - setPatToken(e.target.value)} - placeholder="ghp_xxxxxxxxxxxx" - className="w-full px-3 py-2 border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500" - disabled={isValidatingPAT} - onKeyDown={(e) => { - if (e.key === "Enter" && patToken && !isValidatingPAT) { - handlePATLogin(); - } - }} - /> -
- - {patError && ( -
- {patError} -
+ return ( +
+
+ setPatToken(e.target.value)} + placeholder="Paste your token (ghp_... or github_pat_...)" + className={cn( + "w-full h-10 px-3 rounded-md border bg-background text-foreground text-sm", + "placeholder:text-muted-foreground", + "focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background", + "disabled:opacity-50 disabled:cursor-not-allowed", + patError && "border-destructive focus:ring-destructive" )} + disabled={isValidatingPAT} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && patToken && !isValidatingPAT) { + handlePATLogin(); + } + if (e.key === "Escape") { + setShowPATInput(false); + setPatToken(""); + setPatError(null); + } + }} + /> +
-
-

Required scopes:

-
    -
  • - repo - Access repositories -
  • -
  • - read:user - Read user profile -
  • -
- - Create a token on GitHub - - -
- - + {patError && ( +
+ + {patError}
)} + +
+ + +
+ +

+ Requires{" "} + repo{" "} + scope.{" "} + + Create token → + +

); } diff --git a/src/browser/contexts/auth.tsx b/src/browser/contexts/auth.tsx index 08f3817..6338e59 100644 --- a/src/browser/contexts/auth.tsx +++ b/src/browser/contexts/auth.tsx @@ -371,7 +371,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [abortController]); const loginWithPAT = useCallback(async (token: string): Promise => { - // Basic format validation const trimmedToken = token.trim(); if (!trimmedToken) { throw new Error("Token cannot be empty"); @@ -387,22 +386,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); } - // Validate token with backend - const response = await fetch("/api/auth/validate-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: trimmedToken }), + // Validate token directly with GitHub API (CORS is supported) + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${trimmedToken}`, + Accept: "application/vnd.github.v3+json", + }, }); - const result = await response.json(); - - if (!result.valid) { - throw new Error(result.error || "Token validation failed"); + if (!response.ok) { + if (response.status === 401) { + throw new Error("Invalid or expired token"); + } + throw new Error("Failed to validate token with GitHub"); } - // Warn if token doesn't have required scopes - if (!result.hasRequiredScopes) { - console.warn('Token may not have required "repo" scope'); + const userData = await response.json(); + + // Check token scopes from response headers + const scopes = response.headers.get("x-oauth-scopes") || ""; + const hasRepoScope = scopes.includes("repo"); + + if (!hasRepoScope) { + throw new Error( + 'Token is missing the required "repo" scope. Please create a new token with the repo scope.' + ); } // Store token (same mechanism as device flow) @@ -422,7 +430,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isRateLimited: false, }); - console.log("Successfully authenticated with PAT as user:", result.user); + console.log("Successfully authenticated with PAT as:", userData.login); }, []); const logout = useCallback(() => {