Skip to content

Confidential Client Support

Status: ⚠️ Limitation (By Design) Priority: 🟢 LOW Estimated Time: 8-12 hours Risk Level: LOW Impact: Server-to-server OAuth flows and enhanced security

← Back to Enhancements


Current Limitation

Seed currently only supports public OAuth clients using PKCE (Proof Key for Code Exchange) for security. This design choice aligns with the primary use case (Claude Desktop and Claude Code), but limits support for server-to-server scenarios.

Location: src/routes/oauth-register.ts:272

typescript
const client: RegisteredClient = {
  client_id: clientId,
  client_name: request.client_name ?? "OAuth Client",
  redirect_uris: request.redirect_uris,
  grant_types: request.grant_types ?? ["authorization_code"],
  response_types: request.response_types ?? ["code"],
  token_endpoint_auth_method: "none", // ⚠️ Fixed to public clients only
  client_id_issued_at: now,
  client_secret_expires_at: 0, // Never expires
};

Public vs Confidential Clients

Public Clients (Currently Supported)

Characteristics:

  • Cannot securely store secrets (desktop apps, mobile apps, SPAs)
  • Use PKCE for security instead of client secrets
  • Examples: Claude Desktop, Claude Code, mobile apps

Security Model:

Authorization Request → code_challenge (SHA-256 hash)
Token Exchange → code_verifier (original value)
IdP verifies: SHA-256(code_verifier) == code_challenge

Supported Authentication Methods:

  • none (PKCE required)

Confidential Clients (Not Implemented)

Characteristics:

  • Can securely store secrets (backend servers, server-to-server)
  • Use client secrets for authentication
  • Examples: API gateways, internal services, backend-to-backend

Security Model:

Token Exchange → client_id + client_secret
IdP verifies: provided client_secret matches stored secret

Missing Authentication Methods:

  • client_secret_basic - HTTP Basic Auth with client_id:client_secret
  • client_secret_post - Send client_secret in POST body

Use Cases for Confidential Clients

1. Server-to-Server Integration

Backend Service A → Seed MCP Server → Backend Service B
└─ Needs: OAuth client credentials flow

2. API Gateway Authentication

API Gateway → Seed MCP Server
└─ Needs: Confidential client with secret

3. Internal Service Mesh

Microservice A → Microservice B (via Seed)
└─ Needs: mTLS + client secret

Proposed Implementation

Phase 1: Dynamic Client Registration with Secrets

Update DCR endpoint to support confidential clients:

typescript
// src/routes/oauth-register.ts

import { randomBytes } from "crypto";

interface RegistrationRequest {
  client_name: string | undefined;
  redirect_uris: string[];
  grant_types: string[] | undefined;
  response_types: string[] | undefined;
  token_endpoint_auth_method: string | undefined; // none | client_secret_post | client_secret_basic
  software_id: string | undefined;
  software_version: string | undefined;
}

/**
 * Generate cryptographically secure client secret
 */
function generateClientSecret(): string {
  // 32 bytes = 256 bits of entropy, base64-encoded
  return randomBytes(32).toString("base64url");
}

oauthRegisterRouter.post("/", async (req: Request, res: Response) => {
  const validationResult = validateRegistrationRequest(req.body);
  if (typeof validationResult === "string") {
    dcrRegistrations.inc({ result: "failure" });
    return sendRegistrationError(res, "invalid_client_metadata", validationResult);
  }

  const request = validationResult;
  const clientId = generateClientId();
  const now = Math.floor(Date.now() / 1000);

  // Determine auth method
  const authMethod = request.token_endpoint_auth_method ?? "none";
  const isConfidential = authMethod !== "none";

  // Generate client secret for confidential clients
  const clientSecret = isConfidential ? generateClientSecret() : undefined;

  const client: RegisteredClient = {
    client_id: clientId,
    client_secret: clientSecret, // Only set for confidential clients
    client_name: request.client_name ?? "OAuth Client",
    redirect_uris: request.redirect_uris,
    grant_types: request.grant_types ?? ["authorization_code"],
    response_types: request.response_types ?? ["code"],
    token_endpoint_auth_method: authMethod,
    client_id_issued_at: now,
    client_secret_expires_at: isConfidential ? now + (365 * 24 * 60 * 60) : 0, // 1 year for confidential
  };

  // Store in Redis (client_secret will be hashed)
  const store = getClientStore();
  await store.set(client);

  dcrRegistrations.inc({ result: "success" });

  // Return client with secret (only sent once!)
  res.status(201).json(client);
});

