Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,6 +110,13 @@ export async function refreshAccessToken(): Promise<boolean> {
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;
Expand Down
12 changes: 8 additions & 4 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
accessTokenStorage,
apiKeyStorage,
authModeStorage,
clearJwtSession,
refreshTokenStorage,
} from "@/lib/storage";
import { refreshResponseSchema } from "@/schemas/api";
Expand Down Expand Up @@ -47,11 +48,14 @@ async function refreshAccessToken(): Promise<string | null> {
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;
}

Expand Down
18 changes: 4 additions & 14 deletions src/api/keys.ts
Original file line number Diff line number Diff line change
@@ -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<ApiKeyCreatedResponse> {
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<ApiKeysListResponse> {
return request(`${API_V1}/keys`, {}, apiKeysListResponseSchema);
Expand Down
18 changes: 0 additions & 18 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -297,10 +283,6 @@ export interface ApiKeyResponse {
token_prefix: string | null;
}

export interface ApiKeyCreatedResponse extends ApiKeyResponse {
token: string;
}

export interface ApiKeysListResponse {
keys: ApiKeyResponse[];
}
Expand Down
17 changes: 13 additions & 4 deletions src/components/auth/AuthSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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");
}
Expand All @@ -102,6 +110,7 @@ function WebLoginForm() {
onClick={() => {
setPending(false);
deviceAuthStateStorage.setValue(null);
deviceAuthVerifierStorage.setValue(null);
}}
>
Cancel
Expand Down
108 changes: 23 additions & 85 deletions src/components/sidepanel/AccountTab.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -107,100 +104,41 @@ 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<string | null>(null);

const handleCreate = () => {
const scopes: ApiKeyScope[] = ["shorten:create", "urls:read", "stats:read"];
createKey.mutate(
{ name: newKeyName, scopes },
{
onSuccess: (data) => {
setNewKeyToken(data.token);
setNewKeyName("");
},
},
);
};

return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
API Keys
</h3>
<Button
variant="ghost"
size="icon-xs"
onClick={() => {
setShowCreate(!showCreate);
setNewKeyToken(null);
}}
<a
href="https://spoo.me/dashboard/keys"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<Plus className="size-3.5" />
</Button>
<ExternalLink className="size-3" />
New key
</a>
</div>

{showCreate && (
<div className="space-y-2 rounded-lg border bg-card p-3">
{newKeyToken ? (
<div className="space-y-2">
<p className="text-xs font-medium text-green-600 dark:text-green-400">
Key created! Copy it now — you won't see it again.
</p>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted px-2 py-1 text-xs font-mono truncate">
{newKeyToken}
</code>
<Button
variant="outline"
size="icon-xs"
onClick={() => navigator.clipboard.writeText(newKeyToken)}
>
<Copy className="size-3" />
</Button>
</div>
<Button
variant="ghost"
size="sm"
className="w-full"
onClick={() => {
setShowCreate(false);
setNewKeyToken(null);
}}
>
Done
</Button>
</div>
) : (
<>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Key name (e.g. My Extension)"
className="h-8 text-sm"
/>
<Button
size="sm"
className="w-full"
onClick={handleCreate}
disabled={!newKeyName.trim() || createKey.isPending}
>
{createKey.isPending ? "Creating..." : "Create Key"}
</Button>
</>
)}
</div>
)}

{isLoading && <div className="h-16 rounded-lg bg-muted/30 animate-pulse" />}
{error && <p className="text-xs text-destructive">{error.message}</p>}

{data && data.keys.length === 0 && !showCreate && (
<p className="text-xs text-muted-foreground py-4 text-center">No API keys</p>
{data && data.keys.length === 0 && (
<p className="text-xs text-muted-foreground py-4 text-center">
No API keys. Create one on your{" "}
<a
href="https://spoo.me/dashboard/keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
dashboard
</a>
.
</p>
)}

{data?.keys.map((key) => (
Expand Down
22 changes: 12 additions & 10 deletions src/entrypoints/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { showToastNotification } from "@/lib/notification";
import {
accessTokenStorage,
authModeStorage,
deviceAuthVerifierStorage,
historyStorage,
refreshTokenStorage,
settingsStorage,
Expand Down Expand Up @@ -127,22 +128,22 @@ async function processOfflineQueue(): Promise<void> {
}

async function handleTokenRefresh(): Promise<void> {
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<void> {
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();
Expand All @@ -155,6 +156,7 @@ async function exchangeDeviceCode(code: string): Promise<void> {
accessTokenStorage.setValue(data.access_token),
refreshTokenStorage.setValue(data.refresh_token),
userProfileStorage.setValue(data.user),
deviceAuthVerifierStorage.setValue(null),
]);
await authModeStorage.setValue("jwt");

Expand Down
13 changes: 1 addition & 12 deletions src/hooks/use-keys.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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({
Expand Down
28 changes: 28 additions & 0 deletions src/lib/pkce.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64UrlEncode(new Uint8Array(digest));
}
Loading
Loading