From ba8ba1d9b4232c919141493ee6e6bce0855b780a Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 4 Aug 2026 21:37:46 -0700 Subject: [PATCH 1/2] Fix OpenAPI import over CORS, substitute {{vars}} in OAuth config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs found while trying to import an internal Swashbuckle spec and authenticate against the API behind it. OpenAPI import used the webview's fetch() ---------------------------------------- CollectionsPanel called the global fetch() rather than going through the Rust backend, so the import was a browser request: subject to CORS and to the webview's TLS stack. An internal API server has no reason to send Access-Control-Allow-Origin for a desktop app's origin, so the fetch failed before anything was parsed — and when the host answered with an SSO login page instead, response.json() died on `<` and reported a parse error. Requests now go through send_request, which has no CORS restriction and honours the user's network settings, so a corporate root CA is handled by the existing SSL-verification toggle. The shared helper lives in utils/backendFetch and oidcDiscovery, which already had this logic inline, now uses it too. While here: OpenapiUrlImportModal and OpenapiImportModal were both written but never rendered — the only reachable path was a pair of window.prompt() calls. Both are now wired into the Import menu. The URL modal fetched on every keystroke (one request per character typed); it now loads on demand, resolves relative `servers` entries against the spec URL, and shows failures in the dialog rather than swallowing them. The converter rejects a non-OpenAPI document instead of silently importing nothing and reporting success. OAuth config never substituted {{variables}} -------------------------------------------- services/oauth passed clientId, clientSecret, scope, username, password and the endpoint URLs straight to invoke(). Only discoveryUrl was ever substituted, via a regex inlined in the component — which is why discovery worked and nothing else did. Substitution is now a shared utility (it had been copy-pasted three times and was missing from the one place it mattered) applied on the way out to the provider, never before storing: the config is persisted with the request, and the point of writing {{clientSecret}} is that the secret stays in the environment. A field that is still unresolved at request time now names the missing variable instead of sending literal braces. Diagnosing the 401s ------------------- - login.microsoftonline.com/ without /v2.0 is the Entra *v1.0* discovery document. Both are valid so auto-fill appeared to work, but v1.0 selects the token audience with a `resource` parameter that LitePost does not send. The result is a token for the wrong audience and an unexplained 401. Auto-fill now detects this and offers a one-click switch to v2.0. - Scope is no longer auto-filled for client credentials. `scopes_supported` advertises what the IdP offers for OIDC sign-in and says nothing about the API being called; on Entra, `openid profile email` mints a Microsoft Graph token that the user's own API rejects. - useRequest wrote headerRecord['Authorization'] with fixed casing while headers from the Headers tab go in as typed, so a stale lowercase `authorization` was sent alongside the generated one and the server chose between them. Header names are case-insensitive; setting one now replaces any existing case variant, matching what utils/auth already did on the collection runner path. Verified the import end to end against the Petstore v3 spec (19 operations, the relative `/api/v3` server URL correctly resolved against the spec URL) and the Entra v1.0 detection against login.microsoftonline.com/common. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 6 + src/components/CollectionsPanel.tsx | 54 ++++++-- src/components/OAuthConfigurator.tsx | 62 ++++++++- src/components/OpenapiImportModal.tsx | 2 +- src/components/OpenapiUrlImportModal.tsx | 152 ++++++++++++++++------- src/hooks/useOAuth2TokenActions.ts | 9 +- src/hooks/useRequest.ts | 35 ++++-- src/services/oauth.ts | 74 ++++++++++- src/test/OpenapiImport.test.tsx | 111 ++++++++++------- src/test/oauthVariables.test.ts | 149 ++++++++++++++++++++++ src/test/useRequestHeaders.test.ts | 133 ++++++++++++++++++++ src/utils/backendFetch.ts | 106 ++++++++++++++++ src/utils/collection-converter.ts | 28 +++++ src/utils/oidcDiscovery.ts | 58 ++++----- src/utils/variables.ts | 36 ++++++ 15 files changed, 860 insertions(+), 155 deletions(-) create mode 100644 src/test/oauthVariables.test.ts create mode 100644 src/test/useRequestHeaders.test.ts create mode 100644 src/utils/backendFetch.ts create mode 100644 src/utils/variables.ts 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..6551d3e 100644 --- a/src/components/OAuthConfigurator.tsx +++ b/src/components/OAuthConfigurator.tsx @@ -8,7 +8,8 @@ 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 { Loader2, KeyRound, RefreshCw, Globe, Shield, Wand2 } from "lucide-react" import { useState } from "react" @@ -63,6 +64,8 @@ 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 { isLoading, tokenError, @@ -82,18 +85,27 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP 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(oauth2.discoveryUrl, 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) ) @@ -107,6 +119,22 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP 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 +142,14 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP } } + const applyEntraV2Url = () => { + if (!entraV2Url) return + onOAuth2Change({ ...oauth2, discoveryUrl: entraV2Url }) + setEntraV2Url(null) + setDiscoveryWarning(null) + setDiscoveryNote('Switched to the v2.0 endpoint — hit Auto-fill again.') + } + return (
{/* Grant Type — always visible, stands alone */} @@ -179,6 +215,22 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP {discoveryNote && (

✓ {discoveryNote}

)} + {discoveryWarning && ( +
+

⚠ {discoveryWarning}

+ {entraV2Url && ( + + )} +
+ )} {oauth2.grantType === 'authorization_code' ? ( <> 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/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/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 28a6f07..113841e 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 || ""; 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) +} From b208279651d4f2d0acba78e164f50e880e5fc939 Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 4 Aug 2026 21:50:11 -0700 Subject: [PATCH 2/2] Add OAuth token claims inspector, show routes in OpenAPI imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the review on #9. Token claims inspector ---------------------- The token status box showed only a truncated bearer string, which is no help when the provider issues a token and the API still answers 401. It now decodes the JWT payload locally and leads with the claims that explain that outcome: `aud` first (a token for the wrong API is the usual cause), then `scp` vs `roles` — which says whether the token is delegated or app-only, and so whether the grant type matches what the API expects. A token carrying neither is called out, since for client credentials that normally means no app role was assigned. A Microsoft Graph audience and a v1.0 issuer are both named explicitly. The full claim set is available behind a toggle, and opaque (non-JWT) tokens say so rather than looking broken. Nothing here verifies the signature: this is a client reading what a token says about itself, not a trust decision, and verifying would need the provider's keys and answer a question nobody is asking. The v2.0 switch now finishes the job ------------------------------------ Clicking "Use the v2.0 endpoint" rewrote the field and then asked the user to press Auto-fill again, leaving the v1.0 endpoints sitting in the form. It now re-runs discovery itself. Discovery takes the URL as an argument rather than reading the prop, because at that moment the prop still holds the v1.0 value — and the write-back carries the URL through explicitly, since spreading the stale config would otherwise undo the switch. Imported requests show their route ---------------------------------- Names came from `operation.summary` alone, so a collection read "Update an existing pet." with no way to tell which endpoint that was without opening it. The path now comes first and the summary follows, so the route survives the list's truncation. The method is already a separate badge and is not repeated. Verified against the Petstore v3 spec: "GET /pet/findByStatus — Finds Pets by status." where it previously read "GET Finds Pets by status." Co-Authored-By: Claude Opus 5 --- src/components/OAuthConfigurator.tsx | 74 +++++++++++++-- src/test/OAuthConfigurator.test.tsx | 132 ++++++++++++++++++++++++++ src/test/jwt.test.ts | 98 +++++++++++++++++++ src/utils/collection-converter.ts | 9 +- src/utils/jwt.ts | 136 +++++++++++++++++++++++++++ 5 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 src/test/OAuthConfigurator.test.tsx create mode 100644 src/test/jwt.test.ts create mode 100644 src/utils/jwt.ts diff --git a/src/components/OAuthConfigurator.tsx b/src/components/OAuthConfigurator.tsx index 6551d3e..6842f12 100644 --- a/src/components/OAuthConfigurator.tsx +++ b/src/components/OAuthConfigurator.tsx @@ -10,8 +10,9 @@ import { useOAuth2TokenActions } from "@/hooks/useOAuth2TokenActions" import { useEnvironmentStore } from "@/store/environments" 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 @@ -66,6 +67,8 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP 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, @@ -80,8 +83,13 @@ 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) @@ -90,7 +98,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP try { // Support {{var}} in the discovery URL, like every other field - const resolvedUrl = substituteVariables(oauth2.discoveryUrl, getVariable) + const resolvedUrl = substituteVariables(rawUrl, getVariable) const discovery = await fetchOidcDiscovery(resolvedUrl) const updates: Partial = {} @@ -112,7 +120,9 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change }: OAuthConfiguratorP 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', @@ -142,12 +152,20 @@ 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 - onOAuth2Change({ ...oauth2, discoveryUrl: entraV2Url }) + const nextUrl = entraV2Url setEntraV2Url(null) setDiscoveryWarning(null) - setDiscoveryNote('Switched to the v2.0 endpoint — hit Auto-fill again.') + onOAuth2Change({ ...oauth2, discoveryUrl: nextUrl }) + void runDiscovery(nextUrl) } return ( @@ -375,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/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/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/utils/collection-converter.ts b/src/utils/collection-converter.ts index 113841e..6f46de0 100644 --- a/src/utils/collection-converter.ts +++ b/src/utils/collection-converter.ts @@ -238,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), + } +}