diff --git a/.claude/launch.json b/.claude/launch.json index e41e397..c157800 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,6 +1,12 @@ { "version": "0.0.1", "configurations": [ + { + "name": "litepost-dev", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["exec", "vite", "--port", "5173", "--strictPort"], + "port": 5173 + }, { "name": "litepost-preview", "runtimeExecutable": "pnpm", diff --git a/src/components/CollectionsPanel.tsx b/src/components/CollectionsPanel.tsx index 47281df..cfab84c 100644 --- a/src/components/CollectionsPanel.tsx +++ b/src/components/CollectionsPanel.tsx @@ -24,6 +24,8 @@ import { importFromOpenapi } from '@/utils/collection-converter' import { CollectionCard } from "./collections/CollectionCard" import { savedRequestToTab } from "./collections/collectionUtils" import { useResizablePanel } from "@/hooks/useResizablePanel" +import { OpenapiUrlImportModal } from "./OpenapiUrlImportModal" +import { OpenapiImportModal } from "./OpenapiImportModal" interface CollectionsPanelProps { open: boolean @@ -48,6 +50,8 @@ export const CollectionsPanel = forwardRef>(new Set()) + const [openapiUrlModalOpen, setOpenapiUrlModalOpen] = useState(false) + const [openapiRawModalOpen, setOpenapiRawModalOpen] = useState(false) const fileInputRef = useRef(null) const themeClass = useThemeClass() const { width, isDragging, setIsDragging } = useResizablePanel(600, 450) @@ -178,20 +182,30 @@ export const CollectionsPanel = forwardRef { - const openapiUrl = window.prompt("Enter the URL for the OpenAPI JSON file:"); - if (!openapiUrl) return; + /** + * Shared by both OpenAPI modals — they differ only in how the document is + * obtained (fetched vs pasted), not in what happens to it afterwards. + * + * Note the fetching lives in the URL modal and goes through the Rust + * backend. This used to call the webview's fetch() directly, which fails on + * any internal API server: a browser fetch enforces CORS, and an internal + * host has no reason to send Access-Control-Allow-Origin for a desktop + * app's origin. + */ + const handleOpenapiImport = (apiDoc: unknown, baseUrl: string) => { try { - const response = await fetch(openapiUrl); - if (!response.ok) { - throw new Error("Failed to fetch the OpenAPI document."); - } - const apiDoc = await response.json(); - const baseUrl = window.prompt("Enter the base URL for the API:"); - if (!baseUrl) return; const importedCollections = importFromOpenapi(apiDoc, baseUrl); + const requestCount = importedCollections.reduce((sum, c) => sum + c.requests.length, 0); + + if (requestCount === 0) { + toast.error("No operations found in that document — is it an OpenAPI spec?"); + return; + } + importCollections(importedCollections); - toast.success("OpenAPI collections imported successfully"); + setOpenapiUrlModalOpen(false); + setOpenapiRawModalOpen(false); + toast.success(`Imported ${requestCount} request${requestCount === 1 ? "" : "s"} from OpenAPI`); } catch (error) { if (shouldLogImportErrors) { console.error("Error importing OpenAPI:", error); @@ -244,8 +258,11 @@ export const CollectionsPanel = forwardRef Postman Format - - OpenAPI Format + setOpenapiUrlModalOpen(true)}> + OpenAPI from URL + + setOpenapiRawModalOpen(true)}> + OpenAPI (paste JSON) @@ -298,6 +315,17 @@ export const CollectionsPanel = forwardRef + + + ) } diff --git a/src/components/OAuthConfigurator.tsx b/src/components/OAuthConfigurator.tsx index 628bdff..6842f12 100644 --- a/src/components/OAuthConfigurator.tsx +++ b/src/components/OAuthConfigurator.tsx @@ -8,9 +8,11 @@ import { OAuth2Config, OAuth2GrantType } from "@/types" import { useThemeClass } from "@/hooks/useThemeClass" import { useOAuth2TokenActions } from "@/hooks/useOAuth2TokenActions" import { useEnvironmentStore } from "@/store/environments" -import { fetchOidcDiscovery } from "@/utils/oidcDiscovery" +import { detectEntraV1Url, fetchOidcDiscovery } from "@/utils/oidcDiscovery" +import { substituteVariables } from "@/utils/variables" +import { decodeToken } from "@/utils/jwt" import { Loader2, KeyRound, RefreshCw, Globe, Shield, Wand2 } from "lucide-react" -import { useState } from "react" +import { useMemo, useState } from "react" interface OAuthConfiguratorProps { oauth2: OAuth2Config @@ -63,6 +65,10 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP const [isDiscovering, setIsDiscovering] = useState(false) const [discoveryError, setDiscoveryError] = useState(null) const [discoveryNote, setDiscoveryNote] = useState(null) + const [discoveryWarning, setDiscoveryWarning] = useState(null) + const [entraV2Url, setEntraV2Url] = useState(null) + const [showAllClaims, setShowAllClaims] = useState(false) + const decoded = useMemo(() => decodeToken(oauth2.accessToken), [oauth2.accessToken]) const { isLoading, tokenError, @@ -77,36 +83,68 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP onOAuth2Change({ ...oauth2, [field]: value }) } - const handleDiscover = async () => { - if (!oauth2.discoveryUrl?.trim() || isDiscovering) return + /** + * Run discovery against an explicit URL rather than reading it off the prop. + * The v2.0 switch below needs to discover against a URL it has only just + * handed to the parent, which this render's `oauth2` does not know about yet. + */ + const runDiscovery = async (rawUrl: string) => { + if (!rawUrl.trim() || isDiscovering) return setIsDiscovering(true) setDiscoveryError(null) setDiscoveryNote(null) + setDiscoveryWarning(null) + setEntraV2Url(null) try { // Support {{var}} in the discovery URL, like every other field - const resolvedUrl = oauth2.discoveryUrl.replace(/\{\{([^}]+)\}\}/g, (match, name) => - getVariable(String(name).trim()) ?? match - ) + const resolvedUrl = substituteVariables(rawUrl, getVariable) const discovery = await fetchOidcDiscovery(resolvedUrl) const updates: Partial = {} if (discovery.authorizationEndpoint) updates.authUrl = discovery.authorizationEndpoint if (discovery.tokenEndpoint) updates.tokenUrl = discovery.tokenEndpoint - if (!oauth2.scope && discovery.scopesSupported?.length) { + + // Scope is deliberately NOT auto-filled for client credentials. The + // discovery document's `scopes_supported` advertises what the identity + // provider offers for OIDC sign-in — it says nothing about the API you + // are actually calling. Filling in `openid profile email` yields a token + // minted for the provider's own userinfo endpoint (on Entra, for + // Microsoft Graph), which your API then rejects with a 401. Client + // credentials in particular needs a resource-specific scope that only + // the user knows, e.g. `api:///.default`. + if (!oauth2.scope && oauth2.grantType !== 'client_credentials' && discovery.scopesSupported?.length) { const preferred = ['openid', 'profile', 'email'].filter((scope) => discovery.scopesSupported!.includes(scope) ) if (preferred.length > 0) updates.scope = preferred.join(' ') } - onOAuth2Change({ ...oauth2, ...updates }) + // rawUrl is carried through so the v2.0 switch is not undone by the + // stale discoveryUrl still sitting on `oauth2`. + onOAuth2Change({ ...oauth2, discoveryUrl: rawUrl, ...updates }) const filled = [ updates.authUrl && 'authorization URL', updates.tokenUrl && 'token URL', updates.scope && 'scope', ].filter(Boolean) setDiscoveryNote(`Filled ${filled.join(', ')}`) + + const v2Url = detectEntraV1Url(resolvedUrl) + if (v2Url) { + setEntraV2Url(v2Url) + setDiscoveryWarning( + 'This is the Entra v1.0 discovery document. v1.0 selects the token audience with a ' + + '`resource` parameter, which LitePost does not send — you will get a token, but for ' + + 'the wrong audience, and your API will answer 401. Use the v2.0 endpoint instead.' + ) + } else if (!oauth2.scope && !updates.scope) { + setDiscoveryWarning( + 'No scope set. Most providers need a scope naming the API you are calling ' + + '(Entra: `api:///.default`) — without it the token may be issued for ' + + 'a different audience and rejected with a 401.' + ) + } } catch (error) { setDiscoveryError(error instanceof Error ? error.message : String(error)) } finally { @@ -114,6 +152,22 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP } } + const handleDiscover = () => runDiscovery(oauth2.discoveryUrl ?? '') + + /** + * Swap in the v2.0 discovery URL and immediately re-run discovery against it. + * The URL is passed explicitly rather than read back from `oauth2` because + * the prop has not been updated yet at this point in the render cycle. + */ + const applyEntraV2Url = () => { + if (!entraV2Url) return + const nextUrl = entraV2Url + setEntraV2Url(null) + setDiscoveryWarning(null) + onOAuth2Change({ ...oauth2, discoveryUrl: nextUrl }) + void runDiscovery(nextUrl) + } + return (
{/* Grant Type — always visible, stands alone */} @@ -179,6 +233,22 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP {discoveryNote && (

✓ {discoveryNote}

)} + {discoveryWarning && ( +
+

⚠ {discoveryWarning}

+ {entraV2Url && ( + + )} +
+ )} {oauth2.grantType === 'authorization_code' ? ( <> @@ -323,6 +393,48 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP
{oauth2.tokenType || 'Bearer'} {oauth2.accessToken.substring(0, 50)}…
+ + {/* + The claims answer "the provider gave me a token, so why is the API + still saying 401?" — nearly always because `aud` names a different + API, or because the token is app-only where a user token is wanted. + Decoded locally and unverified; this is a read of what the token + says about itself, not a trust decision. + */} + {decoded ? ( +
+ {decoded.highlights.map((claim) => ( +
+
+ {claim.label} + {claim.value} +
+ {claim.hint && ( +

{claim.hint}

+ )} +
+ ))} + + + + {showAllClaims && ( +
+                  {JSON.stringify(decoded.claims, null, 2)}
+                
+ )} +
+ ) : ( +

+ Opaque token — no claims to decode. Check the audience with your API provider. +

+ )} )} diff --git a/src/components/OpenapiImportModal.tsx b/src/components/OpenapiImportModal.tsx index 49899cf..e8f5229 100644 --- a/src/components/OpenapiImportModal.tsx +++ b/src/components/OpenapiImportModal.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner" interface OpenapiImportModalProps { open: boolean onOpenChange: (open: boolean) => void - onImport: (openapiDoc: any, baseUrl: string) => void + onImport: (openapiDoc: unknown, baseUrl: string) => void } export function OpenapiImportModal({ open, onOpenChange, onImport }: OpenapiImportModalProps) { diff --git a/src/components/OpenapiUrlImportModal.tsx b/src/components/OpenapiUrlImportModal.tsx index 4087955..cf4c196 100644 --- a/src/components/OpenapiUrlImportModal.tsx +++ b/src/components/OpenapiUrlImportModal.tsx @@ -2,87 +2,132 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { useState } from "react" -import { toast } from "sonner" -import { fetch } from '@tauri-apps/plugin-http' +import { Loader2 } from "lucide-react" +import { fetchJsonViaBackend } from "@/utils/backendFetch" + +interface OpenapiDoc { + servers?: { url?: string }[] + [key: string]: unknown +} interface OpenapiUrlImportModalProps { open: boolean onOpenChange: (open: boolean) => void - onImport: (url: string, baseUrl: string) => void + onImport: (openapiDoc: unknown, baseUrl: string) => void } export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiUrlImportModalProps) { const [openapiUrl, setOpenapiUrl] = useState("") const [baseUrl, setBaseUrl] = useState("") const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) const [detectedServers, setDetectedServers] = useState([]) + const [loadedDoc, setLoadedDoc] = useState(null) - const handleUrlChange = async (url: string) => { - setOpenapiUrl(url) + const reset = () => { + setOpenapiUrl("") + setBaseUrl("") setDetectedServers([]) + setLoadedDoc(null) + setError(null) + } - if (!url.trim()) return + /** + * Fetch the spec and pull the server list out of it. Explicitly triggered + * rather than fired from onChange — the previous version fetched on every + * keystroke, which meant one HTTP request per character typed. + */ + const loadSpec = async (): Promise => { + const url = openapiUrl.trim() + if (!url) { + setError("Enter the URL of your OpenAPI JSON document.") + return null + } + setIsLoading(true) + setError(null) try { - setIsLoading(true) - const response = await fetch(url) - if (!response.ok) return + const apiDoc = await fetchJsonViaBackend(url) + setLoadedDoc(apiDoc) + + const servers = (apiDoc.servers ?? []) + .map((server) => server?.url) + .filter((serverUrl): serverUrl is string => typeof serverUrl === "string" && serverUrl.length > 0) - const apiDoc = await response.json() - if (apiDoc.servers && apiDoc.servers.length > 0) { - const servers = apiDoc.servers.map((s: { url: string }) => s.url) - setDetectedServers(servers) - if (servers.length > 0 && !baseUrl) { + setDetectedServers(servers) + // A relative server URL ("/" or "/api") is meaningless on its own — it is + // relative to where the spec was served from, so resolve it against that. + if (servers.length > 0 && !baseUrl) { + try { + setBaseUrl(new URL(servers[0], url).toString()) + } catch { setBaseUrl(servers[0]) } } - } catch (error) { - // Silently fail as this is just for auto-detection - console.error("Failed to detect servers:", error) + return apiDoc + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + return null } finally { setIsLoading(false) } } - const handleImport = () => { - if (!openapiUrl.trim()) { - toast.error("Please enter the OpenAPI JSON URL.") + const handleImport = async () => { + const apiDoc = loadedDoc ?? (await loadSpec()) + if (!apiDoc) return + + if (!baseUrl.trim()) { + setError("Enter a base URL — the spec did not declare one.") return } try { - onImport(openapiUrl, baseUrl) - setOpenapiUrl("") - setBaseUrl("") - setDetectedServers([]) - } catch (error) { - console.error("Error importing OpenAPI:", error) - toast.error(error instanceof Error ? error.message : "Failed to import OpenAPI specification") + onImport(apiDoc, baseUrl.trim()) + reset() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to import OpenAPI specification") } } return ( - + { if (!next) reset(); onOpenChange(next) }}> Import OpenAPI from URL - Enter the URL of your OpenAPI JSON file. The base URL will be auto-detected if available in the spec. + Enter the URL of your OpenAPI JSON document. The base URL is detected from the spec where possible.
- handleUrlChange(e.target.value)} - className="bg-background text-foreground border-border placeholder:text-muted-foreground" - /> +
+ { + setOpenapiUrl(e.target.value) + setLoadedDoc(null) + setDetectedServers([]) + setError(null) + }} + onKeyDown={(e) => { if (e.key === "Enter") loadSpec() }} + className="font-mono text-[13px] bg-background text-foreground border-border placeholder:text-muted-foreground" + /> + +
setBaseUrl(e.target.value)} - className="bg-background text-foreground border-border placeholder:text-muted-foreground" + className="font-mono text-[13px] bg-background text-foreground border-border placeholder:text-muted-foreground" /> {detectedServers.length > 0 && (
@@ -90,10 +135,24 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU

Found server URL in spec: {detectedServers[0]}

) : (
-

Found multiple server URLs in spec:

-
    +

    Found multiple server URLs in spec — click to use:

    +
      {detectedServers.map((server, i) => ( -
    • {server}
    • +
    • + +
    • ))}
@@ -101,20 +160,25 @@ export function OpenapiUrlImportModal({ open, onOpenChange, onImport }: OpenapiU
)}
+ {error && ( +

+ ⚠ {error} +

+ )}
-
) -} \ No newline at end of file +} diff --git a/src/hooks/useOAuth2TokenActions.ts b/src/hooks/useOAuth2TokenActions.ts index 3b173a5..1417a2e 100644 --- a/src/hooks/useOAuth2TokenActions.ts +++ b/src/hooks/useOAuth2TokenActions.ts @@ -6,6 +6,7 @@ import { refreshOAuthToken, requestOAuthToken, } from '@/services/oauth' +import { useEnvironmentStore } from '@/store/environments' interface UseOAuth2TokenActionsOptions { oauth2: OAuth2Config @@ -28,7 +29,11 @@ export function useOAuth2TokenActions({ }: UseOAuth2TokenActionsOptions): OAuth2TokenActions { const [isLoading, setIsLoading] = useState(false) const [tokenError, setTokenError] = useState(null) + const { getVariable } = useEnvironmentStore() + // Note this stores against the *unresolved* config: substitution happens on + // the way out to the provider, so `{{clientSecret}}` stays `{{clientSecret}}` + // in what gets persisted with the request. const handleTokenResponse = (token: OAuthTokenResponse) => { onOAuth2Change(applyTokenResponse(oauth2, token)) } @@ -38,7 +43,7 @@ export function useOAuth2TokenActions({ setTokenError(null) try { - const token = await requestOAuthToken(oauth2) + const token = await requestOAuthToken(oauth2, getVariable) handleTokenResponse(token) } catch (err) { setTokenError(err instanceof Error ? err.message : String(err)) @@ -56,7 +61,7 @@ export function useOAuth2TokenActions({ setTokenError(null) try { - const token = await refreshOAuthToken(oauth2) + const token = await refreshOAuthToken(oauth2, getVariable) handleTokenResponse(token) } catch (err) { setTokenError(err instanceof Error ? err.message : String(err)) diff --git a/src/hooks/useRequest.ts b/src/hooks/useRequest.ts index 114be80..64d7fe7 100644 --- a/src/hooks/useRequest.ts +++ b/src/hooks/useRequest.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core' import { Tab, HistoryItem } from '@/types' import { useEnvironmentStore } from '@/store/environments' import { useSettingsStore } from '@/store/settings' +import { substituteVariables as substitute } from '@/utils/variables' interface RedirectInfo { url: string @@ -48,11 +49,25 @@ export function useRequest(onHistoryUpdate: (item: HistoryItem) => void) { const { getVariable, setVariable } = useEnvironmentStore() const { network: globalNetwork } = useSettingsStore() - const substituteVariables = (text: string): string => { - return text.replace(/\{\{([^}]+)\}\}/g, (match, key) => { - const value = getVariable(key.trim()) - return value !== undefined ? value : match - }) + const substituteVariables = (text: string): string => substitute(text, getVariable) + + /** + * Set a header, replacing any existing key that differs only in case. + * + * HTTP header names are case-insensitive but a JS object's keys are not, so a + * plain assignment to `Authorization` leaves a user-typed `authorization` + * sitting right next to it and both get sent. Servers are free to pick either + * one, which shows up as an intermittent 401 that looks like the auth config + * is broken when it is actually a stale header from the Headers tab. + */ + const setHeader = (headers: Record, key: string, value: string) => { + const lowered = key.toLowerCase() + for (const existing of Object.keys(headers)) { + if (existing !== key && existing.toLowerCase() === lowered) { + delete headers[existing] + } + } + headers[key] = value } const sendRequest = async (tab: Tab) => { @@ -73,21 +88,21 @@ export function useRequest(onHistoryUpdate: (item: HistoryItem) => void) { const username = substituteVariables(tab.auth.username || '') const password = substituteVariables(tab.auth.password || '') const credentials = btoa(`${username}:${password}`) - headerRecord['Authorization'] = `Basic ${credentials}` + setHeader(headerRecord, 'Authorization', `Basic ${credentials}`) } else if (tab.auth.type === 'bearer' && tab.auth.token) { - headerRecord['Authorization'] = `Bearer ${substituteVariables(tab.auth.token)}` + setHeader(headerRecord, 'Authorization', `Bearer ${substituteVariables(tab.auth.token)}`) } else if (tab.auth.type === 'api-key' && tab.auth.key && tab.auth.value) { const key = substituteVariables(tab.auth.key) const value = substituteVariables(tab.auth.value) if (tab.auth.addTo === 'header') { - headerRecord[key] = value + setHeader(headerRecord, key, value) } else { const separator = url.includes('?') ? '&' : '?' url += `${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}` } } else if (tab.auth.type === 'oauth2' && tab.auth.oauth2?.accessToken) { const tokenType = tab.auth.oauth2.tokenType || 'Bearer' - headerRecord['Authorization'] = `${tokenType} ${substituteVariables(tab.auth.oauth2.accessToken)}` + setHeader(headerRecord, 'Authorization', `${tokenType} ${tab.auth.oauth2.accessToken}`) } // Add cookies to headers with variable substitution @@ -96,7 +111,7 @@ export function useRequest(onHistoryUpdate: (item: HistoryItem) => void) { .join('; ') if (cookieHeader) { - headerRecord['Cookie'] = cookieHeader + setHeader(headerRecord, 'Cookie', cookieHeader) } // Substitute variables in body if it exists diff --git a/src/services/oauth.ts b/src/services/oauth.ts index 02ca73e..9a2a301 100644 --- a/src/services/oauth.ts +++ b/src/services/oauth.ts @@ -1,5 +1,11 @@ import { invoke } from '@tauri-apps/api/core' import { OAuth2Config, OAuth2GrantType } from '@/types' +import { + VariableResolver, + hasUnresolvedVariables, + substituteOptional, + substituteVariables, +} from '@/utils/variables' export interface OAuthTokenResponse { access_token: string @@ -22,9 +28,34 @@ function optionalOrNull(value?: string): string | null { return trimmed ? trimmed : null } +/** + * Reject a field that still contains `{{name}}` after substitution. + * + * Without this the literal braces go to the provider, which answers with a + * generic "invalid client" — giving no hint that the real problem is a + * variable that is not defined in the active environment. + */ +function assertResolved(value: string | undefined, fieldName: string) { + if (!hasUnresolvedVariables(value)) return + + const names = [...value!.matchAll(/\{\{([^}]+)\}\}/g)].map((m) => m[1].trim()) + throw new Error( + `${fieldName} still contains ${names.map((n) => `{{${n}}}`).join(', ')} — ` + + `${names.length === 1 ? 'that variable is' : 'those variables are'} not defined in the active environment.` + ) +} + function assertGrantRequirements(config: OAuth2Config) { requiredField(config.clientId, 'Client ID') + assertResolved(config.clientId, 'Client ID') + assertResolved(config.clientSecret, 'Client Secret') + assertResolved(config.tokenUrl, 'Token URL') + assertResolved(config.authUrl, 'Authorization URL') + assertResolved(config.scope, 'Scope') + assertResolved(config.username, 'Username') + assertResolved(config.password, 'Password') + switch (config.grantType) { case 'authorization_code': requiredField(config.authUrl, 'Authorization URL') @@ -57,6 +88,33 @@ function createTokenExchangeOptions(config: OAuth2Config, grantType: OAuth2Grant } } +/** + * Resolve every `{{variable}}` in the user-authored fields of an OAuth config. + * + * Call this immediately before talking to the provider, never before storing — + * the config is persisted with the request, and the whole point of writing + * `{{clientSecret}}` is that the secret itself stays in the environment rather + * than on disk next to the collection. + * + * The token-state fields (accessToken, refreshToken, tokenType, expiresAt) are + * deliberately left alone: they are issued by the provider, not authored by the + * user, so there is nothing in them to substitute. + */ +export function resolveOAuth2Config(config: OAuth2Config, resolve: VariableResolver): OAuth2Config { + return { + ...config, + discoveryUrl: substituteOptional(config.discoveryUrl, resolve), + authUrl: substituteOptional(config.authUrl, resolve), + tokenUrl: substituteOptional(config.tokenUrl, resolve), + clientId: substituteVariables(config.clientId ?? '', resolve), + clientSecret: substituteOptional(config.clientSecret, resolve), + scope: substituteOptional(config.scope, resolve), + redirectUri: substituteOptional(config.redirectUri, resolve), + username: substituteOptional(config.username, resolve), + password: substituteOptional(config.password, resolve), + } +} + export function applyTokenResponse(config: OAuth2Config, token: OAuthTokenResponse): OAuth2Config { return { ...config, @@ -67,7 +125,14 @@ export function applyTokenResponse(config: OAuth2Config, token: OAuthTokenRespon } } -export async function requestOAuthToken(config: OAuth2Config): Promise { +export async function requestOAuthToken( + rawConfig: OAuth2Config, + resolve: VariableResolver +): Promise { + // Substitute first, then validate — otherwise a required field holding only + // `{{clientId}}` passes the non-empty check and the literal braces are what + // reaches the provider. + const config = resolveOAuth2Config(rawConfig, resolve) assertGrantRequirements(config) switch (config.grantType) { @@ -98,7 +163,12 @@ export async function requestOAuthToken(config: OAuth2Config): Promise { +export async function refreshOAuthToken( + rawConfig: OAuth2Config, + resolve: VariableResolver +): Promise { + const config = resolveOAuth2Config(rawConfig, resolve) + return invoke('oauth2_refresh', { options: { token_url: requiredField(config.tokenUrl, 'Token URL'), diff --git a/src/test/OAuthConfigurator.test.tsx b/src/test/OAuthConfigurator.test.tsx new file mode 100644 index 0000000..51144df --- /dev/null +++ b/src/test/OAuthConfigurator.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { OAuthConfigurator } from '@/components/OAuthConfigurator' +import { OAuth2Config } from '@/types' + +const fetchOidcDiscovery = vi.fn() +vi.mock('@/utils/oidcDiscovery', async (importOriginal) => ({ + ...(await importOriginal()), + fetchOidcDiscovery: (url: string) => fetchOidcDiscovery(url), +})) + +vi.mock('@/store/environments', () => ({ + useEnvironmentStore: () => ({ getVariable: () => undefined }), +})) + +function makeToken(payload: Record): string { + const b64 = (obj: unknown) => { + const bytes = new TextEncoder().encode(JSON.stringify(obj)) + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + return `${b64({ alg: 'RS256' })}.${b64(payload)}.c2ln` +} + +const base: OAuth2Config = { grantType: 'authorization_code', clientId: 'client-abc' } + +const renderConfigurator = (oauth2: Partial = {}) => { + const onOAuth2Change = vi.fn() + render() + return onOAuth2Change +} + +describe('OAuthConfigurator token claims', () => { + it('surfaces the audience of a decoded token', () => { + renderConfigurator({ accessToken: makeToken({ aud: 'api://my-api', scp: 'read' }) }) + + expect(screen.getByText('Audience (aud)')).toBeInTheDocument() + expect(screen.getByText('api://my-api')).toBeInTheDocument() + }) + + it('calls out a Graph token, which is the reason a valid token still 401s', () => { + renderConfigurator({ accessToken: makeToken({ aud: '00000003-0000-0000-c000-000000000000' }) }) + + expect(screen.getByText(/This is Microsoft Graph — not your API/)).toBeInTheDocument() + }) + + it('toggles the full claim set', () => { + renderConfigurator({ accessToken: makeToken({ aud: 'api://my-api', tid: 'tenant-1' }) }) + + expect(screen.queryByText(/"tid"/)).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('toggle-all-claims')) + expect(screen.getByText(/"tid": "tenant-1"/)).toBeInTheDocument() + }) + + it('says so plainly when the token is opaque', () => { + renderConfigurator({ accessToken: 'opaque-token-value' }) + + expect(screen.getByText(/Opaque token — no claims to decode/)).toBeInTheDocument() + expect(screen.queryByTestId('toggle-all-claims')).not.toBeInTheDocument() + }) + + it('shows nothing at all before a token exists', () => { + renderConfigurator() + expect(screen.queryByText('Audience (aud)')).not.toBeInTheDocument() + }) +}) + +describe('OAuthConfigurator Entra v1.0 handling', () => { + beforeEach(() => { + fetchOidcDiscovery.mockReset() + fetchOidcDiscovery.mockResolvedValue({ + authorizationEndpoint: 'https://login.microsoftonline.com/t/oauth2/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/t/oauth2/token', + scopesSupported: ['openid'], + }) + }) + + it('warns when discovery lands on the v1.0 document', async () => { + renderConfigurator({ discoveryUrl: 'login.microsoftonline.com/t' }) + fireEvent.click(screen.getByTestId('oidc-discover-button')) + + expect(await screen.findByTestId('use-entra-v2-button')).toBeInTheDocument() + expect(screen.getByText(/Entra v1\.0 discovery document/)).toBeInTheDocument() + }) + + // Previously this only rewrote the field and told the user to press Auto-fill + // again, leaving the v1.0 endpoints sitting in the form in the meantime. + it('re-runs discovery against v2.0 rather than asking the user to', async () => { + const onOAuth2Change = renderConfigurator({ discoveryUrl: 'login.microsoftonline.com/t' }) + fireEvent.click(screen.getByTestId('oidc-discover-button')) + fireEvent.click(await screen.findByTestId('use-entra-v2-button')) + + await waitFor(() => { + expect(fetchOidcDiscovery).toHaveBeenLastCalledWith( + 'https://login.microsoftonline.com/t/v2.0/.well-known/openid-configuration' + ) + }) + + // The corrected URL must survive the write-back — the component's `oauth2` + // prop still holds the v1.0 value at that point, so spreading it blindly + // would undo the switch. + await waitFor(() => { + expect(onOAuth2Change).toHaveBeenLastCalledWith( + expect.objectContaining({ + discoveryUrl: 'https://login.microsoftonline.com/t/v2.0/.well-known/openid-configuration', + }) + ) + }) + expect(screen.queryByTestId('use-entra-v2-button')).not.toBeInTheDocument() + }) + + it('does not auto-fill scope for client credentials', async () => { + // `openid` describes the IdP's sign-in surface, not the API being called — + // filling it in mints a token for the wrong audience. + const onOAuth2Change = renderConfigurator({ + grantType: 'client_credentials', + discoveryUrl: 'https://auth.example.com', + }) + fireEvent.click(screen.getByTestId('oidc-discover-button')) + + await waitFor(() => expect(onOAuth2Change).toHaveBeenCalled()) + expect(onOAuth2Change.mock.calls[0][0].scope).toBeUndefined() + }) + + it('still auto-fills scope for the authorization code flow', async () => { + const onOAuth2Change = renderConfigurator({ discoveryUrl: 'https://auth.example.com' }) + fireEvent.click(screen.getByTestId('oidc-discover-button')) + + await waitFor(() => expect(onOAuth2Change).toHaveBeenCalled()) + expect(onOAuth2Change.mock.calls[0][0].scope).toBe('openid') + }) +}) diff --git a/src/test/OpenapiImport.test.tsx b/src/test/OpenapiImport.test.tsx index b091c8e..9a888d2 100644 --- a/src/test/OpenapiImport.test.tsx +++ b/src/test/OpenapiImport.test.tsx @@ -19,7 +19,7 @@ vi.mock('@/utils/collection-converter', () => ({ if (!apiDoc || !baseUrl) { throw new Error('Invalid OpenAPI document or base URL') } - return [{ id: '1', name: 'Test Collection', requests: [] }] + return [{ id: '1', name: 'Test Collection', requests: [{ id: 'r1', name: 'GET /things' }] }] }) })) @@ -118,68 +118,91 @@ describe('OpenapiImportModal', () => { }) describe('CollectionsPanel OpenAPI Import', () => { - // Mock the fetch function const mockFetch = vi.fn() global.fetch = mockFetch - const mockPrompt = vi.fn() - global.prompt = mockPrompt as unknown as typeof window.prompt beforeEach(() => { vi.clearAllMocks() }) - const openImportMenu = async () => { - const user = userEvent.setup() - const importButton = screen.getByRole('button', { name: /import/i }) - await user.click(importButton) - return screen.findByRole('menuitem', { name: /openapi format/i }) + const SPEC = { openapi: '3.0.0', info: { title: 'Test API' }, servers: [{ url: 'https://api.example.com' }] } + + const okResponse = (body: unknown) => ({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(body)), + }) + + const openUrlModal = async (user: ReturnType) => { + await user.click(screen.getByRole('button', { name: /import/i })) + const item = await screen.findByRole('menuitem', { name: /openapi from url/i }) + fireEvent.click(item) + return screen.findByPlaceholderText(/swagger\/v1\/swagger\.json/i) } - it('handles URL import correctly', async () => { - const mockApiDoc = { openapi: '3.0.0', info: { title: 'Test API' } } - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockApiDoc) - }) - mockPrompt - .mockReturnValueOnce('https://api.example.com/openapi.json') // URL prompt - .mockReturnValueOnce('https://api.example.com') // Base URL prompt + it('imports a spec fetched from a URL', async () => { + const user = userEvent.setup() + mockFetch.mockResolvedValueOnce(okResponse(SPEC)) - render( - {}} - onRequestSelect={() => {}} - /> - ) + render( { }} onRequestSelect={() => { }} />) - // Find and click the OpenAPI URL import option - const urlImportOption = await openImportMenu() - fireEvent.click(urlImportOption) + const urlInput = await openUrlModal(user) + fireEvent.change(urlInput, { target: { value: 'https://api.example.com/swagger/v1/swagger.json' } }) + fireEvent.click(screen.getByRole('button', { name: /^load$/i })) + // The base URL is pre-filled from the spec's `servers` entry await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/openapi.json') - expect(toast.success).toHaveBeenCalledWith('OpenAPI collections imported successfully') + expect(screen.getByPlaceholderText(/base url/i)).toHaveValue('https://api.example.com/') + }) + + fireEvent.click(screen.getByRole('button', { name: /^import$/i })) + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Imported 1 request from OpenAPI') }) }) - it('handles URL import errors', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')) - mockPrompt.mockReturnValueOnce('https://api.example.com/openapi.json') + // The bug this whole path was rewritten for: the import used the webview's + // fetch(), which enforces CORS and so could never reach an internal API host. + // Requests now go out through the Rust backend, and the only reason fetch() + // appears at all here is the browser-mode fallback these tests exercise. + it('surfaces a fetch failure in the dialog instead of failing silently', async () => { + const user = userEvent.setup() + mockFetch.mockRejectedValueOnce(new Error('Failed to fetch')) - render( - {}} - onRequestSelect={() => {}} - /> - ) + render( { }} onRequestSelect={() => { }} />) - const urlImportOption = await openImportMenu() - fireEvent.click(urlImportOption) + const urlInput = await openUrlModal(user) + fireEvent.change(urlInput, { target: { value: 'https://internal.corp/swagger/v1/swagger.json' } }) + fireEvent.click(screen.getByRole('button', { name: /^load$/i })) - await waitFor(() => { - expect(toast.error).toHaveBeenCalled() + expect(await screen.findByText(/Failed to fetch/i)).toBeInTheDocument() + }) + + it('explains an HTML login-page response rather than a JSON parse error', async () => { + const user = userEvent.setup() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve('Sign in'), }) + + render( { }} onRequestSelect={() => { }} />) + + const urlInput = await openUrlModal(user) + fireEvent.change(urlInput, { target: { value: 'https://internal.corp/swagger/v1/swagger.json' } }) + fireEvent.click(screen.getByRole('button', { name: /^load$/i })) + + expect(await screen.findByText(/HTML page instead of JSON/i)).toBeInTheDocument() + }) + + it('does not fetch on every keystroke', async () => { + const user = userEvent.setup() + render( { }} onRequestSelect={() => { }} />) + + const urlInput = await openUrlModal(user) + await user.type(urlInput, 'https://api.example.com/spec.json') + + expect(mockFetch).not.toHaveBeenCalled() }) }) diff --git a/src/test/jwt.test.ts b/src/test/jwt.test.ts new file mode 100644 index 0000000..67b1225 --- /dev/null +++ b/src/test/jwt.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest' +import { decodeToken, looksLikeJwt } from '@/utils/jwt' + +/** + * Build a JWT-shaped string with the given payload. Signature is not checked. + * + * Encodes UTF-8 bytes before base64, the way a real issuer does — btoa() takes + * a byte string, so handing it JSON with non-ASCII in it directly would either + * throw or silently mangle the very case one of these tests covers. + */ +function makeToken(payload: Record): string { + const b64 = (obj: unknown) => { + const bytes = new TextEncoder().encode(JSON.stringify(obj)) + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + } + return `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(payload)}.c2lnbmF0dXJl` +} + +const findClaim = (token: string, label: string) => + decodeToken(token)?.highlights.find((h) => h.label.startsWith(label)) + +describe('looksLikeJwt', () => { + it('accepts a three-segment base64url token', () => { + expect(looksLikeJwt(makeToken({ aud: 'x' }))).toBe(true) + }) + + it('rejects opaque tokens and junk', () => { + // Plenty of providers issue non-JWT access tokens — not an error, just + // nothing to decode. + expect(looksLikeJwt('opaque-token-value')).toBe(false) + expect(looksLikeJwt('a.b')).toBe(false) + expect(looksLikeJwt('has spaces.in.it')).toBe(false) + expect(looksLikeJwt(undefined)).toBe(false) + }) +}) + +describe('decodeToken', () => { + it('returns null for an opaque token rather than throwing', () => { + expect(decodeToken('opaque-token-value')).toBeNull() + }) + + it('returns null when the payload is not valid JSON', () => { + expect(decodeToken('aGVhZGVy.bm90LWpzb24.c2ln')).toBeNull() + }) + + it('decodes claims and expiry', () => { + const decoded = decodeToken(makeToken({ aud: 'api://my-api', exp: 1893456000, iat: 1893452400 })) + + expect(decoded?.claims.aud).toBe('api://my-api') + expect(decoded?.expiresAt?.getTime()).toBe(1893456000 * 1000) + expect(decoded?.issuedAt?.getTime()).toBe(1893452400 * 1000) + }) + + it('decodes non-ASCII claim values correctly', () => { + // atob gives bytes, not characters — without the UTF-8 re-read this comes + // back mangled. + const decoded = decodeToken(makeToken({ aud: 'x', name: 'José Niño' })) + expect(decoded?.claims.name).toBe('José Niño') + }) + + it('puts the audience first — it is the usual cause of a post-token 401', () => { + const decoded = decodeToken(makeToken({ aud: 'api://my-api', scp: 'read' })) + expect(decoded?.highlights[0].label).toContain('Audience') + }) + + it('calls out a Microsoft Graph audience by name', () => { + // The exact symptom JT hit: a valid token, for entirely the wrong API. + const decoded = findClaim(makeToken({ aud: '00000003-0000-0000-c000-000000000000' }), 'Audience') + expect(decoded?.hint).toMatch(/Microsoft Graph/) + }) + + it('distinguishes a delegated token from an app-only one', () => { + expect(findClaim(makeToken({ scp: 'User.Read' }), 'Scopes')?.hint).toMatch(/signed-in user/) + expect(findClaim(makeToken({ roles: ['Thing.Read'] }), 'Roles')?.hint).toMatch(/app-only/) + }) + + it('flags a token carrying neither scp nor roles', () => { + const claim = findClaim(makeToken({ aud: 'api://my-api' }), 'Permissions') + expect(claim?.value).toBe('— none —') + expect(claim?.hint).toMatch(/no app role has been assigned/) + }) + + it('joins array claims into a readable string', () => { + expect(findClaim(makeToken({ roles: ['A', 'B'] }), 'Roles')?.value).toBe('A B') + }) + + it('warns about a v1.0 issuer but not a v2.0 one', () => { + expect(findClaim(makeToken({ iss: 'https://sts.windows.net/tenant/' }), 'Issuer')?.hint) + .toMatch(/v1\.0 issuer/) + expect(findClaim(makeToken({ iss: 'https://login.microsoftonline.com/tenant/v2.0' }), 'Issuer')?.hint) + .toBeUndefined() + }) + + it('falls back to azp when appid is absent', () => { + expect(findClaim(makeToken({ azp: 'client-abc' }), 'Client')?.value).toBe('client-abc') + }) +}) diff --git a/src/test/oauthVariables.test.ts b/src/test/oauthVariables.test.ts new file mode 100644 index 0000000..56066c5 --- /dev/null +++ b/src/test/oauthVariables.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest' +import { resolveOAuth2Config, requestOAuthToken } from '@/services/oauth' +import { substituteVariables, substituteOptional, hasUnresolvedVariables } from '@/utils/variables' +import { detectEntraV1Url } from '@/utils/oidcDiscovery' +import { OAuth2Config } from '@/types' + +const env: Record = { + clientId: 'real-client-id', + clientSecret: 'sup3r-s3cret', + tenant: 'contoso', +} +const resolve = (key: string) => env[key] + +describe('substituteVariables', () => { + it('replaces known references', () => { + expect(substituteVariables('{{clientId}}', resolve)).toBe('real-client-id') + }) + + it('replaces several references in one string', () => { + expect(substituteVariables('https://login.microsoftonline.com/{{tenant}}/v2.0', resolve)) + .toBe('https://login.microsoftonline.com/contoso/v2.0') + }) + + it('leaves unknown references alone rather than blanking them', () => { + // Blanking would send an empty client_id and produce a confusing provider + // error; leaving the braces makes the typo visible in the request. + expect(substituteVariables('{{nope}}', resolve)).toBe('{{nope}}') + }) + + it('tolerates surrounding whitespace in the name', () => { + expect(substituteVariables('{{ clientId }}', resolve)).toBe('real-client-id') + }) + + it('passes undefined through', () => { + expect(substituteOptional(undefined, resolve)).toBeUndefined() + }) + + it('detects leftover references without being confused by its own regex state', () => { + // The pattern is /g, so a shared lastIndex would make repeat calls alternate + // between true and false. + expect(hasUnresolvedVariables('{{a}}')).toBe(true) + expect(hasUnresolvedVariables('{{a}}')).toBe(true) + expect(hasUnresolvedVariables('plain')).toBe(false) + }) +}) + +describe('resolveOAuth2Config', () => { + const base: OAuth2Config = { + grantType: 'client_credentials', + clientId: '{{clientId}}', + clientSecret: '{{clientSecret}}', + tokenUrl: 'https://login.microsoftonline.com/{{tenant}}/oauth2/v2.0/token', + scope: 'api://{{clientId}}/.default', + } + + it('substitutes every user-authored field', () => { + const resolved = resolveOAuth2Config(base, resolve) + + expect(resolved.clientId).toBe('real-client-id') + expect(resolved.clientSecret).toBe('sup3r-s3cret') + expect(resolved.tokenUrl).toBe('https://login.microsoftonline.com/contoso/oauth2/v2.0/token') + expect(resolved.scope).toBe('api://real-client-id/.default') + }) + + it('leaves issued token state untouched', () => { + // These come back from the provider — there is nothing to substitute, and + // rewriting them would corrupt a token that happened to contain braces. + const withToken: OAuth2Config = { + ...base, + accessToken: 'header.payload.signature', + refreshToken: 'refresh-abc', + tokenType: 'Bearer', + expiresAt: 123456, + } + const resolved = resolveOAuth2Config(withToken, resolve) + + expect(resolved.accessToken).toBe('header.payload.signature') + expect(resolved.refreshToken).toBe('refresh-abc') + expect(resolved.tokenType).toBe('Bearer') + expect(resolved.expiresAt).toBe(123456) + }) + + it('does not mutate the config it was given', () => { + // The stored config must keep its {{references}} — that is the whole point + // of writing a secret as a variable rather than inline. + resolveOAuth2Config(base, resolve) + expect(base.clientId).toBe('{{clientId}}') + expect(base.clientSecret).toBe('{{clientSecret}}') + }) + + it('handles a config with no optional fields set', () => { + const minimal: OAuth2Config = { grantType: 'authorization_code', clientId: 'abc' } + const resolved = resolveOAuth2Config(minimal, resolve) + + expect(resolved.clientId).toBe('abc') + expect(resolved.clientSecret).toBeUndefined() + expect(resolved.scope).toBeUndefined() + }) +}) + +describe('requestOAuthToken validation', () => { + it('names the undefined variable instead of letting braces reach the provider', async () => { + const config: OAuth2Config = { + grantType: 'client_credentials', + clientId: '{{missingId}}', + tokenUrl: 'https://login.microsoftonline.com/contoso/oauth2/v2.0/token', + } + + await expect(requestOAuthToken(config, resolve)).rejects.toThrow( + /Client ID still contains \{\{missingId\}\}/ + ) + }) + + it('lists every unresolved name in one message', async () => { + const config: OAuth2Config = { + grantType: 'client_credentials', + clientId: 'fine', + clientSecret: '{{a}}{{b}}', + tokenUrl: 'https://example.com/token', + } + + await expect(requestOAuthToken(config, resolve)).rejects.toThrow(/\{\{a\}\}, \{\{b\}\}/) + }) +}) + +describe('detectEntraV1Url', () => { + it('flags an Entra tenant URL with no version segment', () => { + // login.microsoftonline.com/ serves the v1.0 document, which selects + // the token audience with `resource` rather than `scope`. + expect(detectEntraV1Url('login.microsoftonline.com/contoso')).toBe( + 'https://login.microsoftonline.com/contoso/v2.0/.well-known/openid-configuration' + ) + }) + + it('flags the fully written out v1.0 well-known URL', () => { + expect( + detectEntraV1Url('https://login.microsoftonline.com/contoso/.well-known/openid-configuration') + ).toBe('https://login.microsoftonline.com/contoso/v2.0/.well-known/openid-configuration') + }) + + it('accepts a v2.0 URL', () => { + expect(detectEntraV1Url('https://login.microsoftonline.com/contoso/v2.0')).toBeNull() + }) + + it('ignores non-Microsoft providers', () => { + expect(detectEntraV1Url('https://auth.example.com')).toBeNull() + expect(detectEntraV1Url('https://login.microsoftonline.com.evil.test/contoso')).toBeNull() + }) +}) diff --git a/src/test/useRequestHeaders.test.ts b/src/test/useRequestHeaders.test.ts new file mode 100644 index 0000000..ef76644 --- /dev/null +++ b/src/test/useRequestHeaders.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { Tab } from '@/types' + +const invoke = vi.fn() +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: unknown[]) => invoke(...args) })) + +const env: Record = { token: 'from-env' } +vi.mock('@/store/environments', () => ({ + useEnvironmentStore: () => ({ + getVariable: (key: string) => env[key], + setVariable: vi.fn(), + }), +})) + +vi.mock('@/store/settings', () => ({ + useSettingsStore: () => ({ + network: { timeout: 30, connectTimeout: 10, sslVerification: true, proxy: '' }, + }), +})) + +import { useRequest } from '@/hooks/useRequest' + +function makeTab(overrides: Partial = {}): Tab { + return { + id: 't1', + name: 'Test', + method: 'GET', + url: 'https://api.example.com/things', + rawUrl: 'https://api.example.com/things', + params: [], + headers: [], + body: '', + contentType: 'application/json', + response: null, + loading: false, + auth: { type: 'none' }, + cookies: [], + testScripts: [], + testAssertions: [], + testResults: null, + ...overrides, + } +} + +/** The `headers` map that actually went out to the Rust backend. */ +function sentHeaders(): Record { + const [, payload] = invoke.mock.calls.at(-1) as [string, { options: { headers: Record } }] + return payload.options.headers +} + +async function send(tab: Tab) { + const { result } = renderHook(() => useRequest(vi.fn())) + await result.current.sendRequest(tab) +} + +describe('useRequest auth headers', () => { + beforeEach(() => { + invoke.mockReset() + invoke.mockResolvedValue({ + status: 200, + status_text: 'OK', + headers: {}, + body: '{}', + redirect_chain: [], + cookies: [], + is_base64: false, + }) + }) + + // The bug: header names are case-insensitive over the wire but a JS object's + // keys are not, so a stale lowercase `authorization` from the Headers tab + // survived alongside the `Authorization` that auth generates and both were + // sent. Which one the server honoured was its choice — presenting as an + // intermittent 401 that looked like the auth config was broken. + it('replaces a differently-cased Authorization header instead of sending both', async () => { + await send(makeTab({ + headers: [{ key: 'authorization', value: 'Bearer stale-token', enabled: true }], + auth: { type: 'oauth2', oauth2: { grantType: 'client_credentials', clientId: 'c', accessToken: 'fresh-token', tokenType: 'Bearer' } }, + })) + + const headers = sentHeaders() + const authKeys = Object.keys(headers).filter((k) => k.toLowerCase() === 'authorization') + + expect(authKeys).toHaveLength(1) + expect(headers[authKeys[0]]).toBe('Bearer fresh-token') + }) + + it('applies the same rule to bearer auth', async () => { + await send(makeTab({ + headers: [{ key: 'AUTHORIZATION', value: 'Bearer stale', enabled: true }], + auth: { type: 'bearer', token: '{{token}}' }, + })) + + const headers = sentHeaders() + expect(Object.keys(headers).filter((k) => k.toLowerCase() === 'authorization')).toHaveLength(1) + expect(Object.values(headers)).toContain('Bearer from-env') + }) + + it('applies the same rule to a case-mismatched api-key header', async () => { + await send(makeTab({ + headers: [{ key: 'x-api-key', value: 'stale', enabled: true }], + auth: { type: 'api-key', key: 'X-API-Key', value: 'fresh', addTo: 'header' }, + })) + + const headers = sentHeaders() + expect(Object.keys(headers).filter((k) => k.toLowerCase() === 'x-api-key')).toHaveLength(1) + expect(Object.values(headers)).toContain('fresh') + }) + + it('leaves unrelated headers alone', async () => { + await send(makeTab({ + headers: [ + { key: 'Accept', value: 'application/json', enabled: true }, + { key: 'X-Trace', value: 'abc', enabled: true }, + ], + auth: { type: 'bearer', token: 'tok' }, + })) + + const headers = sentHeaders() + expect(headers['Accept']).toBe('application/json') + expect(headers['X-Trace']).toBe('abc') + expect(headers['Authorization']).toBe('Bearer tok') + }) + + it('does not send an Authorization header when oauth2 has no token yet', async () => { + await send(makeTab({ + auth: { type: 'oauth2', oauth2: { grantType: 'client_credentials', clientId: 'c' } }, + })) + + expect(Object.keys(sentHeaders()).some((k) => k.toLowerCase() === 'authorization')).toBe(false) + }) +}) diff --git a/src/utils/backendFetch.ts b/src/utils/backendFetch.ts new file mode 100644 index 0000000..604531a --- /dev/null +++ b/src/utils/backendFetch.ts @@ -0,0 +1,106 @@ +/** + * GET a URL through the Rust HTTP backend instead of the webview's fetch(). + * + * The webview's own fetch() is a browser fetch: it enforces CORS and uses the + * webview's TLS stack. Neither is acceptable for the endpoints this app talks + * to — an internal API server has no reason to send + * `Access-Control-Allow-Origin` for a desktop app's origin, so the request + * fails before a single byte is parsed. Going through `send_request` has no + * CORS restriction and honours the user's network settings, which is also how + * a corporate root CA gets handled (Settings → SSL verification). + */ +import { useSettingsStore } from '@/store/settings' + +interface BackendResponse { + status: number + status_text?: string + body: string + error?: string +} + +/** True when running inside the Tauri shell, false under vitest/browser dev. */ +function isTauri(): boolean { + return ( + typeof window !== 'undefined' && + !!(window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ + ) +} + +export interface BackendFetchOptions { + headers?: Record +} + +/** + * Fetch `url` and return the response body as text. + * + * Falls back to the browser fetch() outside the Tauri shell (browser dev mode + * and tests), where there is no backend to invoke. + * + * @throws if the request fails or the response status is not 2xx. + */ +export async function fetchTextViaBackend( + url: string, + options: BackendFetchOptions = {} +): Promise { + const headers = options.headers ?? { Accept: 'application/json' } + + if (!isTauri()) { + const response = await fetch(url, { headers }) + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + return response.text() + } + + const { invoke } = await import('@tauri-apps/api/core') + const { network } = useSettingsStore.getState() + + const response = await invoke('send_request', { + options: { + method: 'GET', + url, + headers, + cookies: [], + timeout: network.timeout || undefined, + connect_timeout: network.connectTimeout || undefined, + ssl_verification: network.sslVerification, + proxy: network.proxy || undefined, + }, + }) + + if (response.error) { + throw new Error(response.error) + } + if (response.status < 200 || response.status >= 300) { + throw new Error(`Request failed with status ${response.status}`) + } + + return response.body +} + +/** + * Fetch `url` and parse it as JSON. + * + * The separate parse step is deliberate: an endpoint behind a corporate SSO + * proxy answers with an HTML login page and a 200, and `response.json()` + * blowing up on `<` is a confusing way to learn that. The message below says + * what actually happened. + */ +export async function fetchJsonViaBackend( + url: string, + options: BackendFetchOptions = {} +): Promise { + const text = await fetchTextViaBackend(url, options) + + try { + return JSON.parse(text) as T + } catch { + const preview = text.trim().slice(0, 80) + const looksLikeHtml = /^\s*<(!doctype|html)/i.test(text) + throw new Error( + looksLikeHtml + ? 'The server returned an HTML page instead of JSON — the URL may be behind a login redirect.' + : `The response was not valid JSON (starts with: ${preview})` + ) + } +} diff --git a/src/utils/collection-converter.ts b/src/utils/collection-converter.ts index bc1400b..0bb5c11 100644 --- a/src/utils/collection-converter.ts +++ b/src/utils/collection-converter.ts @@ -189,7 +189,35 @@ function buildUrl(urlObj: PostmanItem['request']['url']): string { return url.toString() } +/** + * Sanity-check the document before walking it. + * + * Without this a non-spec (an HTML error page that happened to parse, a + * Postman export, the wrong JSON file) produced an empty collection and a + * success toast, which is a much worse outcome than an error. + */ +function assertOpenapiDocument(doc: unknown): asserts doc is Record { + if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + throw new Error("That is not an OpenAPI document — expected a JSON object."); + } + + const record = doc as Record; + if (typeof record.swagger === "string" && record.swagger.startsWith("2.")) { + throw new Error( + "That is a Swagger 2.0 document. LitePost imports OpenAPI 3.x — most tools can emit 3.x, " + + "or you can convert the file first." + ); + } + if (!record.openapi && !record.paths) { + throw new Error( + "No `openapi` version or `paths` object found — this does not look like an OpenAPI document." + ); + } +} + export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[] { + assertOpenapiDocument(openapiDoc); + const collections: Collection[] = []; const title = openapiDoc.info?.title || "Imported OpenAPI Collection"; const description = openapiDoc.info?.description || ""; @@ -210,7 +238,14 @@ export function importFromOpenapi(openapiDoc: any, baseUrl: string): Collection[ for (const method in pathItem) { if (["get", "post", "put", "patch", "delete", "options", "head"].includes(method.toLowerCase())) { const operation = pathItem[method]; - const name = operation.summary || `${method.toUpperCase()} ${path}`; + // Route first, summary second. The summary alone ("Update an existing + // pet.") reads nicely but leaves you unable to tell which endpoint a + // saved request actually hits without opening it — and the list + // truncates, so whatever goes first is what survives. The method is + // already shown as a separate badge, so it is not repeated here. + const name = operation.summary + ? `${path} — ${operation.summary}` + : path; let fullUrl = path; try { if (serverUrl) { diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..a74c8db --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,136 @@ +/** + * Minimal JWT payload decoding, for showing a token's claims in the UI. + * + * This deliberately does NOT verify the signature — LitePost is a client + * inspecting a token it was just handed, not a resource server deciding whether + * to trust one. Verification would need the provider's signing keys and would + * tell the user nothing they need here. Treat everything this returns as + * "what the token says about itself". + */ + +export interface JwtClaims { + [claim: string]: unknown +} + +export interface DecodedToken { + claims: JwtClaims + /** Claims worth showing first, in the order they answer "why is this 401ing?" */ + highlights: { label: string; value: string; hint?: string }[] + expiresAt: Date | null + issuedAt: Date | null +} + +/** base64url → UTF-8 string. */ +function decodeSegment(segment: string): string { + const padded = segment.replace(/-/g, '+').replace(/_/g, '/') + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '=')) + + // atob yields one byte per char; re-read it as UTF-8 so non-ASCII claim + // values (names, for instance) do not come out mangled. + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + return new TextDecoder('utf-8').decode(bytes) +} + +/** A JWT is three dot-separated base64url segments. Opaque tokens are not. */ +export function looksLikeJwt(token: string | undefined): boolean { + if (!token) return false + const parts = token.split('.') + return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) +} + +function asString(value: unknown): string { + if (Array.isArray(value)) return value.join(' ') + if (value === null || value === undefined) return '' + return String(value) +} + +function toDate(value: unknown): Date | null { + return typeof value === 'number' ? new Date(value * 1000) : null +} + +/** + * Decode a JWT's payload. Returns null for opaque tokens or malformed input — + * plenty of providers issue non-JWT access tokens, which is not an error. + */ +export function decodeToken(token: string | undefined): DecodedToken | null { + if (!looksLikeJwt(token)) return null + + let claims: JwtClaims + try { + const parsed = JSON.parse(decodeSegment(token!.split('.')[1])) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + claims = parsed as JwtClaims + } catch { + return null + } + + const highlights: DecodedToken['highlights'] = [] + + // `aud` first, always: a token minted for the wrong audience is the single + // most common cause of a 401 that follows a *successful* token request. + if (claims.aud !== undefined) { + const aud = asString(claims.aud) + highlights.push({ + label: 'Audience (aud)', + value: aud, + hint: + aud === '00000003-0000-0000-c000-000000000000' + ? 'This is Microsoft Graph — not your API. Set a scope naming your own API, e.g. api:///.default' + : 'The API this token is for. It must match the API you are calling.', + }) + } + + // Delegated tokens carry `scp` (a user acted); app-only tokens carry `roles`. + // Which one is present tells you whether the grant type matches what the API + // expects, and an app-only token with neither means no app role was assigned. + if (claims.scp !== undefined) { + highlights.push({ + label: 'Scopes (scp)', + value: asString(claims.scp), + hint: 'Delegated permissions — this token represents a signed-in user.', + }) + } + if (claims.roles !== undefined) { + highlights.push({ + label: 'Roles', + value: asString(claims.roles), + hint: 'App roles — this token is app-only, with no user behind it.', + }) + } + if (claims.scp === undefined && claims.roles === undefined) { + highlights.push({ + label: 'Permissions', + value: '— none —', + hint: + 'No scp and no roles. For client credentials this usually means no app role has been ' + + 'assigned to the application, which most APIs reject.', + }) + } + + if (claims.iss !== undefined) { + const iss = asString(claims.iss) + highlights.push({ + label: 'Issuer (iss)', + value: iss, + hint: /\/v2\.0\/?$/.test(iss) + ? undefined + : /sts\.windows\.net|login\.microsoftonline\.com/.test(iss) + ? 'A v1.0 issuer. If your API validates a v2.0 issuer it will reject this token.' + : undefined, + }) + } + if (claims.appid !== undefined || claims.azp !== undefined) { + highlights.push({ + label: 'Client (appid)', + value: asString(claims.appid ?? claims.azp), + hint: 'The application this token was issued to.', + }) + } + + return { + claims, + highlights, + expiresAt: toDate(claims.exp), + issuedAt: toDate(claims.iat), + } +} diff --git a/src/utils/oidcDiscovery.ts b/src/utils/oidcDiscovery.ts index 5fdd15f..62f410f 100644 --- a/src/utils/oidcDiscovery.ts +++ b/src/utils/oidcDiscovery.ts @@ -1,3 +1,5 @@ +import { fetchJsonViaBackend } from '@/utils/backendFetch' + export interface OidcDiscovery { authorizationEndpoint?: string tokenEndpoint?: string @@ -41,7 +43,27 @@ export function parseDiscoveryDocument(json: unknown): OidcDiscovery { return { authorizationEndpoint, tokenEndpoint, scopesSupported } } -const isTauri = typeof window !== 'undefined' && !!(window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ +/** + * Microsoft Entra serves a discovery document at both + * `login.microsoftonline.com//.well-known/…` (v1.0) and + * `…//v2.0/.well-known/…` (v2.0). Both are valid, so discovery + * "succeeds" either way — but they are different protocol dialects. v1.0 keys + * its token audience off a `resource` parameter, which LitePost does not send; + * v2.0 uses `scope`, which it does. Point LitePost at the v1.0 document and you + * get a token for the wrong audience and an unexplained 401 from your API. + * + * Returns the corrected v2.0 URL when the input is an Entra v1.0 URL. + */ +export function detectEntraV1Url(input: string): string | null { + const url = normalizeDiscoveryUrl(input) + if (!/(^|\/\/)(login\.microsoftonline\.com|login\.windows\.net|sts\.windows\.net)\//i.test(url)) { + return null + } + if (/\/v2\.0\//i.test(url)) { + return null + } + return url.replace(WELL_KNOWN_PATH, `/v2.0${WELL_KNOWN_PATH}`) +} /** * Fetch and parse an OIDC discovery document. Goes through the Rust HTTP @@ -50,38 +72,6 @@ const isTauri = typeof window !== 'undefined' && !!(window as unknown as { __TAU */ export async function fetchOidcDiscovery(inputUrl: string): Promise { const url = normalizeDiscoveryUrl(inputUrl) - - let bodyText: string - if (isTauri) { - const { invoke } = await import('@tauri-apps/api/core') - const response = await invoke<{ status: number; body: string; error?: string }>('send_request', { - options: { - method: 'GET', - url, - headers: { Accept: 'application/json' }, - cookies: [], - }, - }) - if (response.error) { - throw new Error(response.error) - } - if (response.status < 200 || response.status >= 300) { - throw new Error(`Discovery request failed with status ${response.status}`) - } - bodyText = response.body - } else { - const response = await fetch(url, { headers: { Accept: 'application/json' } }) - if (!response.ok) { - throw new Error(`Discovery request failed with status ${response.status}`) - } - bodyText = await response.text() - } - - let parsed: unknown - try { - parsed = JSON.parse(bodyText) - } catch { - throw new Error('Discovery response is not valid JSON') - } + const parsed = await fetchJsonViaBackend(url, { headers: { Accept: 'application/json' } }) return parseDiscoveryDocument(parsed) } diff --git a/src/utils/variables.ts b/src/utils/variables.ts new file mode 100644 index 0000000..4573228 --- /dev/null +++ b/src/utils/variables.ts @@ -0,0 +1,36 @@ +/** + * `{{variable}}` substitution, shared by every code path that sends a request. + * + * This lived as three near-identical private copies (useRequest, CollectionRunner, + * and an inline regex in OAuthConfigurator). The OAuth service had none at all, + * which is why `{{clientId}}` reached the token endpoint verbatim. + */ + +/** Resolves a variable name. Return undefined to leave the reference untouched. */ +export type VariableResolver = (key: string) => string | undefined + +const VARIABLE_PATTERN = /\{\{([^}]+)\}\}/g + +/** + * Replace every `{{name}}` in `text` with its resolved value. Unknown names are + * left as-is rather than blanked, so a typo shows up in the request instead of + * silently sending an empty string. + */ +export function substituteVariables(text: string, resolve: VariableResolver): string { + return text.replace(VARIABLE_PATTERN, (match, key) => resolve(String(key).trim()) ?? match) +} + +/** `substituteVariables` that passes undefined through, for optional config fields. */ +export function substituteOptional( + text: string | undefined, + resolve: VariableResolver +): string | undefined { + return text === undefined ? undefined : substituteVariables(text, resolve) +} + +/** True if the text still contains a `{{name}}` reference. */ +export function hasUnresolvedVariables(text: string | undefined): boolean { + if (!text) return false + VARIABLE_PATTERN.lastIndex = 0 + return VARIABLE_PATTERN.test(text) +}