Phase 2: Client Secret Storage

Update client store to hash secrets:

typescript
// src/services/client-store.ts

import { createHash, timingSafeEqual } from "crypto";

export interface RegisteredClient {
  client_id: string;
  client_secret?: string; // Hashed for storage, plaintext only in registration response
  client_name: string;
  redirect_uris: string[];
  grant_types: string[];
  response_types: string[];
  token_endpoint_auth_method: "none" | "client_secret_post" | "client_secret_basic";
  client_id_issued_at: number;
  client_secret_expires_at: number;
}

/**
 * Hash client secret using SHA-256
 */
function hashClientSecret(secret: string): string {
  return createHash("sha256").update(secret).digest("base64url");
}

/**
 * Verify client secret in constant time
 */
export function verifyClientSecret(
  providedSecret: string,
  storedHashedSecret: string,
): boolean {
  const providedHash = hashClientSecret(providedSecret);
  const providedBuffer = Buffer.from(providedHash);
  const storedBuffer = Buffer.from(storedHashedSecret);

  if (providedBuffer.length !== storedBuffer.length) {
    return false;
  }

  return timingSafeEqual(providedBuffer, storedBuffer);
}

export class ClientStore {
  async set(client: RegisteredClient): Promise<void> {
    // Hash client secret before storing
    const storedClient = { ...client };
    if (storedClient.client_secret) {
      storedClient.client_secret = hashClientSecret(storedClient.client_secret);
    }

    const key = `${this.keyPrefix}${client.client_id}`;
    await this.redis.setex(key, this.ttl, JSON.stringify(storedClient));
  }
}

Phase 3: Token Endpoint Authentication

Update token endpoint to validate client secrets:

typescript
// src/routes/oauth-token.ts

import { verifyClientSecret } from "../services/client-store.js";

/**
 * Extract client credentials from request
 */
function extractClientCredentials(req: Request): {
  clientId: string | undefined;
  clientSecret: string | undefined;
  method: "basic" | "post" | "none";
} {
  // Check Authorization header for Basic auth
  const authHeader = req.headers.authorization;
  if (authHeader?.startsWith("Basic ")) {
    const credentials = Buffer.from(authHeader.slice(6), "base64").toString();
    const [clientId, clientSecret] = credentials.split(":", 2);
    return { clientId, clientSecret, method: "basic" };
  }

  // Check POST body
  const clientId = req.body.client_id as string | undefined;
  const clientSecret = req.body.client_secret as string | undefined;

  if (clientSecret) {
    return { clientId, clientSecret, method: "post" };
  }

  return { clientId, clientSecret: undefined, method: "none" };
}

/**
 * Validate client authentication
 */
async function validateClientAuth(
  clientId: string,
  clientSecret: string | undefined,
  authMethod: "basic" | "post" | "none",
): Promise<boolean> {
  const store = getClientStore();
  const client = await store.get(clientId);

  if (!client) {
    return false;
  }

  // Public client (PKCE)
  if (client.token_endpoint_auth_method === "none") {
    return authMethod === "none"; // No secret expected
  }

  // Confidential client
  if (authMethod !== client.token_endpoint_auth_method.replace("client_secret_", "")) {
    return false; // Wrong auth method
  }

  if (!clientSecret || !client.client_secret) {
    return false; // Missing secret
  }

  // Verify secret in constant time
  return verifyClientSecret(clientSecret, client.client_secret);
}

oauthTokenRouter.post("/", async (req: Request, res: Response) => {
  const body = req.body as Record<string, string>;
  const grantType = body.grant_type;

  if (!grantType) {
    return sendOAuthError(res, "invalid_request", "Missing grant_type");
  }

  // Extract and validate client credentials
  const { clientId, clientSecret, method } = extractClientCredentials(req);

  if (!clientId) {
    return sendOAuthError(res, "invalid_client", "Missing client_id");
  }

  const isValid = await validateClientAuth(clientId, clientSecret, method);
  if (!isValid) {
    return sendOAuthError(res, "invalid_client", "Invalid client credentials");
  }

  // ... proceed with token exchange ...
});

