Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/lib/components/auth-method-fields.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)' : '';
</script>

Expand All @@ -48,6 +54,7 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : '';
<option value="query_param">Query Parameter</option>
<option value="jwt_es256">JWT ES256 (Apple, etc.)</option>
<option value="oauth2_refresh_token">OAuth2 Refresh Token (Google, etc.)</option>
<option value="oauth2_client_credentials">OAuth2 Client Credentials (M2M)</option>
{/if}
</select>
</div>
Expand Down Expand Up @@ -248,6 +255,45 @@ const optionalHint = mode === 'edit' ? ' (leave blank to keep existing)' : '';
<Label for="{idPrefix}-oauth2-token-url">Token URL <span class="text-muted-foreground text-xs">(optional, defaults to Google)</span></Label>
<Input id="{idPrefix}-oauth2-token-url" name="oauth2TokenUrl" bind:value={oauth2TokenUrl} placeholder="https://oauth2.googleapis.com/token" />
</div>
{:else if authType === 'oauth2_client_credentials'}
<div class="grid gap-2">
<Label for="{idPrefix}-oauth2cc-client-id">Client ID{#if mode === 'edit'} <span class="text-muted-foreground text-xs">{optionalHint}</span>{/if}</Label>
<Input id="{idPrefix}-oauth2cc-client-id" name="oauth2CcClientId" bind:value={oauth2CcClientId} placeholder="e.g. amzn1.application-oa2-client.abc123" required={mode === 'add'} />
</div>
<div class="grid gap-2">
<Label for="{idPrefix}-oauth2cc-client-secret">Client Secret{#if mode === 'edit'} <span class="text-muted-foreground text-xs">{optionalHint}</span>{/if}</Label>
<div class="relative">
<Input
id="{idPrefix}-oauth2cc-client-secret"
name="oauth2CcClientSecret"
type={showCredential ? 'text' : 'password'}
bind:value={oauth2CcClientSecret}
placeholder="client secret"
required={mode === 'add'}
/>
<Button
type="button"
variant="ghost"
size="icon"
class="absolute right-0 top-0 h-full px-3"
onclick={() => (showCredential = !showCredential)}
>
{#if showCredential}
<EyeOffIcon class="size-4" />
{:else}
<EyeIcon class="size-4" />
{/if}
</Button>
</div>
</div>
<div class="grid gap-2">
<Label for="{idPrefix}-oauth2cc-token-url">Token URL{#if mode === 'edit'} <span class="text-muted-foreground text-xs">{optionalHint}</span>{/if}</Label>
<Input id="{idPrefix}-oauth2cc-token-url" name="oauth2CcTokenUrl" bind:value={oauth2CcTokenUrl} placeholder="https://example.com/oauth2/token" required={mode === 'add'} />
</div>
<div class="grid gap-2">
<Label for="{idPrefix}-oauth2cc-scope">Scope <span class="text-muted-foreground text-xs">(optional)</span></Label>
<Input id="{idPrefix}-oauth2cc-scope" name="oauth2CcScope" bind:value={oauth2CcScope} placeholder="e.g. creatorsapi/default" />
</div>
{:else}
<div class="grid gap-2">
<Label for="{idPrefix}-credential">Credential{#if mode === 'edit'} <span class="text-muted-foreground text-xs">{optionalHint}</span>{/if}</Label>
Expand Down
11 changes: 10 additions & 1 deletion src/lib/server/services/auth-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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)}`;
}
Expand Down
19 changes: 18 additions & 1 deletion src/lib/server/services/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 },
);
}
}
}

Expand Down
56 changes: 54 additions & 2 deletions src/lib/server/utils/oauth2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* OAuth2 refresh token flowexchanges 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.
*/

Expand All @@ -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<string, CachedToken>();

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<string> {
const key = cacheKey(config);
const cached = tokenCache.get(key);
Expand Down Expand Up @@ -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<string> {
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<string, string> = {
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;
}
22 changes: 22 additions & 0 deletions src/routes/(app)/targets/[slug]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = { 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() ?? "";
Expand Down Expand Up @@ -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<string, unknown> = { 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;
Expand Down
1 change: 1 addition & 0 deletions src/routes/(app)/targets/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const authTypeLabels: Record<string, string> = {
ssh_key: "SSH Key",
jwt_es256: "JWT ES256",
oauth2_refresh_token: "OAuth2 Refresh Token",
oauth2_client_credentials: "OAuth2 Client Credentials",
};

// Rename auth state
Expand Down
Loading