diff --git a/src/api/auth.ts b/src/api/auth.ts index d9389be..da5dd3c 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -14,7 +14,7 @@ import type { VerifyEmailResponse, } from "@/api/types"; import { AUTH_ENDPOINTS, CLIENT_HEADER, CLIENT_HEADER_VALUE } from "@/lib/constants"; -import { accessTokenStorage, refreshTokenStorage } from "@/lib/storage"; +import { accessTokenStorage, clearJwtSession, refreshTokenStorage } from "@/lib/storage"; import { loginResponseSchema, meResponseSchema, @@ -110,6 +110,13 @@ export async function refreshAccessToken(): Promise { await refreshTokenStorage.setValue(data.refresh_token); return true; } + + if (res.status === 401) { + // Grant revoked or refresh token expired — clear the session so we + // don't keep retrying a dead grant. + await clearJwtSession(); + } + // Transient failures leave tokens intact for a later retry. return false; } catch { return false; diff --git a/src/api/client.ts b/src/api/client.ts index f1af3f3..5f62b2c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,6 +5,7 @@ import { accessTokenStorage, apiKeyStorage, authModeStorage, + clearJwtSession, refreshTokenStorage, } from "@/lib/storage"; import { refreshResponseSchema } from "@/schemas/api"; @@ -47,11 +48,14 @@ async function refreshAccessToken(): Promise { body: JSON.stringify({ refresh_token: refreshToken }), }); + if (res.status === 401) { + // Grant revoked or refresh token expired — clear the session. + await clearJwtSession(); + return null; + } + if (!res.ok) { - // Refresh failed — clear auth state - await accessTokenStorage.setValue(null); - await refreshTokenStorage.setValue(null); - await authModeStorage.setValue("anonymous"); + // Transient failure (server/network) — keep tokens for a later retry. return null; } diff --git a/src/api/keys.ts b/src/api/keys.ts index fcdb0c2..9fd708e 100644 --- a/src/api/keys.ts +++ b/src/api/keys.ts @@ -1,20 +1,10 @@ import { request } from "@/api/client"; -import type { - ApiKeyActionResponse, - ApiKeyCreatedResponse, - ApiKeysListResponse, - CreateApiKeyRequest, -} from "@/api/types"; +import type { ApiKeyActionResponse, ApiKeysListResponse } from "@/api/types"; import { API_V1 } from "@/lib/constants"; -import { - apiKeyActionResponseSchema, - apiKeyCreatedResponseSchema, - apiKeysListResponseSchema, -} from "@/schemas/api"; +import { apiKeyActionResponseSchema, apiKeysListResponseSchema } from "@/schemas/api"; -export function createApiKey(data: CreateApiKeyRequest): Promise { - return request(`${API_V1}/keys`, { method: "POST", body: data }, apiKeyCreatedResponseSchema); -} +// API key creation is first-party (dashboard) only. Connected-app tokens can +// list and revoke keys but cannot mint them, so no createApiKey is exposed here. export function listApiKeys(): Promise { return request(`${API_V1}/keys`, {}, apiKeysListResponseSchema); diff --git a/src/api/types.ts b/src/api/types.ts index 195acc6..ebb86ec 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -272,20 +272,6 @@ export interface PublicStatsResponse { // ── API Keys ───────────────────────────────────────────────── -export type ApiKeyScope = - | "shorten:create" - | "urls:manage" - | "urls:read" - | "stats:read" - | "admin:all"; - -export interface CreateApiKeyRequest { - name: string; - description?: string; - scopes: ApiKeyScope[]; - expires_at?: string | number; -} - export interface ApiKeyResponse { id: string; name: string; @@ -297,10 +283,6 @@ export interface ApiKeyResponse { token_prefix: string | null; } -export interface ApiKeyCreatedResponse extends ApiKeyResponse { - token: string; -} - export interface ApiKeysListResponse { keys: ApiKeyResponse[]; } diff --git a/src/components/auth/AuthSection.tsx b/src/components/auth/AuthSection.tsx index dcef051..2341ee0 100644 --- a/src/components/auth/AuthSection.tsx +++ b/src/components/auth/AuthSection.tsx @@ -13,7 +13,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AUTH_ENDPOINTS } from "@/lib/constants"; -import { deviceAuthStateStorage } from "@/lib/storage"; +import { deriveCodeChallenge, generateCodeVerifier } from "@/lib/pkce"; +import { deviceAuthStateStorage, deviceAuthVerifierStorage } from "@/lib/storage"; import { useAuthStore } from "@/stores/auth"; /** @@ -76,13 +77,20 @@ function WebLoginForm() { setPending(true); setError(""); const state = crypto.randomUUID(); + const verifier = generateCodeVerifier(); + const challenge = await deriveCodeChallenge(verifier); await deviceAuthStateStorage.setValue(state); + await deviceAuthVerifierStorage.setValue(verifier); try { - await browser.tabs.create({ - url: `${AUTH_ENDPOINTS.deviceLogin}?app_id=spoo-snap&state=${state}`, - }); + const url = new URL(AUTH_ENDPOINTS.deviceLogin); + url.searchParams.set("app_id", "spoo-snap"); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); + await browser.tabs.create({ url: url.toString() }); } catch { await deviceAuthStateStorage.setValue(null); + await deviceAuthVerifierStorage.setValue(null); setPending(false); setError("Failed to open sign in page"); } @@ -102,6 +110,7 @@ function WebLoginForm() { onClick={() => { setPending(false); deviceAuthStateStorage.setValue(null); + deviceAuthVerifierStorage.setValue(null); }} > Cancel diff --git a/src/components/sidepanel/AccountTab.tsx b/src/components/sidepanel/AccountTab.tsx index d83b399..7c78849 100644 --- a/src/components/sidepanel/AccountTab.tsx +++ b/src/components/sidepanel/AccountTab.tsx @@ -1,13 +1,10 @@ -import { Copy, Key, Plus, Trash2 } from "lucide-react"; -import { useState } from "react"; -import type { ApiKeyScope } from "@/api/types"; +import { ExternalLink, Key, Trash2 } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { useLogout } from "@/hooks/use-auth"; -import { useApiKeys, useCreateApiKey, useDeleteApiKey } from "@/hooks/use-keys"; +import { useApiKeys, useDeleteApiKey } from "@/hooks/use-keys"; import { useAuthStore } from "@/stores/auth"; export function AccountTab() { @@ -107,24 +104,7 @@ function ProfileSection({ function ApiKeysSection() { const { data, isLoading, error } = useApiKeys(); - const createKey = useCreateApiKey(); const deleteKey = useDeleteApiKey(); - const [showCreate, setShowCreate] = useState(false); - const [newKeyName, setNewKeyName] = useState(""); - const [newKeyToken, setNewKeyToken] = useState(null); - - const handleCreate = () => { - const scopes: ApiKeyScope[] = ["shorten:create", "urls:read", "stats:read"]; - createKey.mutate( - { name: newKeyName, scopes }, - { - onSuccess: (data) => { - setNewKeyToken(data.token); - setNewKeyName(""); - }, - }, - ); - }; return (
@@ -132,75 +112,33 @@ function ApiKeysSection() {

API Keys

- + + New key +
- {showCreate && ( -
- {newKeyToken ? ( -
-

- Key created! Copy it now — you won't see it again. -

-
- - {newKeyToken} - - -
- -
- ) : ( - <> - setNewKeyName(e.target.value)} - placeholder="Key name (e.g. My Extension)" - className="h-8 text-sm" - /> - - - )} -
- )} - {isLoading &&
} {error &&

{error.message}

} - {data && data.keys.length === 0 && !showCreate && ( -

No API keys

+ {data && data.keys.length === 0 && ( +

+ No API keys. Create one on your{" "} + + dashboard + + . +

)} {data?.keys.map((key) => ( diff --git a/src/entrypoints/background/index.ts b/src/entrypoints/background/index.ts index 48fcb22..d2bfc81 100644 --- a/src/entrypoints/background/index.ts +++ b/src/entrypoints/background/index.ts @@ -17,6 +17,7 @@ import { showToastNotification } from "@/lib/notification"; import { accessTokenStorage, authModeStorage, + deviceAuthVerifierStorage, historyStorage, refreshTokenStorage, settingsStorage, @@ -127,22 +128,22 @@ async function processOfflineQueue(): Promise { } async function handleTokenRefresh(): Promise { - const refreshed = await refreshAccessToken(); - if (!refreshed) { - // Token expired or invalid — clean up and go anonymous - const hasRefresh = await refreshTokenStorage.getValue(); - if (hasRefresh) { - await Promise.all([accessTokenStorage.setValue(null), refreshTokenStorage.setValue(null)]); - await authModeStorage.setValue("anonymous"); - } - } + // A revoked grant or expired refresh token clears the session inside + // refreshAccessToken (401). Transient failures leave it intact to retry + // on the next alarm. + await refreshAccessToken(); } async function exchangeDeviceCode(code: string): Promise { + const codeVerifier = await deviceAuthVerifierStorage.getValue(); + if (!codeVerifier) { + throw new Error("Missing PKCE verifier — please start sign in again"); + } + const res = await fetch(AUTH_ENDPOINTS.deviceToken, { method: "POST", headers: { "Content-Type": "application/json", [CLIENT_HEADER]: CLIENT_HEADER_VALUE }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, code_verifier: codeVerifier }), }); const json = await res.json(); @@ -155,6 +156,7 @@ async function exchangeDeviceCode(code: string): Promise { accessTokenStorage.setValue(data.access_token), refreshTokenStorage.setValue(data.refresh_token), userProfileStorage.setValue(data.user), + deviceAuthVerifierStorage.setValue(null), ]); await authModeStorage.setValue("jwt"); diff --git a/src/hooks/use-keys.ts b/src/hooks/use-keys.ts index d2b2bad..f14e75b 100644 --- a/src/hooks/use-keys.ts +++ b/src/hooks/use-keys.ts @@ -1,6 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createApiKey, deleteApiKey, listApiKeys } from "@/api/keys"; -import type { CreateApiKeyRequest } from "@/api/types"; +import { deleteApiKey, listApiKeys } from "@/api/keys"; const KEYS_KEY = ["api-keys"] as const; @@ -11,16 +10,6 @@ export function useApiKeys() { }); } -export function useCreateApiKey() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: CreateApiKeyRequest) => createApiKey(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: KEYS_KEY }); - }, - }); -} - export function useDeleteApiKey() { const queryClient = useQueryClient(); return useMutation({ diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 0000000..3651f63 --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,28 @@ +/** + * PKCE (RFC 7636) helpers for the device authorization flow. + * + * Mirrors the S256 approach used by spoo-cli and spoo-raycast: a 32-byte + * random verifier encoded as unpadded base64url (43 chars), with an + * S256 challenge of BASE64URL(SHA-256(verifier)). + */ + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Generate a PKCE code verifier: 32 random bytes as unpadded base64url (43 chars). */ +export function generateCodeVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +/** Derive the S256 challenge for a verifier: BASE64URL(SHA-256(verifier)), unpadded. */ +export async function deriveCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return base64UrlEncode(new Uint8Array(digest)); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index cab9f65..d6b47e3 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -59,3 +59,22 @@ export const shortenQueueStorage = storage.defineItem("local:sh export const deviceAuthStateStorage = storage.defineItem("local:deviceAuthState", { fallback: null, }); + +/** PKCE code verifier for the in-flight device auth flow, sent at token exchange. */ +export const deviceAuthVerifierStorage = storage.defineItem( + "local:deviceAuthVerifier", + { fallback: null }, +); + +/** + * Clear a JWT session and return to anonymous. Used when a device grant is + * revoked or its refresh token has expired (a definitive 401 on refresh). + */ +export async function clearJwtSession(): Promise { + await Promise.all([ + accessTokenStorage.setValue(null), + refreshTokenStorage.setValue(null), + userProfileStorage.setValue(null), + ]); + await authModeStorage.setValue("anonymous"); +} diff --git a/src/schemas/api.ts b/src/schemas/api.ts index a2dff69..6fe82b6 100644 --- a/src/schemas/api.ts +++ b/src/schemas/api.ts @@ -190,10 +190,6 @@ export const apiKeyResponseSchema = z.object({ token_prefix: z.string().nullable(), }); -export const apiKeyCreatedResponseSchema = apiKeyResponseSchema.extend({ - token: z.string(), -}); - export const apiKeysListResponseSchema = z.object({ keys: z.array(apiKeyResponseSchema), }); diff --git a/src/stores/auth.ts b/src/stores/auth.ts index c10f341..102a711 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -67,11 +67,10 @@ export const useAuthStore = create((set) => ({ if (restored) { set({ mode: "jwt", user: restored, isLoading: false }); } else { - await Promise.all([ - accessTokenStorage.setValue(null), - refreshTokenStorage.setValue(null), - authModeStorage.setValue("anonymous"), - ]); + // A revoked grant / expired refresh token is cleared inside + // refreshAccessToken (401). A transient failure leaves the stored + // session untouched so a later launch can retry; we fall back to + // anonymous in memory for now without wiping the stored session. set({ mode: "anonymous", user: null, isLoading: false }); } } else {