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