From c2f068970b6fcd65f8c7af2972707904c4d25152 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 30 Jan 2026 10:29:49 -0800 Subject: [PATCH] Add external browser support for OAuth2 authorization (#375) Co-authored-by: Claude Opus 4.5 --- package-lock.json | 72 ++-- package.json | 2 +- plugins/auth-oauth2/src/callbackServer.ts | 335 ++++++++++++++++++ .../src/grants/authorizationCode.ts | 91 +++-- plugins/auth-oauth2/src/grants/implicit.ts | 113 +++++- plugins/auth-oauth2/src/index.ts | 216 ++++++++--- src-web/components/DynamicForm.tsx | 16 +- .../components/HttpAuthenticationEditor.tsx | 11 +- src-web/components/core/Icon.tsx | 4 + src-web/hooks/useAuthTab.tsx | 138 +++++--- 10 files changed, 834 insertions(+), 164 deletions(-) create mode 100644 plugins/auth-oauth2/src/callbackServer.ts diff --git a/package-lock.json b/package-lock.json index 9edec906..f9a2a116 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,7 @@ "src-web" ], "devDependencies": { - "@biomejs/biome": "^2.3.10", + "@biomejs/biome": "^2.3.13", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.3.4", "dotenv-cli": "^11.0.0", @@ -501,9 +501,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz", - "integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz", + "integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -517,20 +517,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.11", - "@biomejs/cli-darwin-x64": "2.3.11", - "@biomejs/cli-linux-arm64": "2.3.11", - "@biomejs/cli-linux-arm64-musl": "2.3.11", - "@biomejs/cli-linux-x64": "2.3.11", - "@biomejs/cli-linux-x64-musl": "2.3.11", - "@biomejs/cli-win32-arm64": "2.3.11", - "@biomejs/cli-win32-x64": "2.3.11" + "@biomejs/cli-darwin-arm64": "2.3.13", + "@biomejs/cli-darwin-x64": "2.3.13", + "@biomejs/cli-linux-arm64": "2.3.13", + "@biomejs/cli-linux-arm64-musl": "2.3.13", + "@biomejs/cli-linux-x64": "2.3.13", + "@biomejs/cli-linux-x64-musl": "2.3.13", + "@biomejs/cli-win32-arm64": "2.3.13", + "@biomejs/cli-win32-x64": "2.3.13" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz", - "integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz", + "integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==", "cpu": [ "arm64" ], @@ -545,9 +545,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz", - "integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz", + "integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==", "cpu": [ "x64" ], @@ -562,9 +562,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz", - "integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz", + "integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==", "cpu": [ "arm64" ], @@ -579,9 +579,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz", - "integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz", + "integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==", "cpu": [ "arm64" ], @@ -596,9 +596,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz", - "integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz", + "integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==", "cpu": [ "x64" ], @@ -613,9 +613,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz", - "integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz", + "integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==", "cpu": [ "x64" ], @@ -630,9 +630,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz", - "integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz", + "integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==", "cpu": [ "arm64" ], @@ -647,9 +647,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz", - "integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==", + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz", + "integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 11920ad2..5b427526 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "js-yaml": "^4.1.1" }, "devDependencies": { - "@biomejs/biome": "^2.3.10", + "@biomejs/biome": "^2.3.13", "@tauri-apps/cli": "^2.9.6", "@yaakapp/cli": "^0.3.4", "dotenv-cli": "^11.0.0", diff --git a/plugins/auth-oauth2/src/callbackServer.ts b/plugins/auth-oauth2/src/callbackServer.ts new file mode 100644 index 00000000..a351bca8 --- /dev/null +++ b/plugins/auth-oauth2/src/callbackServer.ts @@ -0,0 +1,335 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import http from 'node:http'; +import type { Context } from '@yaakapp/api'; + +export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect'; +export const DEFAULT_LOCALHOST_PORT = 8765; +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +/** Singleton: only one callback server runs at a time across all OAuth flows. */ +let activeServer: CallbackServerResult | null = null; + +export interface CallbackServerResult { + /** The port the server is listening on */ + port: number; + /** The full redirect URI to register with the OAuth provider */ + redirectUri: string; + /** Promise that resolves with the callback URL when received */ + waitForCallback: () => Promise; + /** Stop the server */ + stop: () => void; +} + +/** + * Start a local HTTP server to receive OAuth callbacks. + * Only one server runs at a time — if a previous server is still active, + * it is stopped before starting the new one. + * Returns the port, redirect URI, and a promise that resolves when the callback is received. + */ +export function startCallbackServer(options: { + /** Specific port to use, or 0 for random available port */ + port?: number; + /** Path for the callback endpoint */ + path?: string; + /** Timeout in milliseconds (default 5 minutes) */ + timeoutMs?: number; +}): Promise { + // Stop any previously active server before starting a new one + if (activeServer) { + console.log('[oauth2] Stopping previous callback server before starting new one'); + activeServer.stop(); + activeServer = null; + } + + const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options; + + return new Promise((resolve, reject) => { + let callbackResolve: ((url: string) => void) | null = null; + let callbackReject: ((err: Error) => void) | null = null; + let timeoutHandle: ReturnType | null = null; + let stopped = false; + + const server = http.createServer((req: IncomingMessage, res: ServerResponse) => { + const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`); + + // Only handle the callback path + if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + + if (req.method === 'POST') { + // POST: read JSON body with the final callback URL and resolve + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { url: callbackUrl } = JSON.parse(body); + if (!callbackUrl || typeof callbackUrl !== 'string') { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing url in request body'); + return; + } + + // Send success response + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('OK'); + + // Resolve the callback promise + if (callbackResolve) { + callbackResolve(callbackUrl); + callbackResolve = null; + callbackReject = null; + } + + // Stop the server after a short delay to ensure response is sent + setTimeout(() => stopServer(), 100); + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Invalid JSON'); + } + }); + return; + } + + // GET: serve intermediate page that reads the fragment and POSTs back + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(getFragmentForwardingHtml()); + }); + + server.on('error', (err: Error) => { + if (!stopped) { + reject(err); + } + }); + + const stopServer = () => { + if (stopped) return; + stopped = true; + + // Clear the singleton reference + if (activeServer?.stop === stopServer) { + activeServer = null; + } + + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + + server.close(); + + if (callbackReject) { + callbackReject(new Error('Callback server stopped')); + callbackResolve = null; + callbackReject = null; + } + }; + + server.listen(port, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to get server address')); + return; + } + + const actualPort = address.port; + const redirectUri = `http://127.0.0.1:${actualPort}${path}`; + + console.log(`[oauth2] Callback server listening on ${redirectUri}`); + + const result: CallbackServerResult = { + port: actualPort, + redirectUri, + waitForCallback: () => { + return new Promise((res, rej) => { + if (stopped) { + rej(new Error('Callback server already stopped')); + return; + } + + callbackResolve = res; + callbackReject = rej; + + // Set timeout + timeoutHandle = setTimeout(() => { + if (callbackReject) { + callbackReject(new Error('Authorization timed out')); + callbackResolve = null; + callbackReject = null; + } + stopServer(); + }, timeoutMs); + }); + }, + stop: stopServer, + }; + + activeServer = result; + resolve(result); + }); + }); +} + +/** + * Build the redirect URI for the hosted callback page. + * The hosted page will redirect to the local server with the OAuth response. + */ +export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string { + const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`; + // The hosted callback page will read params and redirect to the local server + return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`; +} + +/** + * Open an authorization URL in the system browser, start a local callback server, + * and wait for the OAuth provider to redirect back. + * + * Returns the raw callback URL and the redirect URI that was registered with the + * OAuth provider (needed for token exchange). + */ +export async function getRedirectUrlViaExternalBrowser( + ctx: Context, + authorizationUrl: URL, + options: { + callbackType: 'localhost' | 'hosted'; + callbackPort?: number; + }, +): Promise<{ callbackUrl: string; redirectUri: string }> { + const { callbackType, callbackPort } = options; + + // Determine port based on callback type: + // - localhost: use specified port or default stable port + // - hosted: use random port (0) since hosted page redirects to local + const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0; + + console.log( + `[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`, + ); + + const server = await startCallbackServer({ + port, + path: '/callback', + }); + + try { + // Determine the redirect URI to send to the OAuth provider + let oauthRedirectUri: string; + + if (callbackType === 'hosted') { + oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback'); + console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri); + } else { + oauthRedirectUri = server.redirectUri; + console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri); + } + + // Set the redirect URI on the authorization URL + authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri); + + const authorizationUrlStr = authorizationUrl.toString(); + console.log('[oauth2] Opening external browser:', authorizationUrlStr); + + // Show toast to inform user + await ctx.toast.show({ + message: 'Opening browser for authorization...', + icon: 'info', + timeout: 3000, + }); + + // Open the system browser + await ctx.window.openExternalUrl(authorizationUrlStr); + + // Wait for the callback + console.log('[oauth2] Waiting for callback on', server.redirectUri); + const callbackUrl = await server.waitForCallback(); + + console.log('[oauth2] Received callback:', callbackUrl); + + return { callbackUrl, redirectUri: oauthRedirectUri }; + } finally { + server.stop(); + } +} + +/** + * Intermediate HTML page that reads the URL fragment and _fragment query param, + * reconstructs a proper OAuth callback URL, and POSTs it back to the server. + * + * Handles three cases: + * - Localhost implicit: fragment is in location.hash (e.g. #access_token=...) + * - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page + * - Auth code: no fragment, code is already in query params + */ +function getFragmentForwardingHtml(): string { + return ` + + + Yaak + + + +
+ +

Authorizing...

+

Please wait

+
+ + +`; +} diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts index a1efdc5a..fc2b535f 100644 --- a/plugins/auth-oauth2/src/grants/authorizationCode.ts +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes } from 'node:crypto'; import type { Context } from '@yaakapp/api'; +import { getRedirectUrlViaExternalBrowser } from '../callbackServer'; import { fetchAccessToken } from '../fetchAccessToken'; import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; import type { AccessToken, TokenStoreArgs } from '../store'; @@ -10,6 +11,15 @@ export const PKCE_SHA256 = 'S256'; export const PKCE_PLAIN = 'plain'; export const DEFAULT_PKCE_METHOD = PKCE_SHA256; +export type CallbackType = 'localhost' | 'hosted'; + +export interface ExternalBrowserOptions { + useExternalBrowser: boolean; + callbackType: CallbackType; + /** Port for localhost callback (only used when callbackType is 'localhost') */ + callbackPort?: number; +} + export async function getAuthorizationCode( ctx: Context, contextId: string, @@ -25,6 +35,7 @@ export async function getAuthorizationCode( credentialsInBody, pkce, tokenName, + externalBrowser, }: { authorizationUrl: string; accessTokenUrl: string; @@ -40,6 +51,7 @@ export async function getAuthorizationCode( codeVerifier: string; } | null; tokenName: 'access_token' | 'id_token'; + externalBrowser?: ExternalBrowserOptions; }, ): Promise { const tokenArgs: TokenStoreArgs = { @@ -68,7 +80,6 @@ export async function getAuthorizationCode( } authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('client_id', clientId); - if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri); if (scope) authorizationUrl.searchParams.set('scope', scope); if (state) authorizationUrl.searchParams.set('state', state); if (audience) authorizationUrl.searchParams.set('audience', audience); @@ -80,12 +91,65 @@ export async function getAuthorizationCode( authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod); } + let code: string; + let actualRedirectUri: string | null = redirectUri; + + // Use external browser flow if enabled + if (externalBrowser?.useExternalBrowser) { + const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, { + callbackType: externalBrowser.callbackType, + callbackPort: externalBrowser.callbackPort, + }); + // Pass null to skip redirect URI matching — the callback came from our own local server + const extractedCode = extractCode(result.callbackUrl, null); + if (!extractedCode) { + throw new Error('No authorization code found in callback URL'); + } + code = extractedCode; + actualRedirectUri = result.redirectUri; + } else { + // Use embedded browser flow (original behavior) + if (redirectUri) { + authorizationUrl.searchParams.set('redirect_uri', redirectUri); + } + code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri); + } + + console.log('[oauth2] Code found'); + const response = await fetchAccessToken(ctx, { + grantType: 'authorization_code', + accessTokenUrl, + clientId, + clientSecret, + scope, + audience, + credentialsInBody, + params: [ + { name: 'code', value: code }, + ...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []), + ...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []), + ], + }); + + return storeToken(ctx, tokenArgs, response, tokenName); +} + +/** + * Get authorization code using the embedded browser window. + * This is the original flow that monitors navigation events. + */ +async function getCodeViaEmbeddedBrowser( + ctx: Context, + contextId: string, + authorizationUrl: URL, + redirectUri: string | null, +): Promise { const dataDirKey = await getDataDirKey(ctx, contextId); const authorizationUrlStr = authorizationUrl.toString(); - console.log('[oauth2] Authorizing', authorizationUrlStr); + console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr); - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: none - const code = await new Promise(async (resolve, reject) => { + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern + return new Promise(async (resolve, reject) => { let foundCode = false; const { close } = await ctx.window.openUrl({ dataDirKey, @@ -110,31 +174,12 @@ export async function getAuthorizationCode( return; } - // Close the window here, because we don't need it anymore! foundCode = true; close(); resolve(code); }, }); }); - - console.log('[oauth2] Code found'); - const response = await fetchAccessToken(ctx, { - grantType: 'authorization_code', - accessTokenUrl, - clientId, - clientSecret, - scope, - audience, - credentialsInBody, - params: [ - { name: 'code', value: code }, - ...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []), - ...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []), - ], - }); - - return storeToken(ctx, tokenArgs, response, tokenName); } export function genPkceCodeVerifier() { diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts index 33306f5b..3ec58502 100644 --- a/plugins/auth-oauth2/src/grants/implicit.ts +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -1,7 +1,9 @@ import type { Context } from '@yaakapp/api'; +import { getRedirectUrlViaExternalBrowser } from '../callbackServer'; import type { AccessToken, AccessTokenRawResponse } from '../store'; import { getDataDirKey, getToken, storeToken } from '../store'; import { isTokenExpired } from '../util'; +import type { ExternalBrowserOptions } from './authorizationCode'; export async function getImplicit( ctx: Context, @@ -15,6 +17,7 @@ export async function getImplicit( state, audience, tokenName, + externalBrowser, }: { authorizationUrl: string; responseType: string; @@ -24,6 +27,7 @@ export async function getImplicit( state: string | null; audience: string | null; tokenName: 'access_token' | 'id_token'; + externalBrowser?: ExternalBrowserOptions; }, ): Promise { const tokenArgs = { @@ -43,9 +47,8 @@ export async function getImplicit( } catch { throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`); } - authorizationUrl.searchParams.set('response_type', 'token'); + authorizationUrl.searchParams.set('response_type', responseType); authorizationUrl.searchParams.set('client_id', clientId); - if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri); if (scope) authorizationUrl.searchParams.set('scope', scope); if (state) authorizationUrl.searchParams.set('state', state); if (audience) authorizationUrl.searchParams.set('audience', audience); @@ -56,11 +59,55 @@ export async function getImplicit( ); } - // biome-ignore lint/suspicious/noAsyncPromiseExecutor: none - const newToken = await new Promise(async (resolve, reject) => { + let newToken: AccessToken; + + // Use external browser flow if enabled + if (externalBrowser?.useExternalBrowser) { + const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, { + callbackType: externalBrowser.callbackType, + callbackPort: externalBrowser.callbackPort, + }); + newToken = await extractImplicitToken(ctx, result.callbackUrl, tokenArgs, tokenName); + } else { + // Use embedded browser flow (original behavior) + if (redirectUri) { + authorizationUrl.searchParams.set('redirect_uri', redirectUri); + } + newToken = await getTokenViaEmbeddedBrowser( + ctx, + contextId, + authorizationUrl, + tokenArgs, + tokenName, + ); + } + + return newToken; +} + +/** + * Get token using the embedded browser window. + * This is the original flow that monitors navigation events. + */ +async function getTokenViaEmbeddedBrowser( + ctx: Context, + contextId: string, + authorizationUrl: URL, + tokenArgs: { + contextId: string; + clientId: string; + accessTokenUrl: null; + authorizationUrl: string; + }, + tokenName: 'access_token' | 'id_token', +): Promise { + const dataDirKey = await getDataDirKey(ctx, contextId); + const authorizationUrlStr = authorizationUrl.toString(); + console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr); + + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern + return new Promise(async (resolve, reject) => { let foundAccessToken = false; - const authorizationUrlStr = authorizationUrl.toString(); - const dataDirKey = await getDataDirKey(ctx, contextId); const { close } = await ctx.window.openUrl({ dataDirKey, url: authorizationUrlStr, @@ -97,6 +144,56 @@ export async function getImplicit( }, }); }); - - return newToken; +} + +/** + * Extract the implicit grant token from a callback URL and store it. + */ +async function extractImplicitToken( + ctx: Context, + callbackUrl: string, + tokenArgs: { + contextId: string; + clientId: string; + accessTokenUrl: null; + authorizationUrl: string; + }, + tokenName: 'access_token' | 'id_token', +): Promise { + const url = new URL(callbackUrl); + + // Check for errors + if (url.searchParams.has('error')) { + throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`); + } + + // Extract token from fragment + const hash = url.hash.slice(1); + const params = new URLSearchParams(hash); + + // Also check query params (in case fragment was converted) + const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName); + if (!accessToken) { + throw new Error(`No ${tokenName} found in callback URL`); + } + + // Build response from params (prefer fragment, fall back to query) + const response: AccessTokenRawResponse = { + access_token: params.get('access_token') ?? url.searchParams.get('access_token') ?? '', + token_type: params.get('token_type') ?? url.searchParams.get('token_type') ?? undefined, + expires_in: params.has('expires_in') + ? parseInt(params.get('expires_in') ?? '0', 10) + : url.searchParams.has('expires_in') + ? parseInt(url.searchParams.get('expires_in') ?? '0', 10) + : undefined, + scope: params.get('scope') ?? url.searchParams.get('scope') ?? undefined, + }; + + // Include id_token if present + const idToken = params.get('id_token') ?? url.searchParams.get('id_token'); + if (idToken) { + response.id_token = idToken; + } + + return storeToken(ctx, tokenArgs, response); } diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 324ad9b0..ba384f49 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -5,7 +5,9 @@ import type { JsonPrimitive, PluginDefinition, } from '@yaakapp/api'; +import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer'; import { + type CallbackType, DEFAULT_PKCE_METHOD, genPkceCodeVerifier, getAuthorizationCode, @@ -134,8 +136,6 @@ export const plugin: PluginDefinition = { defaultValue: defaultGrantType, options: grantTypes, }, - - // Always-present fields { type: 'text', name: 'clientId', @@ -169,11 +169,105 @@ export const plugin: PluginDefinition = { completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })), }, { - type: 'text', - name: 'redirectUri', - label: 'Redirect URI', - optional: true, - dynamic: hiddenIfNot(['authorization_code', 'implicit']), + type: 'banner', + inputs: [ + { + type: 'checkbox', + name: 'useExternalBrowser', + label: 'Use External Browser', + description: + 'Open authorization URL in your system browser instead of the embedded browser. ' + + 'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.', + dynamic: hiddenIfNot(['authorization_code', 'implicit']), + }, + { + type: 'text', + name: 'redirectUri', + label: 'Redirect URI', + description: + 'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.', + optional: true, + dynamic: hiddenIfNot( + ['authorization_code', 'implicit'], + ({ useExternalBrowser }) => !useExternalBrowser, + ), + }, + { + type: 'h_stack', + inputs: [ + { + type: 'select', + name: 'callbackType', + label: 'Callback Type', + description: + '"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.', + defaultValue: 'hosted', + options: [ + { label: 'Hosted Redirect', value: 'hosted' }, + { label: 'Localhost', value: 'localhost' }, + ], + dynamic: hiddenIfNot( + ['authorization_code', 'implicit'], + ({ useExternalBrowser }) => !!useExternalBrowser, + ), + }, + { + type: 'text', + name: 'callbackPort', + label: 'Callback Port', + placeholder: `${DEFAULT_LOCALHOST_PORT}`, + description: + 'Port for the local callback server. Defaults to ' + + DEFAULT_LOCALHOST_PORT + + ' if empty.', + optional: true, + dynamic: hiddenIfNot( + ['authorization_code', 'implicit'], + ({ useExternalBrowser, callbackType }) => + !!useExternalBrowser && callbackType === 'localhost', + ), + }, + ], + }, + { + type: 'banner', + color: 'info', + inputs: [ + { + type: 'markdown', + content: 'Redirect URI to Register', + async dynamic(_ctx, { values }) { + const grantType = String(values.grantType ?? defaultGrantType); + const useExternalBrowser = !!values.useExternalBrowser; + const callbackType = (stringArg(values, 'callbackType') || + 'localhost') as CallbackType; + + // Only show for authorization_code and implicit with external browser enabled + if ( + !['authorization_code', 'implicit'].includes(grantType) || + !useExternalBrowser + ) { + return { hidden: true }; + } + + // Compute the redirect URI based on callback type + let redirectUri: string; + if (callbackType === 'hosted') { + redirectUri = HOSTED_CALLBACK_URL; + } else { + const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT; + redirectUri = `http://127.0.0.1:${port}/callback`; + } + + return { + hidden: false, + content: `Register \`${redirectUri}\` as a redirect URI with your OAuth provider.`, + }; + }, + }, + ], + }, + ], }, { type: 'text', @@ -182,12 +276,8 @@ export const plugin: PluginDefinition = { optional: true, dynamic: hiddenIfNot(['authorization_code', 'implicit']), }, - { - type: 'text', - name: 'audience', - label: 'Audience', - optional: true, - }, + { type: 'text', name: 'scope', label: 'Scope', optional: true }, + { type: 'text', name: 'audience', label: 'Audience', optional: true }, { type: 'select', name: 'tokenName', @@ -203,44 +293,54 @@ export const plugin: PluginDefinition = { dynamic: hiddenIfNot(['authorization_code', 'implicit']), }, { - type: 'checkbox', - name: 'usePkce', - label: 'Use PKCE', - dynamic: hiddenIfNot(['authorization_code']), - }, - { - type: 'select', - name: 'pkceChallengeMethod', - label: 'Code Challenge Method', - options: [ - { label: 'SHA-256', value: PKCE_SHA256 }, - { label: 'Plain', value: PKCE_PLAIN }, + type: 'banner', + inputs: [ + { + type: 'checkbox', + name: 'usePkce', + label: 'Use PKCE', + dynamic: hiddenIfNot(['authorization_code']), + }, + { + type: 'select', + name: 'pkceChallengeMethod', + label: 'Code Challenge Method', + options: [ + { label: 'SHA-256', value: PKCE_SHA256 }, + { label: 'Plain', value: PKCE_PLAIN }, + ], + defaultValue: DEFAULT_PKCE_METHOD, + dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), + }, + { + type: 'text', + name: 'pkceCodeChallenge', + label: 'Code Verifier', + placeholder: 'Automatically generated when not set', + optional: true, + dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), + }, ], - defaultValue: DEFAULT_PKCE_METHOD, - dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), }, { - type: 'text', - name: 'pkceCodeChallenge', - label: 'Code Verifier', - placeholder: 'Automatically generated when not set', - optional: true, - dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), - }, - { - type: 'text', - name: 'username', - label: 'Username', - optional: true, - dynamic: hiddenIfNot(['password']), - }, - { - type: 'text', - name: 'password', - label: 'Password', - password: true, - optional: true, - dynamic: hiddenIfNot(['password']), + type: 'h_stack', + inputs: [ + { + type: 'text', + name: 'username', + label: 'Username', + optional: true, + dynamic: hiddenIfNot(['password']), + }, + { + type: 'text', + name: 'password', + label: 'Password', + password: true, + optional: true, + dynamic: hiddenIfNot(['password']), + }, + ], }, { type: 'select', @@ -258,7 +358,6 @@ export const plugin: PluginDefinition = { type: 'accordion', label: 'Advanced', inputs: [ - { type: 'text', name: 'scope', label: 'Scope', optional: true }, { type: 'text', name: 'headerName', @@ -321,6 +420,16 @@ export const plugin: PluginDefinition = { const credentialsInBody = values.credentials === 'body'; const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token'; + // Build external browser options if enabled + const useExternalBrowser = !!values.useExternalBrowser; + const externalBrowserOptions = useExternalBrowser + ? { + useExternalBrowser: true, + callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType, + callbackPort: intArg(values, 'callbackPort') ?? undefined, + } + : undefined; + let token: AccessToken; if (grantType === 'authorization_code') { const authorizationUrl = stringArg(values, 'authorizationUrl'); @@ -348,6 +457,7 @@ export const plugin: PluginDefinition = { } : null, tokenName: tokenName, + externalBrowser: externalBrowserOptions, }); } else if (grantType === 'implicit') { const authorizationUrl = stringArg(values, 'authorizationUrl'); @@ -362,6 +472,7 @@ export const plugin: PluginDefinition = { audience: stringArgOrNull(values, 'audience'), state: stringArgOrNull(values, 'state'), tokenName: tokenName, + externalBrowser: externalBrowserOptions, }); } else if (grantType === 'client_credentials') { const accessTokenUrl = stringArg(values, 'accessTokenUrl'); @@ -414,3 +525,10 @@ function stringArg(values: Record, name: stri if (!arg) return ''; return arg; } + +function intArg(values: Record, name: string): number | null { + const arg = values[name]; + if (arg == null || arg === '') return null; + const num = parseInt(`${arg}`, 10); + return Number.isNaN(num) ? null : num; +} diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index 244fa495..b78ed951 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -83,7 +83,7 @@ export function DynamicForm>({ function FormInputsStack>({ className, ...props -}: FormInputsProps & { className?: string}) { +}: FormInputsProps & { className?: string }) { return ( >({ /> ); case 'accordion': + if (!hasVisibleInputs(input.inputs)) { + return null; + } return (
>({
); case 'h_stack': + if (!hasVisibleInputs(input.inputs)) { + return null; + } return (
>({
); case 'banner': + if (!hasVisibleInputs(input.inputs)) { + return null; + } return ( ); } + +function hasVisibleInputs(inputs: FormInput[] | undefined): boolean { + if (!inputs) return false; + return inputs.some((i) => !i.hidden); +} diff --git a/src-web/components/HttpAuthenticationEditor.tsx b/src-web/components/HttpAuthenticationEditor.tsx index a32a8dfd..56e11068 100644 --- a/src-web/components/HttpAuthenticationEditor.tsx +++ b/src-web/components/HttpAuthenticationEditor.tsx @@ -62,9 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {

Apply auth to all requests in {resolvedModelName(model)}

- - Documentation - + Documentation ); } @@ -140,7 +138,12 @@ export function HttpAuthenticationEditor({ model }: Props) { }), )} > - + )} diff --git a/src-web/components/core/Icon.tsx b/src-web/components/core/Icon.tsx index b4959171..df68385c 100644 --- a/src-web/components/core/Icon.tsx +++ b/src-web/components/core/Icon.tsx @@ -44,6 +44,7 @@ import { CookieIcon, CopyCheck, CopyIcon, + CornerRightDownIcon, CornerRightUpIcon, CreditCardIcon, CrosshairIcon, @@ -63,6 +64,7 @@ import { FlaskConicalIcon, FolderCodeIcon, FolderCogIcon, + FolderDownIcon, FolderGitIcon, FolderIcon, FolderInputIcon, @@ -179,6 +181,7 @@ const icons = { cookie: CookieIcon, copy: CopyIcon, copy_check: CopyCheck, + corner_right_down: CornerRightDownIcon, corner_right_up: CornerRightUpIcon, credit_card: CreditCardIcon, crosshair: CrosshairIcon, @@ -205,6 +208,7 @@ const icons = { folder_output: FolderOutputIcon, folder_symlink: FolderSymlinkIcon, folder_sync: FolderSyncIcon, + folder_down: FolderDownIcon, folder_up: FolderUpIcon, gift: GiftIcon, git_branch: GitBranchIcon, diff --git a/src-web/hooks/useAuthTab.tsx b/src-web/hooks/useAuthTab.tsx index d4ce679e..19affc71 100644 --- a/src-web/hooks/useAuthTab.tsx +++ b/src-web/hooks/useAuthTab.tsx @@ -1,5 +1,5 @@ import type { Folder } from '@yaakapp-internal/models'; -import { patchModel } from '@yaakapp-internal/models'; +import { modelTypeLabel, patchModel } from '@yaakapp-internal/models'; import { useMemo } from 'react'; import { openFolderSettings } from '../commands/openFolderSettings'; import { openWorkspaceSettings } from '../commands/openWorkspaceSettings'; @@ -57,49 +57,103 @@ export function useAuthTab(tabValue: T, model: AuthenticatedMo }, { label: 'No Auth', shortLabel: 'No Auth', value: 'none' }, ], - itemsAfter: - parentModel && - model.authenticationType && - model.authenticationType !== 'none' && - (parentModel.authenticationType == null || parentModel.authenticationType === 'none') - ? [ - { type: 'separator', label: 'Actions' }, - { - label: `Promote to ${capitalize(parentModel.model)}`, - leftSlot: ( - - ), - onSelect: async () => { - const confirmed = await showConfirm({ - id: 'promote-auth-confirm', - title: 'Promote Authentication', - confirmText: 'Promote', - description: ( - <> - Move authentication config to{' '} - {resolvedModelName(parentModel)}? - - ), - }); - if (confirmed) { - await patchModel(model, { authentication: {}, authenticationType: null }); - await patchModel(parentModel, { - authentication: model.authentication, - authenticationType: model.authenticationType, - }); + itemsAfter: (() => { + const actions: ( + | { type: 'separator'; label: string } + | { label: string; leftSlot: React.ReactNode; onSelect: () => Promise } + )[] = []; - if (parentModel.model === 'folder') { - openFolderSettings(parentModel.id, 'auth'); - } else { - openWorkspaceSettings('auth'); - } + // Promote: move auth from current model up to parent + if ( + parentModel && + model.authenticationType && + model.authenticationType !== 'none' && + (parentModel.authenticationType == null || parentModel.authenticationType === 'none') + ) { + actions.push( + { type: 'separator', label: 'Actions' }, + { + label: `Promote to ${capitalize(parentModel.model)}`, + leftSlot: ( + + ), + onSelect: async () => { + const confirmed = await showConfirm({ + id: 'promote-auth-confirm', + title: 'Promote Authentication', + confirmText: 'Promote', + description: ( + <> + Move authentication config to{' '} + {resolvedModelName(parentModel)}? + + ), + }); + if (confirmed) { + await patchModel(model, { authentication: {}, authenticationType: null }); + await patchModel(parentModel, { + authentication: model.authentication, + authenticationType: model.authenticationType, + }); + + if (parentModel.model === 'folder') { + openFolderSettings(parentModel.id, 'auth'); + } else { + openWorkspaceSettings('auth'); } - }, + } }, - ] - : undefined, + }, + ); + } + + // Copy from ancestor: copy auth config down to current model + const ancestorWithAuth = ancestors.find( + (a) => a.authenticationType != null && a.authenticationType !== 'none', + ); + if (ancestorWithAuth) { + if (actions.length === 0) { + actions.push({ type: 'separator', label: 'Actions' }); + } + actions.push({ + label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`, + leftSlot: ( + + ), + onSelect: async () => { + const confirmed = await showConfirm({ + id: 'copy-auth-confirm', + title: 'Copy Authentication', + confirmText: 'Copy', + description: ( + <> + Copy{' '} + {authentication.find((a) => a.name === ancestorWithAuth.authenticationType) + ?.label ?? 'authentication'}{' '} + config from {resolvedModelName(ancestorWithAuth)}? + This will override the current authentication but will not affect the{' '} + {modelTypeLabel(ancestorWithAuth).toLowerCase()}. + + ), + }); + if (confirmed) { + await patchModel(model, { + authentication: { ...ancestorWithAuth.authentication }, + authenticationType: ancestorWithAuth.authenticationType, + }); + } + }, + }); + } + + return actions.length > 0 ? actions : undefined; + })(), onChange: async (authenticationType) => { let authentication: Folder['authentication'] = model.authentication; if (model.authenticationType !== authenticationType) { @@ -113,5 +167,5 @@ export function useAuthTab(tabValue: T, model: AuthenticatedMo }; return [tab]; - }, [authentication, inheritedAuth, model, parentModel, tabValue]); + }, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]); }