Merge main into proxy branch (formatting and docs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 12:09:59 -07:00
parent 3c4035097a
commit 7314aedc71
712 changed files with 13408 additions and 13322 deletions

View File

@@ -25,7 +25,7 @@ The most secure and commonly used OAuth 2.0 flow for web applications.
### Client Credentials Flow
Ideal for server-to-server authentication where no user interaction is required.
Ideal for server-to-server authentication where no user interaction is required.
### Implicit Flow

View File

@@ -1,18 +1,18 @@
{
"name": "@yaak/auth-oauth2",
"displayName": "OAuth 2.0",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using OAuth 2.0",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-oauth2"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
"test": "vp test --run tests"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"

View File

@@ -1,8 +1,8 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http';
import type { Context } from '@yaakapp/api';
import type { IncomingMessage, ServerResponse } from "node:http";
import http from "node:http";
import type { Context } from "@yaakapp/api";
export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
export const HOSTED_CALLBACK_URL_BASE = "https://oauth.yaak.app/redirect";
export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
@@ -36,12 +36,12 @@ export function startCallbackServer(options: {
}): Promise<CallbackServerResult> {
// Stop any previously active server before starting a new one
if (activeServer) {
console.log('[oauth2] Stopping previous callback server before starting new one');
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;
const { port = 0, path = "/callback", timeoutMs = CALLBACK_TIMEOUT_MS } = options;
return new Promise((resolve, reject) => {
let callbackResolve: ((url: string) => void) | null = null;
@@ -50,33 +50,33 @@ export function startCallbackServer(options: {
let stopped = false;
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
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');
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
if (req.method === 'POST') {
if (req.method === "POST") {
// POST: read JSON body with the final callback URL and resolve
let body = '';
req.on('data', (chunk: Buffer) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on('end', () => {
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');
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');
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
// Resolve the callback promise
if (callbackResolve) {
@@ -88,19 +88,19 @@ export function startCallbackServer(options: {
// 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');
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.writeHead(200, { "Content-Type": "text/html" });
res.end(getFragmentForwardingHtml());
});
server.on('error', (err: Error) => {
server.on("error", (err: Error) => {
if (!stopped) {
reject(err);
}
@@ -123,16 +123,16 @@ export function startCallbackServer(options: {
server.close();
if (callbackReject) {
callbackReject(new Error('Callback server stopped'));
callbackReject(new Error("Callback server stopped"));
callbackResolve = null;
callbackReject = null;
}
};
server.listen(port, '127.0.0.1', () => {
server.listen(port, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
if (!address || typeof address === "string") {
reject(new Error("Failed to get server address"));
return;
}
@@ -147,7 +147,7 @@ export function startCallbackServer(options: {
waitForCallback: () => {
return new Promise<string>((res, rej) => {
if (stopped) {
rej(new Error('Callback server already stopped'));
rej(new Error("Callback server already stopped"));
return;
}
@@ -157,7 +157,7 @@ export function startCallbackServer(options: {
// Set timeout
timeoutHandle = setTimeout(() => {
if (callbackReject) {
callbackReject(new Error('Authorization timed out'));
callbackReject(new Error("Authorization timed out"));
callbackResolve = null;
callbackReject = null;
}
@@ -193,7 +193,7 @@ export function buildHostedCallbackRedirectUri(localPort: number): string {
*/
export function stopActiveServer(): void {
if (activeServer) {
console.log('[oauth2] Stopping active callback server during dispose');
console.log("[oauth2] Stopping active callback server during dispose");
activeServer.stop();
activeServer = null;
}
@@ -210,7 +210,7 @@ export async function getRedirectUrlViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
options: {
callbackType: 'localhost' | 'hosted';
callbackType: "localhost" | "hosted";
callbackPort?: number;
},
): Promise<{ callbackUrl: string; redirectUri: string }> {
@@ -222,31 +222,31 @@ export async function getRedirectUrlViaExternalBrowser(
const server = await startCallbackServer({
port,
path: '/callback',
path: "/callback",
});
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
if (callbackType === "hosted") {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
console.log("[oauth2] Using hosted callback redirect:", oauthRedirectUri);
} else {
oauthRedirectUri = server.redirectUri;
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
console.log("[oauth2] Using localhost callback redirect:", oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
authorizationUrl.searchParams.set("redirect_uri", oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
console.log("[oauth2] Opening external browser:", authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: 'Opening browser for authorization...',
icon: 'info',
message: "Opening browser for authorization...",
icon: "info",
timeout: 3000,
});
@@ -254,10 +254,10 @@ export async function getRedirectUrlViaExternalBrowser(
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
console.log('[oauth2] Waiting for callback on', server.redirectUri);
console.log("[oauth2] Waiting for callback on", server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log('[oauth2] Received callback:', callbackUrl);
console.log("[oauth2] Received callback:", callbackUrl);
return { callbackUrl, redirectUri: oauthRedirectUri };
} finally {

View File

@@ -1,6 +1,6 @@
import { readFileSync } from 'node:fs';
import type { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api';
import type { AccessTokenRawResponse } from './store';
import { readFileSync } from "node:fs";
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
import type { AccessTokenRawResponse } from "./store";
export async function fetchAccessToken(
ctx: Context,
@@ -14,64 +14,64 @@ export async function fetchAccessToken(
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
): Promise<AccessTokenRawResponse> {
const { clientId, grantType, accessTokenUrl, scope, audience, params } = args;
console.log('[oauth2] Getting access token', accessTokenUrl);
console.log("[oauth2] Getting access token", accessTokenUrl);
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
method: "POST",
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: 'grant_type', value: grantType }, ...params],
form: [{ name: "grant_type", value: grantType }, ...params],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: "User-Agent", value: "yaak" },
{
name: 'Accept',
value: 'application/x-www-form-urlencoded, application/json',
name: "Accept",
value: "application/x-www-form-urlencoded, application/json",
},
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience });
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
if ('clientAssertion' in args) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
if ("clientAssertion" in args) {
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: 'client_assertion_type',
value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
name: "client_assertion_type",
value: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
});
httpRequest.body?.form.push({
name: 'client_assertion',
name: "client_assertion",
value: args.clientAssertion,
});
} else if (args.credentialsInBody) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: 'client_secret',
name: "client_secret",
value: args.clientSecret,
});
} else {
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`;
httpRequest.headers?.push({ name: 'Authorization', value });
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
console.log('[oauth2] Got access token response', resp.status);
console.log("[oauth2] Got access token response", resp.status);
if (resp.error) {
throw new Error(`Failed to fetch access token: ${resp.error}`);
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);
}
// biome-ignore lint/suspicious/noExplicitAny: none
// oxlint-disable-next-line no-explicit-any
let response: any;
try {
response = JSON.parse(body);

View File

@@ -1,8 +1,8 @@
import { readFileSync } from 'node:fs';
import type { Context, HttpRequest } from '@yaakapp/api';
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from './store';
import { deleteToken, getToken, storeToken } from './store';
import { isTokenExpired } from './util';
import { readFileSync } from "node:fs";
import type { Context, HttpRequest } from "@yaakapp/api";
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
import { deleteToken, getToken, storeToken } from "./store";
import { isTokenExpired } from "./util";
export async function getOrRefreshAccessToken(
ctx: Context,
@@ -42,33 +42,33 @@ export async function getOrRefreshAccessToken(
// Access token is expired, so get a new one
const httpRequest: Partial<HttpRequest> = {
method: 'POST',
method: "POST",
url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded',
bodyType: "application/x-www-form-urlencoded",
body: {
form: [
{ name: 'grant_type', value: 'refresh_token' },
{ name: 'refresh_token', value: token.response.refresh_token },
{ name: "grant_type", value: "refresh_token" },
{ name: "refresh_token", value: token.response.refresh_token },
],
},
headers: [
{ name: 'User-Agent', value: 'yaak' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: "User-Agent", value: "yaak" },
{ name: "Accept", value: "application/x-www-form-urlencoded, application/json" },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
if (credentialsInBody) {
httpRequest.body?.form.push({ name: 'client_id', value: clientId });
httpRequest.body?.form.push({ name: 'client_secret', value: clientSecret });
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({ name: "client_secret", value: clientSecret });
} else {
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`;
httpRequest.headers?.push({ name: 'Authorization', value });
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
httpRequest.authenticationType = 'none'; // Don't inherit workspace auth
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const resp = await ctx.httpRequest.send({ httpRequest });
if (resp.error) {
@@ -78,20 +78,20 @@ export async function getOrRefreshAccessToken(
if (resp.status >= 400 && resp.status < 500) {
// Client errors (4xx) indicate the refresh token is invalid, expired, or revoked
// Delete the token and return null to trigger a fresh authorization flow
console.log('[oauth2] Refresh token request failed with client error, deleting token');
console.log("[oauth2] Refresh token request failed with client error, deleting token");
await deleteToken(ctx, tokenArgs);
return null;
}
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
const body = resp.bodyPath ? readFileSync(resp.bodyPath, "utf8") : "";
console.log('[oauth2] Got refresh token response', resp.status);
console.log("[oauth2] Got refresh token response", resp.status);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to refresh access token with status=${resp.status} and body=${body}`);
}
// biome-ignore lint/suspicious/noExplicitAny: none
// oxlint-disable-next-line no-explicit-any
let response: any;
try {
response = JSON.parse(body);

View File

@@ -1,17 +1,17 @@
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';
import { getDataDirKey, storeToken } from '../store';
import { extractCode } from '../util';
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";
import { getDataDirKey, storeToken } from "../store";
import { extractCode } from "../util";
export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
export const PKCE_SHA256 = "S256";
export const PKCE_PLAIN = "plain";
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
export type CallbackType = 'localhost' | 'hosted';
export type CallbackType = "localhost" | "hosted";
export interface ExternalBrowserOptions {
useExternalBrowser: boolean;
@@ -50,7 +50,7 @@ export async function getAuthorizationCode(
challengeMethod: string;
codeVerifier: string;
} | null;
tokenName: 'access_token' | 'id_token';
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
@@ -74,21 +74,21 @@ export async function getAuthorizationCode(
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (pkce) {
authorizationUrl.searchParams.set(
'code_challenge',
"code_challenge",
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
);
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
authorizationUrl.searchParams.set("code_challenge_method", pkce.challengeMethod);
}
let code: string;
@@ -103,21 +103,21 @@ export async function getAuthorizationCode(
// 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');
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);
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
}
console.log('[oauth2] Code found');
console.log("[oauth2] Code found");
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
grantType: "authorization_code",
accessTokenUrl,
clientId,
clientSecret,
@@ -125,9 +125,9 @@ export async function getAuthorizationCode(
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
{ name: "code", value: code },
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
],
});
@@ -146,18 +146,18 @@ async function getCodeViaEmbeddedBrowser(
): Promise<string> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
console.log("[oauth2] Authorizing via embedded browser", authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<string>(async (resolve, reject) => {
let foundCode = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
label: "oauth-authorization-url",
async onClose() {
if (!foundCode) {
reject(new Error('Authorization window closed'));
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
@@ -187,21 +187,21 @@ export function genPkceCodeVerifier() {
}
function pkceCodeChallenge(verifier: string, method: string) {
if (method === 'plain') {
if (method === "plain") {
return verifier;
}
const hash = encodeForPkce(createHash('sha256').update(verifier).digest());
const hash = encodeForPkce(createHash("sha256").update(verifier).digest());
return hash
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}
function encodeForPkce(bytes: Buffer) {
return bytes
.toString('base64')
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_'
.toString("base64")
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}

View File

@@ -1,25 +1,25 @@
import { createPrivateKey, randomUUID } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import jwt, { type Algorithm } from 'jsonwebtoken';
import { fetchAccessToken } from '../fetchAccessToken';
import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
import { createPrivateKey, randomUUID } from "node:crypto";
import type { Context } from "@yaakapp/api";
import jwt, { type Algorithm } from "jsonwebtoken";
import { fetchAccessToken } from "../fetchAccessToken";
import type { TokenStoreArgs } from "../store";
import { getToken, storeToken } from "../store";
import { isTokenExpired } from "../util";
export const jwtAlgorithms = [
'HS256',
'HS384',
'HS512',
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'none',
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"none",
] as const;
export const defaultJwtAlgorithm = jwtAlgorithms[0];
@@ -40,7 +40,7 @@ function buildClientAssertionJwt(params: {
}): string {
const { clientId, accessTokenUrl, secret, algorithm } = params;
const isHmac = algorithm.startsWith('HS') || algorithm === 'none';
const isHmac = algorithm.startsWith("HS") || algorithm === "none";
// Resolve the signing key depending on format
let signingKey: jwt.Secret;
@@ -51,24 +51,25 @@ function buildClientAssertionJwt(params: {
if (isHmac) {
// HMAC algorithms use the raw secret (string or Buffer)
signingKey = secret;
} else if (trimmed.startsWith('{')) {
} else if (trimmed.startsWith("{")) {
// Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.
// oxlint-disable-next-line no-explicit-any
let jwk: any;
try {
jwk = JSON.parse(trimmed);
} catch {
throw new Error('Client Assertion secret looks like JSON but is not valid');
throw new Error("Client Assertion secret looks like JSON but is not valid");
}
kid = jwk?.kid;
signingKey = createPrivateKey({ key: jwk, format: 'jwk' });
} else if (trimmed.startsWith('-----')) {
signingKey = createPrivateKey({ key: jwk, format: "jwk" });
} else if (trimmed.startsWith("-----")) {
// PEM-encoded key
signingKey = createPrivateKey({ key: trimmed, format: 'pem' });
signingKey = createPrivateKey({ key: trimmed, format: "pem" });
} else {
throw new Error(
'Client Assertion secret must be a JWK JSON object, a PEM-encoded key ' +
'(starting with -----), or a raw secret for HMAC algorithms.',
"Client Assertion secret must be a JWK JSON object, a PEM-encoded key " +
"(starting with -----), or a raw secret for HMAC algorithms.",
);
}
@@ -83,7 +84,7 @@ function buildClientAssertionJwt(params: {
};
// Build the JWT header; include "kid" when available
const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' };
const header: jwt.JwtHeader = { alg: algorithm, typ: "JWT" };
if (kid) {
header.kid = kid;
}
@@ -134,9 +135,9 @@ export async function getClientCredentials(
const common: Omit<
Parameters<typeof fetchAccessToken>[1],
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
"clientAssertion" | "clientSecret" | "credentialsInBody"
> = {
grantType: 'client_credentials',
grantType: "client_credentials",
accessTokenUrl,
audience,
clientId,
@@ -145,7 +146,7 @@ export async function getClientCredentials(
};
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
clientCredentialsMethod === 'client_assertion'
clientCredentialsMethod === "client_assertion"
? {
...common,
clientAssertion: buildClientAssertionJwt({
@@ -153,7 +154,7 @@ export async function getClientCredentials(
algorithm: clientAssertionAlgorithm as Algorithm,
accessTokenUrl,
secret: clientAssertionSecretBase64
? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8')
? Buffer.from(clientAssertionSecret, "base64").toString("utf-8")
: clientAssertionSecret,
}),
}

View File

@@ -1,9 +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';
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,
@@ -26,7 +26,7 @@ export async function getImplicit(
scope: string | null;
state: string | null;
audience: string | null;
tokenName: 'access_token' | 'id_token';
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
@@ -43,18 +43,18 @@ export async function getImplicit(
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`);
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', responseType);
authorizationUrl.searchParams.set('client_id', clientId);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set("response_type", responseType);
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (responseType.includes("id_token")) {
authorizationUrl.searchParams.set(
'nonce',
"nonce",
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
@@ -71,7 +71,7 @@ export async function getImplicit(
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
newToken = await getTokenViaEmbeddedBrowser(
ctx,
@@ -99,28 +99,28 @@ async function getTokenViaEmbeddedBrowser(
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr);
console.log("[oauth2] Authorizing via embedded browser (implicit)", authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: 'oauth-authorization-url',
label: "oauth-authorization-url",
async onClose() {
if (!foundAccessToken) {
reject(new Error('Authorization window closed'));
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (url.searchParams.has('error')) {
return reject(Error(`Failed to authorize: ${url.searchParams.get('error')}`));
if (url.searchParams.has("error")) {
return reject(Error(`Failed to authorize: ${url.searchParams.get("error")}`));
}
const hash = url.hash.slice(1);
@@ -158,13 +158,13 @@ async function extractImplicitToken(
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const url = new URL(callbackUrl);
// Check for errors
if (url.searchParams.has('error')) {
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
if (url.searchParams.has("error")) {
throw new Error(`Failed to authorize: ${url.searchParams.get("error")}`);
}
// Extract token from fragment
@@ -179,18 +179,18 @@ async function extractImplicitToken(
// 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)
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,
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');
const idToken = params.get("id_token") ?? url.searchParams.get("id_token");
if (idToken) {
response.id_token = idToken;
}

View File

@@ -1,8 +1,8 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store';
import { storeToken } from '../store';
import type { Context } from "@yaakapp/api";
import { fetchAccessToken } from "../fetchAccessToken";
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
import type { AccessToken, TokenStoreArgs } from "../store";
import { storeToken } from "../store";
export async function getPassword(
ctx: Context,
@@ -50,11 +50,11 @@ export async function getPassword(
clientSecret,
scope,
audience,
grantType: 'password',
grantType: "password",
credentialsInBody,
params: [
{ name: 'username', value: username },
{ name: 'password', value: password },
{ name: "username", value: username },
{ name: "password", value: password },
],
});

View File

@@ -4,13 +4,13 @@ import type {
GetHttpAuthenticationConfigRequest,
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
} from "@yaakapp/api";
import type { Algorithm } from "jsonwebtoken";
import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
stopActiveServer,
} from './callbackServer';
} from "./callbackServer";
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
@@ -18,31 +18,31 @@ import {
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
} from "./grants/authorizationCode";
import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
} from "./grants/clientCredentials";
import { getImplicit } from "./grants/implicit";
import { getPassword } from "./grants/password";
import type { AccessToken, TokenStoreArgs } from "./store";
import { deleteToken, getToken, resetDataDirKey } from "./store";
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
type GrantType = "authorization_code" | "implicit" | "password" | "client_credentials";
const grantTypes: FormInputSelectOption[] = [
{ label: 'Authorization Code', value: 'authorization_code' },
{ label: 'Implicit', value: 'implicit' },
{ label: 'Resource Owner Password Credential', value: 'password' },
{ label: 'Client Credentials', value: 'client_credentials' },
{ label: "Authorization Code", value: "authorization_code" },
{ label: "Implicit", value: "implicit" },
{ label: "Resource Owner Password Credential", value: "password" },
{ label: "Client Credentials", value: "client_credentials" },
];
const defaultGrantType = grantTypes[0]?.value;
function hiddenIfNot(
grantTypes: GrantType[],
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
@@ -53,37 +53,37 @@ function hiddenIfNot(
}
const authorizationUrls = [
'https://github.com/login/oauth/authorize',
'https://account.box.com/api/oauth2/authorize',
'https://accounts.google.com/o/oauth2/v2/auth',
'https://api.imgur.com/oauth2/authorize',
'https://bitly.com/oauth/authorize',
'https://gitlab.example.com/oauth/authorize',
'https://medium.com/m/oauth/authorize',
'https://public-api.wordpress.com/oauth2/authorize',
'https://slack.com/oauth/authorize',
'https://todoist.com/oauth/authorize',
'https://www.dropbox.com/oauth2/authorize',
'https://www.linkedin.com/oauth/v2/authorization',
'https://MY_SHOP.myshopify.com/admin/oauth/access_token',
'https://appcenter.intuit.com/app/connect/oauth2/authorize',
"https://github.com/login/oauth/authorize",
"https://account.box.com/api/oauth2/authorize",
"https://accounts.google.com/o/oauth2/v2/auth",
"https://api.imgur.com/oauth2/authorize",
"https://bitly.com/oauth/authorize",
"https://gitlab.example.com/oauth/authorize",
"https://medium.com/m/oauth/authorize",
"https://public-api.wordpress.com/oauth2/authorize",
"https://slack.com/oauth/authorize",
"https://todoist.com/oauth/authorize",
"https://www.dropbox.com/oauth2/authorize",
"https://www.linkedin.com/oauth/v2/authorization",
"https://MY_SHOP.myshopify.com/admin/oauth/access_token",
"https://appcenter.intuit.com/app/connect/oauth2/authorize",
];
const accessTokenUrls = [
'https://github.com/login/oauth/access_token',
'https://api-ssl.bitly.com/oauth/access_token',
'https://api.box.com/oauth2/token',
'https://api.dropboxapi.com/oauth2/token',
'https://api.imgur.com/oauth2/token',
'https://api.medium.com/v1/tokens',
'https://gitlab.example.com/oauth/token',
'https://public-api.wordpress.com/oauth2/token',
'https://slack.com/api/oauth.access',
'https://todoist.com/oauth/access_token',
'https://www.googleapis.com/oauth2/v4/token',
'https://www.linkedin.com/oauth/v2/accessToken',
'https://MY_SHOP.myshopify.com/admin/oauth/authorize',
'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
"https://github.com/login/oauth/access_token",
"https://api-ssl.bitly.com/oauth/access_token",
"https://api.box.com/oauth2/token",
"https://api.dropboxapi.com/oauth2/token",
"https://api.imgur.com/oauth2/token",
"https://api.medium.com/v1/tokens",
"https://gitlab.example.com/oauth/token",
"https://public-api.wordpress.com/oauth2/token",
"https://slack.com/api/oauth.access",
"https://todoist.com/oauth/access_token",
"https://www.googleapis.com/oauth2/v4/token",
"https://www.linkedin.com/oauth/v2/accessToken",
"https://MY_SHOP.myshopify.com/admin/oauth/authorize",
"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
];
export const plugin: PluginDefinition = {
@@ -91,59 +91,59 @@ export const plugin: PluginDefinition = {
stopActiveServer();
},
authentication: {
name: 'oauth2',
label: 'OAuth 2.0',
shortLabel: 'OAuth 2',
name: "oauth2",
label: "OAuth 2.0",
shortLabel: "OAuth 2",
actions: [
{
label: 'Copy Current Token',
label: "Copy Current Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({
message: 'No token to copy',
color: 'warning',
message: "No token to copy",
color: "warning",
});
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
message: 'Token copied to clipboard',
icon: 'copy',
color: 'success',
message: "Token copied to clipboard",
icon: "copy",
color: "success",
});
}
},
},
{
label: 'Delete Token',
label: "Delete Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({
message: 'Token deleted',
color: 'success',
message: "Token deleted",
color: "success",
});
} else {
await ctx.toast.show({
message: 'No token to delete',
color: 'warning',
message: "No token to delete",
color: "warning",
});
}
},
},
{
label: 'Clear Window Session',
label: "Clear Window Session",
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
},
@@ -151,85 +151,85 @@ export const plugin: PluginDefinition = {
],
args: [
{
type: 'select',
name: 'grantType',
label: 'Grant Type',
type: "select",
name: "grantType",
label: "Grant Type",
defaultValue: defaultGrantType,
options: grantTypes,
},
{
type: 'select',
name: 'clientCredentialsMethod',
label: 'Authentication Method',
type: "select",
name: "clientCredentialsMethod",
label: "Authentication Method",
description:
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
defaultValue: 'client_secret',
defaultValue: "client_secret",
options: [
{ label: 'Client Secret', value: 'client_secret' },
{ label: 'Client Assertion', value: 'client_assertion' },
{ label: "Client Secret", value: "client_secret" },
{ label: "Client Assertion", value: "client_assertion" },
],
dynamic: hiddenIfNot(['client_credentials']),
dynamic: hiddenIfNot(["client_credentials"]),
},
{
type: 'text',
name: 'clientId',
label: 'Client ID',
type: "text",
name: "clientId",
label: "Client ID",
optional: true,
},
{
type: 'text',
name: 'clientSecret',
label: 'Client Secret',
type: "text",
name: "clientSecret",
label: "Client Secret",
optional: true,
password: true,
dynamic: hiddenIfNot(
['authorization_code', 'password', 'client_credentials'],
(values) => values.clientCredentialsMethod === 'client_secret',
["authorization_code", "password", "client_credentials"],
(values) => values.clientCredentialsMethod === "client_secret",
),
},
{
type: 'select',
name: 'clientAssertionAlgorithm',
label: 'JWT Algorithm',
type: "select",
name: "clientAssertionAlgorithm",
label: "JWT Algorithm",
defaultValue: defaultJwtAlgorithm,
options: jwtAlgorithms.map((value) => ({
label: value === 'none' ? 'None' : value,
label: value === "none" ? "None" : value,
value,
})),
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'text',
name: 'clientAssertionSecret',
label: 'JWT Secret',
type: "text",
name: "clientAssertionSecret",
label: "JWT Secret",
description:
'Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.',
"Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.",
password: true,
optional: true,
multiLine: true,
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'checkbox',
name: 'clientAssertionSecretBase64',
label: 'JWT secret is base64 encoded',
type: "checkbox",
name: "clientAssertionSecretBase64",
label: "JWT secret is base64 encoded",
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: 'text',
name: 'authorizationUrl',
type: "text",
name: "authorizationUrl",
optional: true,
label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
label: "Authorization URL",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({
label: url,
@@ -237,103 +237,103 @@ export const plugin: PluginDefinition = {
})),
},
{
type: 'text',
name: 'accessTokenUrl',
type: "text",
name: "accessTokenUrl",
optional: true,
label: 'Access Token URL',
label: "Access Token URL",
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'banner',
type: "banner",
inputs: [
{
type: 'checkbox',
name: 'useExternalBrowser',
label: 'Use External Browser',
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']),
"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 (can be any valid URL)',
placeholder: 'https://mysite.example.com/oauth/callback',
type: "text",
name: "redirectUri",
label: "Redirect URI (can be any valid URL)",
placeholder: "https://mysite.example.com/oauth/callback",
description:
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
"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'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'select',
name: 'callbackType',
label: 'Callback Type',
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',
defaultValue: "hosted",
options: [
{ label: 'Hosted Redirect', value: 'hosted' },
{ label: 'Localhost', value: 'localhost' },
{ label: "Hosted Redirect", value: "hosted" },
{ label: "Localhost", value: "localhost" },
],
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: 'text',
name: 'callbackPort',
label: 'Callback Port',
type: "text",
name: "callbackPort",
label: "Callback Port",
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
'Port for the local callback server. Defaults to ' +
"Port for the local callback server. Defaults to " +
DEFAULT_LOCALHOST_PORT +
' if empty.',
" if empty.",
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
],
},
{
type: 'banner',
color: 'info',
type: "banner",
color: "info",
inputs: [
{
type: 'markdown',
content: 'Redirect URI to Register',
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;
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) ||
!["authorization_code", "implicit"].includes(grantType) ||
!useExternalBrowser
) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
const port = intArg(values, "callbackPort") || DEFAULT_LOCALHOST_PORT;
let redirectUri: string;
if (callbackType === 'hosted') {
if (callbackType === "hosted") {
redirectUri = buildHostedCallbackRedirectUri(port);
} else {
redirectUri = `http://127.0.0.1:${port}/callback`;
@@ -350,149 +350,149 @@ export const plugin: PluginDefinition = {
],
},
{
type: 'text',
name: 'state',
label: 'State',
type: "text",
name: "state",
label: "State",
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{ 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',
label: 'Token for authorization',
type: "select",
name: "tokenName",
label: "Token for authorization",
description:
'Select which token to send in the "Authorization: Bearer" header. Most APIs expect ' +
'access_token, but some (like OpenID Connect) require id_token.',
defaultValue: 'access_token',
"access_token, but some (like OpenID Connect) require id_token.",
defaultValue: "access_token",
options: [
{ label: 'access_token', value: 'access_token' },
{ label: 'id_token', value: 'id_token' },
{ label: "access_token", value: "access_token" },
{ label: "id_token", value: "id_token" },
],
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: 'banner',
type: "banner",
inputs: [
{
type: 'checkbox',
name: 'usePkce',
label: 'Use PKCE',
dynamic: hiddenIfNot(['authorization_code']),
type: "checkbox",
name: "usePkce",
label: "Use PKCE",
dynamic: hiddenIfNot(["authorization_code"]),
},
{
type: 'select',
name: 'pkceChallengeMethod',
label: 'Code Challenge Method',
type: "select",
name: "pkceChallengeMethod",
label: "Code Challenge Method",
options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
{ label: "SHA-256", value: PKCE_SHA256 },
{ label: "Plain", value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated when not set',
type: "text",
name: "pkceCodeChallenge",
label: "Code Verifier",
placeholder: "Automatically generated when not set",
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
],
},
{
type: 'h_stack',
type: "h_stack",
inputs: [
{
type: 'text',
name: 'username',
label: 'Username',
type: "text",
name: "username",
label: "Username",
optional: true,
dynamic: hiddenIfNot(['password']),
dynamic: hiddenIfNot(["password"]),
},
{
type: 'text',
name: 'password',
label: 'Password',
type: "text",
name: "password",
label: "Password",
password: true,
optional: true,
dynamic: hiddenIfNot(['password']),
dynamic: hiddenIfNot(["password"]),
},
],
},
{
type: 'select',
name: 'responseType',
label: 'Response Type',
defaultValue: 'token',
type: "select",
name: "responseType",
label: "Response Type",
defaultValue: "token",
options: [
{ label: 'Access Token', value: 'token' },
{ label: 'ID Token', value: 'id_token' },
{ label: 'ID and Access Token', value: 'id_token token' },
{ label: "Access Token", value: "token" },
{ label: "ID Token", value: "id_token" },
{ label: "ID and Access Token", value: "id_token token" },
],
dynamic: hiddenIfNot(['implicit']),
dynamic: hiddenIfNot(["implicit"]),
},
{
type: 'accordion',
label: 'Advanced',
type: "accordion",
label: "Advanced",
inputs: [
{
type: 'text',
name: 'headerName',
label: 'Header Name',
defaultValue: 'Authorization',
type: "text",
name: "headerName",
label: "Header Name",
defaultValue: "Authorization",
},
{
type: 'text',
name: 'headerPrefix',
label: 'Header Prefix',
type: "text",
name: "headerPrefix",
label: "Header Prefix",
optional: true,
defaultValue: 'Bearer',
defaultValue: "Bearer",
},
{
type: 'select',
name: 'credentials',
label: 'Send Credentials',
defaultValue: 'body',
type: "select",
name: "credentials",
label: "Send Credentials",
defaultValue: "body",
options: [
{ label: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' },
{ label: "In Request Body", value: "body" },
{ label: "As Basic Authentication", value: "basic" },
],
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
hidden:
values.grantType === 'client_credentials' &&
values.clientCredentialsMethod === 'client_assertion',
values.grantType === "client_credentials" &&
values.clientCredentialsMethod === "client_assertion",
}),
},
],
},
{
type: 'accordion',
label: 'Access Token Response',
type: "accordion",
label: "Access Token Response",
inputs: [],
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
return {
label: 'Access Token Response',
label: "Access Token Response",
inputs: [
{
type: 'editor',
name: 'response',
type: "editor",
name: "response",
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,
language: 'json',
language: "json",
},
],
};
@@ -500,101 +500,101 @@ export const plugin: PluginDefinition = {
},
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = stringArg(values, 'headerPrefix');
const grantType = stringArg(values, 'grantType') as GrantType;
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
const headerPrefix = stringArg(values, "headerPrefix");
const grantType = stringArg(values, "grantType") as GrantType;
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,
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');
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
if (grantType === "authorization_code") {
const authorizationUrl = stringArg(values, "authorizationUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl:
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
accessTokenUrl === "" || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl:
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
authorizationUrl === "" || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
redirectUri: stringArgOrNull(values, "redirectUri"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
challengeMethod: stringArg(values, "pkceChallengeMethod") || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, "pkceCodeVerifier") || genPkceCodeVerifier(),
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
} else if (grantType === "implicit") {
const authorizationUrl = stringArg(values, "authorizationUrl");
token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
responseType: stringArg(values, 'responseType'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
clientId: stringArg(values, "clientId"),
redirectUri: stringArgOrNull(values, "redirectUri"),
responseType: stringArg(values, "responseType"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
} else if (grantType === "client_credentials") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
clientSecret: stringArg(values, 'clientSecret'),
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
clientId: stringArg(values, "clientId"),
clientAssertionAlgorithm: stringArg(values, "clientAssertionAlgorithm") as Algorithm,
clientSecret: stringArg(values, "clientSecret"),
clientCredentialsMethod: stringArg(values, "clientCredentialsMethod"),
clientAssertionSecret: stringArg(values, "clientAssertionSecret"),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
});
} else if (grantType === 'password') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
} else if (grantType === "password") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
username: stringArg(values, 'username'),
password: stringArg(values, 'password'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
username: stringArg(values, "username"),
password: stringArg(values, "password"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
});
} else {
throw new Error(`Invalid grant type ${grantType}`);
throw new Error(`Invalid grant type ${String(grantType)}`);
}
const headerName = stringArg(values, 'headerName') || 'Authorization';
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
const headerName = stringArg(values, "headerName") || "Authorization";
const headerValue = `${headerPrefix} ${token.response[tokenName] ?? ""}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
@@ -605,19 +605,19 @@ function stringArgOrNull(
name: string,
): string | null {
const arg = values[name];
if (arg == null || arg === '') return null;
if (arg == null || arg === "") return null;
return `${arg}`;
}
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
const arg = stringArgOrNull(values, name);
if (!arg) return '';
if (!arg) return "";
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === '') return null;
if (arg == null || arg === "") return null;
const num = parseInt(`${arg}`, 10);
return Number.isNaN(num) ? null : num;
}

View File

@@ -1,14 +1,14 @@
import { createHash } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api";
export async function storeToken(
ctx: Context,
args: TokenStoreArgs,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
tokenName: "access_token" | "id_token" = "access_token",
) {
if (!response[tokenName]) {
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(', ')}`);
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
}
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
@@ -34,7 +34,7 @@ export async function resetDataDirKey(ctx: Context, contextId: string) {
}
export async function getDataDirKey(ctx: Context, contextId: string) {
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? 'default';
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? "default";
return `${contextId}::${key}`;
}
@@ -50,17 +50,17 @@ export interface TokenStoreArgs {
* account for slight variations (like domains with and without a protocol scheme).
*/
function tokenStoreKey(args: TokenStoreArgs) {
const hash = createHash('md5');
const hash = createHash("md5");
if (args.contextId) hash.update(args.contextId.trim());
if (args.clientId) hash.update(args.clientId.trim());
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ''));
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ''));
const key = hash.digest('hex');
return ['token', key].join('::');
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ""));
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ""));
const key = hash.digest("hex");
return ["token", key].join("::");
}
function dataDirStoreKey(contextId: string) {
return ['data_dir', contextId].join('::');
return ["data_dir", contextId].join("::");
}
export interface AccessToken {

View File

@@ -1,4 +1,4 @@
import type { AccessToken } from './store';
import type { AccessToken } from "./store";
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
@@ -8,24 +8,24 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
const url = new URL(urlStr);
if (!urlMatchesRedirect(url, redirectUri)) {
console.log('[oauth2] URL does not match redirect origin/path; skipping.');
console.log("[oauth2] URL does not match redirect origin/path; skipping.");
return null;
}
// Prefer query param; fall back to fragment if query lacks it
const query = url.searchParams;
const queryError = query.get('error');
const queryDesc = query.get('error_description');
const queryUri = query.get('error_uri');
const queryError = query.get("error");
const queryDesc = query.get("error_description");
const queryUri = query.get("error_uri");
let hashParams: URLSearchParams | null = null;
if (url.hash && url.hash.length > 1) {
hashParams = new URLSearchParams(url.hash.slice(1));
}
const hashError = hashParams?.get('error');
const hashDesc = hashParams?.get('error_description');
const hashUri = hashParams?.get('error_uri');
const hashError = hashParams?.get("error");
const hashDesc = hashParams?.get("error_description");
const hashUri = hashParams?.get("error_uri");
const error = queryError || hashError;
if (error) {
@@ -37,13 +37,13 @@ export function extractCode(urlStr: string, redirectUri: string | null): string
throw new Error(message);
}
const queryCode = query.get('code');
const queryCode = query.get("code");
if (queryCode) return queryCode;
const hashCode = hashParams?.get('code');
const hashCode = hashParams?.get("code");
if (hashCode) return hashCode;
console.log('[oauth2] Code not found');
console.log("[oauth2] Code not found");
return null;
}
@@ -54,7 +54,7 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
try {
redirect = new URL(redirectUrl);
} catch {
console.log('[oauth2] Invalid redirect URI; skipping.');
console.log("[oauth2] Invalid redirect URI; skipping.");
return false;
}
@@ -63,17 +63,17 @@ export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolea
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
const normalizePort = (u: URL) =>
(u.protocol === 'https:' && (!u.port || u.port === '443')) ||
(u.protocol === 'http:' && (!u.port || u.port === '80'))
? ''
(u.protocol === "https:" && (!u.port || u.port === "443")) ||
(u.protocol === "http:" && (!u.port || u.port === "80"))
? ""
: u.port;
const samePort = normalizePort(url) === normalizePort(redirect);
const normPath = (p: string) => {
const withLeading = p.startsWith('/') ? p : `/${p}`;
const withLeading = p.startsWith("/") ? p : `/${p}`;
// strip trailing slashes, keep root as "/"
return withLeading.replace(/\/+$/g, '') || '/';
return withLeading.replace(/\/+$/g, "") || "/";
};
// Require redirect path to be a prefix of the navigated URL path

View File

@@ -1,109 +1,109 @@
import { describe, expect, test } from 'vitest';
import { extractCode } from '../src/util';
import { describe, expect, test } from "vite-plus/test";
import { extractCode } from "../src/util";
describe('extractCode', () => {
test('extracts code from query when same origin + path', () => {
const url = 'https://app.example.com/cb?code=abc123&state=xyz';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc123');
describe("extractCode", () => {
test("extracts code from query when same origin + path", () => {
const url = "https://app.example.com/cb?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc123");
});
test('extracts code from query with weird path', () => {
const url = 'https://app.example.com/cbwithextra?code=abc123&state=xyz';
const redirect = 'https://app.example.com/cb';
test("extracts code from query with weird path", () => {
const url = "https://app.example.com/cbwithextra?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test('allows trailing slash differences', () => {
expect(extractCode('https://app.example.com/cb/?code=abc', 'https://app.example.com/cb')).toBe(
'abc',
test("allows trailing slash differences", () => {
expect(extractCode("https://app.example.com/cb/?code=abc", "https://app.example.com/cb")).toBe(
"abc",
);
expect(extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com/cb/')).toBe(
'abc',
expect(extractCode("https://app.example.com/cb?code=abc", "https://app.example.com/cb/")).toBe(
"abc",
);
});
test('treats default ports as equal (https:443, http:80)', () => {
test("treats default ports as equal (https:443, http:80)", () => {
expect(
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:443/cb'),
).toBe('abc');
expect(extractCode('http://app.example.com/cb?code=abc', 'http://app.example.com:80/cb')).toBe(
'abc',
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:443/cb"),
).toBe("abc");
expect(extractCode("http://app.example.com/cb?code=abc", "http://app.example.com:80/cb")).toBe(
"abc",
);
});
test('rejects different port', () => {
test("rejects different port", () => {
expect(
extractCode('https://app.example.com/cb?code=abc', 'https://app.example.com:8443/cb'),
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:8443/cb"),
).toBeNull();
});
test('rejects different hostname (including subdomain changes)', () => {
test("rejects different hostname (including subdomain changes)", () => {
expect(
extractCode('https://evil.example.com/cb?code=abc', 'https://app.example.com/cb'),
extractCode("https://evil.example.com/cb?code=abc", "https://app.example.com/cb"),
).toBeNull();
});
test('requires path to start with redirect path (ignoring query/hash)', () => {
test("requires path to start with redirect path (ignoring query/hash)", () => {
// same origin but wrong path -> null
expect(
extractCode('https://app.example.com/other?code=abc', 'https://app.example.com/cb'),
extractCode("https://app.example.com/other?code=abc", "https://app.example.com/cb"),
).toBeNull();
// deeper subpath under the redirect path -> allowed (prefix match)
expect(
extractCode('https://app.example.com/cb/deep?code=abc', 'https://app.example.com/cb'),
).toBe('abc');
extractCode("https://app.example.com/cb/deep?code=abc", "https://app.example.com/cb"),
).toBe("abc");
});
test('works with custom schemes', () => {
expect(extractCode('myapp://cb?code=abc', 'myapp://cb')).toBe('abc');
test("works with custom schemes", () => {
expect(extractCode("myapp://cb?code=abc", "myapp://cb")).toBe("abc");
});
test('prefers query over fragment when both present', () => {
const url = 'https://app.example.com/cb?code=queryCode#code=hashCode';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('queryCode');
test("prefers query over fragment when both present", () => {
const url = "https://app.example.com/cb?code=queryCode#code=hashCode";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("queryCode");
});
test('extracts code from fragment when query lacks code', () => {
const url = 'https://app.example.com/cb#code=fromHash&state=xyz';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('fromHash');
test("extracts code from fragment when query lacks code", () => {
const url = "https://app.example.com/cb#code=fromHash&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("fromHash");
});
test('returns null if no code present (query or fragment)', () => {
const url = 'https://app.example.com/cb?state=only';
const redirect = 'https://app.example.com/cb';
test("returns null if no code present (query or fragment)", () => {
const url = "https://app.example.com/cb?state=only";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test('returns null when provider reports an error', () => {
const url = 'https://app.example.com/cb?error=access_denied&error_description=oopsy';
const redirect = 'https://app.example.com/cb';
expect(() => extractCode(url, redirect)).toThrow('Failed to authorize: access_denied');
test("returns null when provider reports an error", () => {
const url = "https://app.example.com/cb?error=access_denied&error_description=oopsy";
const redirect = "https://app.example.com/cb";
expect(() => extractCode(url, redirect)).toThrow("Failed to authorize: access_denied");
});
test('when redirectUri is null, extracts code from any URL', () => {
expect(extractCode('https://random.example.com/whatever?code=abc', null)).toBe('abc');
test("when redirectUri is null, extracts code from any URL", () => {
expect(extractCode("https://random.example.com/whatever?code=abc", null)).toBe("abc");
});
test('handles extra params gracefully', () => {
const url = 'https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("handles extra params gracefully", () => {
const url = "https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
test('ignores fragment noise when code is in query', () => {
const url = 'https://app.example.com/cb?code=abc#some=thing';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("ignores fragment noise when code is in query", () => {
const url = "https://app.example.com/cb?code=abc#some=thing";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
test('supports fragment-only code for response_mode=fragment providers', () => {
const url = 'https://app.example.com/cb#state=xyz&code=abc';
const redirect = 'https://app.example.com/cb';
expect(extractCode(url, redirect)).toBe('abc');
test("supports fragment-only code for response_mode=fragment providers", () => {
const url = "https://app.example.com/cb#state=xyz&code=abc";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
});