diff --git a/src/lib/components/auth-method-fields.svelte b/src/lib/components/auth-method-fields.svelte
index 8fd5c35..5ea9cf3 100644
--- a/src/lib/components/auth-method-fields.svelte
+++ b/src/lib/components/auth-method-fields.svelte
@@ -33,6 +33,12 @@ let oauth2ClientSecret = $state("");
let oauth2RefreshToken = $state("");
let oauth2TokenUrl = $state("");
+// OAuth2 Client Credentials state
+let oauth2CcClientId = $state("");
+let oauth2CcClientSecret = $state("");
+let oauth2CcTokenUrl = $state("");
+let oauth2CcScope = $state("");
+
const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : '';
@@ -48,6 +54,7 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : '';
+
{/if}
@@ -248,6 +255,45 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : '';
+{:else if authType === 'oauth2_client_credentials'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{:else}
diff --git a/src/lib/server/services/auth-methods.ts b/src/lib/server/services/auth-methods.ts
index c0b8d64..f267dc9 100644
--- a/src/lib/server/services/auth-methods.ts
+++ b/src/lib/server/services/auth-methods.ts
@@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "../db";
import { targetAuthMethods } from "../db/schema";
-const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token"];
+const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "oauth2_client_credentials"];
export function computeCredentialHint(credential: string, type?: string): string {
if (type === "custom_header") {
@@ -42,6 +42,15 @@ export function computeCredentialHint(credential: string, type?: string): string
return "OAuth2 (invalid config)";
}
}
+ if (type === "oauth2_client_credentials") {
+ try {
+ const config = JSON.parse(credential);
+ if (config.clientId) return `OAuth2 CC ••• ${config.clientId.slice(0, 8)}`;
+ return "OAuth2 Client Credentials";
+ } catch {
+ return "OAuth2 CC (invalid config)";
+ }
+ }
if (credential.length < 10) return "••••••••";
return `${credential.slice(0, 3)}••••••••${credential.slice(-4)}`;
}
diff --git a/src/lib/server/services/gateway.ts b/src/lib/server/services/gateway.ts
index 82760ad..05c011b 100644
--- a/src/lib/server/services/gateway.ts
+++ b/src/lib/server/services/gateway.ts
@@ -5,7 +5,7 @@ import type { Target, Token } from "../db/schema";
import { getDefaultAuthMethod } from "./auth-methods";
import { hasPermission } from "./permissions";
import { signES256JWT } from "../utils/jwt";
-import { getOAuth2AccessToken } from "../utils/oauth2";
+import { getOAuth2AccessToken, getOAuth2ClientCredentialsToken } from "../utils/oauth2";
/**
* Resolve and validate a target for gateway proxying.
@@ -164,6 +164,23 @@ export async function proxyToTarget(
{ status: 500 },
);
}
+ } else if (authMethod.type === "oauth2_client_credentials") {
+ try {
+ const config = JSON.parse(authMethod.credential);
+ const accessToken = await getOAuth2ClientCredentialsToken({
+ clientId: config.clientId,
+ clientSecret: config.clientSecret,
+ tokenUrl: config.tokenUrl,
+ scope: config.scope,
+ });
+ headers.set("Authorization", `Bearer ${accessToken}`);
+ } catch (err) {
+ console.error("[gateway] ✗ OAuth2 client credentials token failed:", err);
+ return Response.json(
+ { error: "OAuth2 client credentials token failed" },
+ { status: 500 },
+ );
+ }
}
}
diff --git a/src/lib/server/utils/oauth2.ts b/src/lib/server/utils/oauth2.ts
index 89b1c08..d9a380f 100644
--- a/src/lib/server/utils/oauth2.ts
+++ b/src/lib/server/utils/oauth2.ts
@@ -1,5 +1,5 @@
/**
- * OAuth2 refresh token flow — exchanges a refresh token for a fresh access token.
+ * OAuth2 token flows — refresh token and client credentials.
* Includes in-memory caching so we don't hit the token endpoint on every request.
*/
@@ -10,18 +10,29 @@ interface OAuth2RefreshConfig {
tokenUrl?: string; // defaults to Google's token endpoint
}
+interface OAuth2ClientCredentialsConfig {
+ clientId: string;
+ clientSecret: string;
+ tokenUrl: string;
+ scope?: string;
+}
+
interface CachedToken {
accessToken: string;
expiresAt: number; // epoch ms
}
-// In-memory cache keyed by "clientId:refreshToken" hash
+// In-memory cache keyed by config hash
const tokenCache = new Map();
function cacheKey(config: OAuth2RefreshConfig): string {
return `${config.clientId}:${config.refreshToken.slice(-8)}`;
}
+function ccCacheKey(config: OAuth2ClientCredentialsConfig): string {
+ return `cc:${config.clientId}:${config.tokenUrl}`;
+}
+
export async function getOAuth2AccessToken(config: OAuth2RefreshConfig): Promise {
const key = cacheKey(config);
const cached = tokenCache.get(key);
@@ -58,5 +69,46 @@ export async function getOAuth2AccessToken(config: OAuth2RefreshConfig): Promise
expiresAt: Date.now() + expiresIn * 1000,
});
+ return accessToken;
+}
+
+export async function getOAuth2ClientCredentialsToken(config: OAuth2ClientCredentialsConfig): Promise {
+ const key = ccCacheKey(config);
+ const cached = tokenCache.get(key);
+
+ // Return cached token if still valid (with 60s buffer)
+ if (cached && cached.expiresAt > Date.now() + 60_000) {
+ return cached.accessToken;
+ }
+
+ const body: Record = {
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ grant_type: "client_credentials",
+ };
+ if (config.scope) {
+ body.scope = config.scope;
+ }
+
+ const response = await fetch(config.tokenUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams(body),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`OAuth2 client credentials token failed (${response.status}): ${text}`);
+ }
+
+ const data = await response.json();
+ const accessToken = data.access_token as string;
+ const expiresIn = (data.expires_in as number) || 3600;
+
+ tokenCache.set(key, {
+ accessToken,
+ expiresAt: Date.now() + expiresIn * 1000,
+ });
+
return accessToken;
}
\ No newline at end of file
diff --git a/src/routes/(app)/targets/[slug]/+page.server.ts b/src/routes/(app)/targets/[slug]/+page.server.ts
index e0dda77..ffc7211 100644
--- a/src/routes/(app)/targets/[slug]/+page.server.ts
+++ b/src/routes/(app)/targets/[slug]/+page.server.ts
@@ -167,6 +167,17 @@ export const actions = {
const tokenUrl = data.get("oauth2TokenUrl")?.toString()?.trim();
if (tokenUrl) config.tokenUrl = tokenUrl;
+ credential = JSON.stringify(config);
+ } else if (type === "oauth2_client_credentials") {
+ const clientId = data.get("oauth2CcClientId")?.toString()?.trim() ?? "";
+ const clientSecret = data.get("oauth2CcClientSecret")?.toString()?.trim() ?? "";
+ const tokenUrl = data.get("oauth2CcTokenUrl")?.toString()?.trim() ?? "";
+ if (!clientId || !clientSecret || !tokenUrl) return fail(400, { error: "Client ID, Client Secret, and Token URL are required" });
+
+ const config: Record = { clientId, clientSecret, tokenUrl };
+ const scope = data.get("oauth2CcScope")?.toString()?.trim();
+ if (scope) config.scope = scope;
+
credential = JSON.stringify(config);
} else {
credential = data.get("credential")?.toString() ?? "";
@@ -276,6 +287,17 @@ export const actions = {
if (tokenUrl) config.tokenUrl = tokenUrl;
credential = JSON.stringify(config);
}
+ } else if (type === "oauth2_client_credentials") {
+ const clientId = data.get("oauth2CcClientId")?.toString()?.trim() ?? "";
+ const clientSecret = data.get("oauth2CcClientSecret")?.toString()?.trim() ?? "";
+ const tokenUrl = data.get("oauth2CcTokenUrl")?.toString()?.trim() ?? "";
+ if (clientId || clientSecret || tokenUrl) {
+ if (!clientId || !clientSecret || !tokenUrl) return fail(400, { error: "Client ID, Client Secret, and Token URL are all required when updating" });
+ const config: Record = { clientId, clientSecret, tokenUrl };
+ const scope = data.get("oauth2CcScope")?.toString()?.trim();
+ if (scope) config.scope = scope;
+ credential = JSON.stringify(config);
+ }
} else {
const raw = data.get("credential")?.toString() ?? "";
if (raw) credential = raw;
diff --git a/src/routes/(app)/targets/[slug]/+page.svelte b/src/routes/(app)/targets/[slug]/+page.svelte
index 90ffa48..823fe26 100644
--- a/src/routes/(app)/targets/[slug]/+page.svelte
+++ b/src/routes/(app)/targets/[slug]/+page.svelte
@@ -96,6 +96,7 @@ const authTypeLabels: Record = {
ssh_key: "SSH Key",
jwt_es256: "JWT ES256",
oauth2_refresh_token: "OAuth2 Refresh Token",
+ oauth2_client_credentials: "OAuth2 Client Credentials",
};
// Rename auth state