Security Considerations

1. Client Secret Entropy

Requirement: Minimum 256 bits of entropy (32 bytes)

typescript
const clientSecret = randomBytes(32).toString("base64url");
// Example: "xGE3qP_7Rk2WvN8jL5mT9pQ4zU6yA1bC0dF2hI3kJ4"

2. Storage Security

Requirements:

  • Hash secrets using SHA-256 before storage
  • Use constant-time comparison to prevent timing attacks
  • Store hashed secrets in Redis with TTL (1 year default)

3. Secret Rotation

Not implemented but recommended:

typescript
// POST /oauth/clients/:client_id/rotate-secret
// Generates new secret, invalidates old one

4. Rate Limiting

Apply stricter rate limits to confidential client registration:

typescript
// 5 registrations per hour (instead of 10 for public clients)
CONFIDENTIAL_CLIENT_RATE_LIMIT=5

Configuration

bash
# Enable confidential client support
CONFIDENTIAL_CLIENTS_ENABLED=true

# Client secret TTL (in seconds, default: 1 year)
CLIENT_SECRET_TTL_SECONDS=31536000

# Minimum secret entropy (bytes, default: 32)
CLIENT_SECRET_MIN_BYTES=32

Testing Strategy

typescript
// src/routes/oauth-register.test.ts

describe("Confidential client registration", () => {
  it("should generate client secret for client_secret_post", async () => {
    const res = await request(app)
      .post("/oauth/register")
      .send({
        redirect_uris: ["https://example.com/callback"],
        token_endpoint_auth_method: "client_secret_post",
      });

    expect(res.status).toBe(201);
    expect(res.body.client_secret).toBeDefined();
    expect(res.body.client_secret.length).toBeGreaterThan(40);
  });

  it("should not return client secret for public clients", async () => {
    const res = await request(app)
      .post("/oauth/register")
      .send({
        redirect_uris: ["https://example.com/callback"],
        token_endpoint_auth_method: "none",
      });

    expect(res.status).toBe(201);
    expect(res.body.client_secret).toBeUndefined();
  });
});

// src/routes/oauth-token.test.ts

describe("Client authentication", () => {
  it("should validate client secret via Basic auth", async () => {
    const client = await createConfidentialClient();
    const credentials = Buffer.from(`${client.client_id}:${client.client_secret}`).toString(
      "base64",
    );

    const res = await request(app)
      .post("/oauth/token")
      .set("Authorization", `Basic ${credentials}`)
      .send({
        grant_type: "authorization_code",
        code: "test_code",
        redirect_uri: "https://example.com/callback",
      });

    expect(res.status).toBe(200);
  });

  it("should reject invalid client secret", async () => {
    const client = await createConfidentialClient();
    const credentials = Buffer.from(`${client.client_id}:wrong_secret`).toString("base64");

    const res = await request(app)
      .post("/oauth/token")
      .set("Authorization", `Basic ${credentials}`)
      .send({
        grant_type: "authorization_code",
        code: "test_code",
      });

    expect(res.status).toBe(401);
    expect(res.body.error).toBe("invalid_client");
  });
});

Acceptance Criteria

  • [ ] Update DCR endpoint to support token_endpoint_auth_method parameter
  • [ ] Generate cryptographically secure client secrets (32 bytes)
  • [ ] Hash client secrets before storage using SHA-256
  • [ ] Return plaintext secret only in registration response (one-time)
  • [ ] Implement client_secret_basic authentication (HTTP Basic)
  • [ ] Implement client_secret_post authentication (POST body)
  • [ ] Add constant-time secret comparison
  • [ ] Client secret expiration (1 year default)
  • [ ] Comprehensive test coverage (≥90%)
  • [ ] Update OAuth documentation
  • [ ] Security audit of implementation

Why This Is a Limitation (Not a Bug)

Design Decision: Seed is designed for client applications (Claude Desktop, Claude Code) that cannot securely store secrets. The current public client + PKCE approach is:

  1. More secure for the target use case
  2. Simpler to implement and maintain
  3. Aligned with OAuth 2.1 best practices for native apps
  4. Sufficient for current requirements

When to implement:

  • Server-to-server use cases emerge
  • API gateway integration needed
  • Enterprise requirements demand confidential clients


References

Released under the MIT License.