/**
* Shopify Agentic MCP Gateway — Authentication Middleware
*
* Handles Shopify OAuth HMAC verification, access token validation,
* UCP profile validation, and agent profile extraction from headers.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
import type { AgentProfile, UCPProfile } from "../types.js";
export interface AuthMiddlewareConfig {
apiKey: string;
apiSecret: string;
}
export class AuthMiddleware {
private readonly apiKey: string;
private readonly apiSecret: string;
constructor(config: AuthMiddlewareConfig) {
this.apiKey = config.apiKey;
this.apiSecret = config.apiSecret;
}
/**
* Verify the HMAC signature on a Shopify OAuth callback query string.
* Shopify signs the callback with HMAC-SHA256 using the app's API secret.
*
* @param query - The full query parameters from the OAuth callback URL
* @returns true if the HMAC is valid
*/
validateShopifyHMAC(query: Record<string, string>): boolean {
const hmac = query["hmac"];
if (!hmac) {
return false;
}
// Build the message string from all query params except "hmac" and "signature"
const entries = Object.entries(query)
.filter(([key]) => key !== "hmac" && key !== "signature")
.sort(([a], [b]) => a.localeCompare(b));
const message = entries
.map(([key, value]) => `${key}=${value}`)
.join("&");
const computed = createHmac("sha256", this.apiSecret)
.update(message)
.digest("hex");
// Use timing-safe comparison to prevent timing attacks
try {
const hmacBuffer = Buffer.from(hmac, "hex");
const computedBuffer = Buffer.from(computed, "hex");
if (hmacBuffer.length !== computedBuffer.length) {
return false;
}
return timingSafeEqual(hmacBuffer, computedBuffer);
} catch {
return false;
}
}
/**
* Verify a Shopify access token by making a lightweight API call.
* If the token is valid, Shopify returns 200; otherwise 401.
*
* @param token - The Shopify access token to validate
* @returns true if the token is accepted by Shopify
*/
async validateAccessToken(token: string): Promise<boolean> {
if (!token) {
return false;
}
try {
const response = await fetch(
`https://${this.apiKey}.myshopify.com/admin/api/2024-01/shop.json`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": token,
"Content-Type": "application/json",
},
},
);
return response.ok;
} catch {
return false;
}
}
/**
* Fetch and validate an agent's UCP profile from the given URL.
* The profile must contain at least one service with a valid type.
*
* @param profileUrl - The URL where the agent's UCP profile is hosted
* @returns true if the profile is valid and well-formed
*/
async validateUCPProfile(profileUrl: string): Promise<boolean> {
if (!profileUrl) {
return false;
}
try {
const url = new URL(profileUrl);
// UCP profiles must be served over HTTPS
if (url.protocol !== "https:") {
return false;
}
const response = await fetch(profileUrl, {
method: "GET",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
return false;
}
const profile = (await response.json()) as UCPProfile;
// Validate basic structure
if (!profile.version || !Array.isArray(profile.services)) {
return false;
}
// Must have at least one service
if (profile.services.length === 0) {
return false;
}
// Each service must have a type, at least one transport, and capabilities
for (const service of profile.services) {
if (!service.type) {
return false;
}
if (
!Array.isArray(service.transports) ||
service.transports.length === 0
) {
return false;
}
if (
!Array.isArray(service.capabilities) ||
service.capabilities.length === 0
) {
return false;
}
}
return true;
} catch {
return false;
}
}
/**
* Extract agent profile information from request headers.
* Parses the UCP-Agent header following RFC 8941 Structured Fields format.
*
* Expected header format:
* UCP-Agent: profile="https://example.com/.well-known/ucp.json";
* cap="dev.ucp.shopping.checkout,dev.ucp.shopping.catalog";
* cred="urn:ietf:params:oauth:grant-type:jwt-bearer"
*
* @param headers - The HTTP request headers (case-insensitive keys)
* @returns The parsed AgentProfile or null if the header is missing/invalid
*/
extractAgentProfile(headers: Record<string, string>): AgentProfile | null {
// Normalize header keys to lowercase for case-insensitive lookup
const normalizedHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
normalizedHeaders[key.toLowerCase()] = value;
}
const agentHeader = normalizedHeaders["ucp-agent"];
if (!agentHeader) {
return null;
}
try {
// Parse RFC 8941 structured field dictionary-like format
const profileUrl = this.extractStructuredFieldValue(
agentHeader,
"profile",
);
const capString = this.extractStructuredFieldValue(agentHeader, "cap");
const credString = this.extractStructuredFieldValue(agentHeader, "cred");
if (!profileUrl) {
return null;
}
const capabilities = capString
? capString.split(",").map((c) => c.trim()).filter(Boolean)
: [];
const credentials = credString
? credString.split(",").map((c) => c.trim()).filter(Boolean)
: [];
return {
profile_url: profileUrl,
capabilities,
credentials,
};
} catch {
return null;
}
}
/**
* Extract a quoted string value from an RFC 8941 structured field.
*/
private extractStructuredFieldValue(
header: string,
key: string,
): string | null {
// Match key="value" pattern (RFC 8941 quoted strings)
const pattern = new RegExp(`${key}="([^"]*)"`, "i");
const match = header.match(pattern);
return match?.[1] ?? null;
}
}