Add support for client assertions in the OAuth 2 plugin (#395)

Co-authored-by: Davide Becker <github@reg.davide.me>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
Davide Becker
2026-02-14 16:38:54 +01:00
committed by GitHub
parent 65e91aec6b
commit f5d11cb6d3
5 changed files with 256 additions and 30 deletions

View File

@@ -13,5 +13,11 @@
"build": "yaakcli build", "build": "yaakcli build",
"dev": "yaakcli dev", "dev": "yaakcli dev",
"test": "vitest --run tests" "test": "vitest --run tests"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
} }
} }

View File

@@ -4,26 +4,16 @@ import type { AccessTokenRawResponse } from './store';
export async function fetchAccessToken( export async function fetchAccessToken(
ctx: Context, ctx: Context,
{ args: {
accessTokenUrl,
scope,
audience,
params,
grantType,
credentialsInBody,
clientId,
clientSecret,
}: {
clientId: string; clientId: string;
clientSecret: string;
grantType: string; grantType: string;
accessTokenUrl: string; accessTokenUrl: string;
scope: string | null; scope: string | null;
audience: string | null; audience: string | null;
credentialsInBody: boolean;
params: HttpUrlParameter[]; params: HttpUrlParameter[];
}, } & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
): Promise<AccessTokenRawResponse> { ): 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> = { const httpRequest: Partial<HttpRequest> = {
method: 'POST', method: 'POST',
@@ -34,7 +24,10 @@ export async function fetchAccessToken(
}, },
headers: [ 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' },
], ],
}; };
@@ -42,11 +35,24 @@ export async function fetchAccessToken(
if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope }); if (scope) httpRequest.body?.form.push({ name: 'scope', value: scope });
if (audience) httpRequest.body?.form.push({ name: 'audience', value: audience }); 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_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 { } 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 }); httpRequest.headers?.push({ name: 'Authorization', value });
} }

View File

@@ -1,9 +1,99 @@
import { createPrivateKey, randomUUID } from 'node:crypto';
import type { Context } from '@yaakapp/api'; import type { Context } from '@yaakapp/api';
import jwt, { type Algorithm } from 'jsonwebtoken';
import { fetchAccessToken } from '../fetchAccessToken'; import { fetchAccessToken } from '../fetchAccessToken';
import type { TokenStoreArgs } from '../store'; import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store'; import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util'; 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( export async function getClientCredentials(
ctx: Context, ctx: Context,
contextId: string, contextId: string,
@@ -14,6 +104,10 @@ export async function getClientCredentials(
scope, scope,
audience, audience,
credentialsInBody, credentialsInBody,
clientAssertionSecret,
clientAssertionSecretBase64,
clientCredentialsMethod,
clientAssertionAlgorithm,
}: { }: {
accessTokenUrl: string; accessTokenUrl: string;
clientId: string; clientId: string;
@@ -21,6 +115,10 @@ export async function getClientCredentials(
scope: string | null; scope: string | null;
audience: string | null; audience: string | null;
credentialsInBody: boolean; credentialsInBody: boolean;
clientAssertionSecret: string;
clientAssertionSecretBase64: boolean;
clientCredentialsMethod: string;
clientAssertionAlgorithm: string;
}, },
) { ) {
const tokenArgs: TokenStoreArgs = { const tokenArgs: TokenStoreArgs = {
@@ -34,16 +132,38 @@ export async function getClientCredentials(
return token; return token;
} }
const response = await fetchAccessToken(ctx, { const common: Omit<
Parameters<typeof fetchAccessToken>[1],
'clientAssertion' | 'clientSecret' | 'credentialsInBody'
> = {
grantType: 'client_credentials', grantType: 'client_credentials',
accessTokenUrl, accessTokenUrl,
audience, audience,
clientId, clientId,
clientSecret,
scope, scope,
credentialsInBody,
params: [], params: [],
}); };
const fetchParams: Parameters<typeof fetchAccessToken>[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); return storeToken(ctx, tokenArgs, response);
} }

View File

@@ -5,6 +5,7 @@ import type {
JsonPrimitive, JsonPrimitive,
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer'; import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
import { import {
type CallbackType, type CallbackType,
@@ -14,7 +15,11 @@ import {
PKCE_PLAIN, PKCE_PLAIN,
PKCE_SHA256, PKCE_SHA256,
} from './grants/authorizationCode'; } from './grants/authorizationCode';
import { getClientCredentials } from './grants/clientCredentials'; import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from './grants/clientCredentials';
import { getImplicit } from './grants/implicit'; import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password'; import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store'; import type { AccessToken, TokenStoreArgs } from './store';
@@ -97,7 +102,10 @@ export const plugin: PluginDefinition = {
}; };
const token = await getToken(ctx, tokenArgs); const token = await getToken(ctx, tokenArgs);
if (token == null) { 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 { } else {
await ctx.clipboard.copyText(token.response.access_token); await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({ await ctx.toast.show({
@@ -118,9 +126,15 @@ export const plugin: PluginDefinition = {
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
}; };
if (await deleteToken(ctx, tokenArgs)) { if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({ message: 'Token deleted', color: 'success' }); await ctx.toast.show({
message: 'Token deleted',
color: 'success',
});
} else { } 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, defaultValue: defaultGrantType,
options: grantTypes, 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', type: 'text',
name: 'clientId', name: 'clientId',
@@ -151,7 +178,47 @@ export const plugin: PluginDefinition = {
label: 'Client Secret', label: 'Client Secret',
optional: true, optional: true,
password: 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', type: 'text',
@@ -160,7 +227,10 @@ export const plugin: PluginDefinition = {
label: 'Authorization URL', label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']), dynamic: hiddenIfNot(['authorization_code', 'implicit']),
placeholder: authorizationUrls[0], placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })), completionOptions: authorizationUrls.map((url) => ({
label: url,
value: url,
})),
}, },
{ {
type: 'text', type: 'text',
@@ -169,7 +239,10 @@ export const plugin: PluginDefinition = {
label: 'Access Token URL', label: 'Access Token URL',
placeholder: accessTokenUrls[0], 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 })), completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
}, },
{ {
type: 'banner', type: 'banner',
@@ -186,7 +259,8 @@ export const plugin: PluginDefinition = {
{ {
type: 'text', type: 'text',
name: 'redirectUri', name: 'redirectUri',
label: 'Redirect URI', label: 'Redirect URI (can be any valid URL)',
placeholder: 'https://mysite.example.com/oauth/callback',
description: 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, optional: true,
@@ -383,6 +457,11 @@ export const plugin: PluginDefinition = {
{ label: 'In Request Body', value: 'body' }, { label: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' }, { 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 ? accessTokenUrl
: `https://${accessTokenUrl}`, : `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
clientSecret: stringArg(values, 'clientSecret'), clientSecret: stringArg(values, 'clientSecret'),
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, 'scope'), scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'), audience: stringArgOrNull(values, 'audience'),
credentialsInBody, credentialsInBody,

View File

@@ -616,5 +616,16 @@ function KeyValueArg({
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean { function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false; 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;
} }