diff --git a/plugins/auth-oauth2/package.json b/plugins/auth-oauth2/package.json index faffe67e..55b69350 100644 --- a/plugins/auth-oauth2/package.json +++ b/plugins/auth-oauth2/package.json @@ -13,5 +13,11 @@ "build": "yaakcli build", "dev": "yaakcli dev", "test": "vitest --run tests" + }, + "dependencies": { + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.7" } } diff --git a/plugins/auth-oauth2/src/fetchAccessToken.ts b/plugins/auth-oauth2/src/fetchAccessToken.ts index 3c5f4761..56e0335f 100644 --- a/plugins/auth-oauth2/src/fetchAccessToken.ts +++ b/plugins/auth-oauth2/src/fetchAccessToken.ts @@ -4,26 +4,16 @@ import type { AccessTokenRawResponse } from './store'; export async function fetchAccessToken( ctx: Context, - { - accessTokenUrl, - scope, - audience, - params, - grantType, - credentialsInBody, - clientId, - clientSecret, - }: { + args: { clientId: string; - clientSecret: string; grantType: string; accessTokenUrl: string; scope: string | null; audience: string | null; - credentialsInBody: boolean; params: HttpUrlParameter[]; - }, + } & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }), ): Promise { + const { clientId, grantType, accessTokenUrl, scope, audience, params } = args; console.log('[oauth2] Getting access token', accessTokenUrl); const httpRequest: Partial = { method: 'POST', @@ -34,7 +24,10 @@ export async function fetchAccessToken( }, headers: [ { 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' }, ], }; @@ -42,11 +35,24 @@ export async function fetchAccessToken( if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope }); if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience }); - if (credentialsInBody) { + if ('clientAssertion' in args) { httpRequest.body?.form.push({ name: 'client_id', value: clientId }); - httpRequest.body?.form.push({ name: 'client_secret', value: clientSecret }); + httpRequest.body?.form.push({ + name: 'client_assertion_type', + value: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }); + httpRequest.body?.form.push({ + 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_secret', + value: args.clientSecret, + }); } else { - const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString('base64')}`; httpRequest.headers?.push({ name: 'Authorization', value }); } diff --git a/plugins/auth-oauth2/src/grants/clientCredentials.ts b/plugins/auth-oauth2/src/grants/clientCredentials.ts index 290dbb80..3658a04c 100644 --- a/plugins/auth-oauth2/src/grants/clientCredentials.ts +++ b/plugins/auth-oauth2/src/grants/clientCredentials.ts @@ -1,9 +1,99 @@ +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', +] as const; + +export const defaultJwtAlgorithm = jwtAlgorithms[0]; + +/** + * Build a signed JWT for the client_assertion parameter (RFC 7523). + * + * The `secret` value is auto-detected as one of: + * - **JWK** – a JSON string containing a private-key object (has a `kty` field). + * - **PEM** – a string whose trimmed form starts with `-----`. + * - **HMAC secret** – anything else, used as-is for HS* algorithms. + */ +function buildClientAssertionJwt(params: { + clientId: string; + accessTokenUrl: string; + secret: string; + algorithm: Algorithm; +}): string { + const { clientId, accessTokenUrl, secret, algorithm } = params; + + const isHmac = algorithm.startsWith('HS') || algorithm === 'none'; + + // Resolve the signing key depending on format + let signingKey: jwt.Secret; + let kid: string | undefined; + + const trimmed = secret.trim(); + + if (isHmac) { + // HMAC algorithms use the raw secret (string or Buffer) + signingKey = secret; + } 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. + let jwk: any; + try { + jwk = JSON.parse(trimmed); + } catch { + 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('-----')) { + // PEM-encoded key + 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.', + ); + } + + const now = Math.floor(Date.now() / 1000); + const payload = { + iss: clientId, + sub: clientId, + aud: accessTokenUrl, + iat: now, + exp: now + 300, // 5 minutes + jti: randomUUID(), + }; + + // Build the JWT header; include "kid" when available + const header: jwt.JwtHeader = { alg: algorithm, typ: 'JWT' }; + if (kid) { + header.kid = kid; + } + + return jwt.sign(JSON.stringify(payload), signingKey, { + algorithm: algorithm as jwt.Algorithm, + header, + }); +} + export async function getClientCredentials( ctx: Context, contextId: string, @@ -14,6 +104,10 @@ export async function getClientCredentials( scope, audience, credentialsInBody, + clientAssertionSecret, + clientAssertionSecretBase64, + clientCredentialsMethod, + clientAssertionAlgorithm, }: { accessTokenUrl: string; clientId: string; @@ -21,6 +115,10 @@ export async function getClientCredentials( scope: string | null; audience: string | null; credentialsInBody: boolean; + clientAssertionSecret: string; + clientAssertionSecretBase64: boolean; + clientCredentialsMethod: string; + clientAssertionAlgorithm: string; }, ) { const tokenArgs: TokenStoreArgs = { @@ -34,16 +132,38 @@ export async function getClientCredentials( return token; } - const response = await fetchAccessToken(ctx, { + const common: Omit< + Parameters[1], + 'clientAssertion' | 'clientSecret' | 'credentialsInBody' + > = { grantType: 'client_credentials', accessTokenUrl, audience, clientId, - clientSecret, scope, - credentialsInBody, params: [], - }); + }; + + const fetchParams: Parameters[1] = + clientCredentialsMethod === 'client_assertion' + ? { + ...common, + clientAssertion: buildClientAssertionJwt({ + clientId, + algorithm: clientAssertionAlgorithm as Algorithm, + accessTokenUrl, + secret: clientAssertionSecretBase64 + ? Buffer.from(clientAssertionSecret, 'base64').toString('utf-8') + : clientAssertionSecret, + }), + } + : { + ...common, + clientSecret, + credentialsInBody, + }; + + const response = await fetchAccessToken(ctx, fetchParams); return storeToken(ctx, tokenArgs, response); } diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 8b337b8c..6dd05c90 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -5,6 +5,7 @@ import type { JsonPrimitive, PluginDefinition, } from '@yaakapp/api'; +import type { Algorithm } from 'jsonwebtoken'; import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer'; import { type CallbackType, @@ -14,7 +15,11 @@ import { PKCE_PLAIN, PKCE_SHA256, } from './grants/authorizationCode'; -import { getClientCredentials } from './grants/clientCredentials'; +import { + defaultJwtAlgorithm, + getClientCredentials, + jwtAlgorithms, +} from './grants/clientCredentials'; import { getImplicit } from './grants/implicit'; import { getPassword } from './grants/password'; import type { AccessToken, TokenStoreArgs } from './store'; @@ -97,7 +102,10 @@ export const plugin: PluginDefinition = { }; const token = await getToken(ctx, tokenArgs); if (token == null) { - await ctx.toast.show({ message: 'No token to copy', color: 'warning' }); + await ctx.toast.show({ + message: 'No token to copy', + color: 'warning', + }); } else { await ctx.clipboard.copyText(token.response.access_token); await ctx.toast.show({ @@ -118,9 +126,15 @@ export const plugin: PluginDefinition = { clientId: stringArg(values, 'clientId'), }; if (await deleteToken(ctx, tokenArgs)) { - await ctx.toast.show({ message: 'Token deleted', color: 'success' }); + await ctx.toast.show({ + message: 'Token deleted', + color: 'success', + }); } else { - await ctx.toast.show({ message: 'No token to delete', color: 'warning' }); + await ctx.toast.show({ + message: 'No token to delete', + color: 'warning', + }); } }, }, @@ -139,6 +153,19 @@ export const plugin: PluginDefinition = { defaultValue: defaultGrantType, options: grantTypes, }, + { + type: 'select', + name: 'clientCredentialsMethod', + label: 'Authentication Method', + description: + '"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.', + defaultValue: 'client_secret', + options: [ + { label: 'Client Secret', value: 'client_secret' }, + { label: 'Client Assertion', value: 'client_assertion' }, + ], + dynamic: hiddenIfNot(['client_credentials']), + }, { type: 'text', name: 'clientId', @@ -151,7 +178,47 @@ export const plugin: PluginDefinition = { label: 'Client Secret', optional: true, password: true, - dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), + dynamic: hiddenIfNot( + ['authorization_code', 'password', 'client_credentials'], + (values) => values.clientCredentialsMethod === 'client_secret', + ), + }, + { + type: 'select', + name: 'clientAssertionAlgorithm', + label: 'JWT Algorithm', + defaultValue: defaultJwtAlgorithm, + options: jwtAlgorithms.map((value) => ({ + label: value === 'none' ? 'None' : value, + value, + })), + dynamic: hiddenIfNot( + ['client_credentials'], + ({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion', + ), + }, + { + type: 'text', + name: 'clientAssertionSecret', + label: 'JWT Secret', + description: + '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', + ), + }, + { + type: 'checkbox', + name: 'clientAssertionSecretBase64', + label: 'JWT secret is base64 encoded', + dynamic: hiddenIfNot( + ['client_credentials'], + ({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion', + ), }, { type: 'text', @@ -160,7 +227,10 @@ export const plugin: PluginDefinition = { label: 'Authorization URL', dynamic: hiddenIfNot(['authorization_code', 'implicit']), placeholder: authorizationUrls[0], - completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })), + completionOptions: authorizationUrls.map((url) => ({ + label: url, + value: url, + })), }, { type: 'text', @@ -169,7 +239,10 @@ export const plugin: PluginDefinition = { label: 'Access Token URL', placeholder: accessTokenUrls[0], dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), - completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })), + completionOptions: accessTokenUrls.map((url) => ({ + label: url, + value: url, + })), }, { type: 'banner', @@ -186,7 +259,8 @@ export const plugin: PluginDefinition = { { type: 'text', name: 'redirectUri', - label: 'Redirect URI', + 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.', optional: true, @@ -383,6 +457,11 @@ export const plugin: PluginDefinition = { { 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', + }), }, ], }, @@ -484,7 +563,11 @@ export const plugin: PluginDefinition = { ? 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'), + clientAssertionSecretBase64: !!values.clientAssertionSecretBase64, scope: stringArgOrNull(values, 'scope'), audience: stringArgOrNull(values, 'audience'), credentialsInBody, diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index ca8bcad1..00e79300 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -616,5 +616,16 @@ function KeyValueArg({ function hasVisibleInputs(inputs: FormInput[] | undefined): boolean { if (!inputs) return false; - return inputs.some((i) => !i.hidden); + + for (const input of inputs) { + if ('inputs' in input && !hasVisibleInputs(input.inputs)) { + // Has children, but none are visible + return false; + } + if (!input.hidden) { + return true; + } + } + + return false; }