diff --git a/README.md b/README.md index 417b1ae6..f64c264f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ All services start with sensible defaults. No config file needed: - **Apple** on `http://localhost:4004` - **Microsoft** on `http://localhost:4005` - **AWS** on `http://localhost:4006` +- **HeyGen** on `http://localhost:4007` ## CLI @@ -678,3 +679,5 @@ Tokens are configured in the seed config and map to users. Pass them as `Authori **Microsoft**: OIDC authorization code flow with PKCE support. Also supports client credentials grants. Microsoft Graph `/v1.0/me` available. **AWS**: Bearer tokens or IAM access key credentials. Default key pair always seeded: `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`. + +**HeyGen**: OAuth 2.0 authorization code flow with mandatory PKCE. Separate token (`/v1/oauth/token`) and refresh (`/v1/oauth/refresh_token`) endpoints. User profile at `/v1/user/me` returns HeyGen's `{ code, data, message }` wrapper. diff --git a/apps/web/app/heygen/layout.tsx b/apps/web/app/heygen/layout.tsx new file mode 100644 index 00000000..c0af9a89 --- /dev/null +++ b/apps/web/app/heygen/layout.tsx @@ -0,0 +1,7 @@ +import { pageMetadata } from "@/lib/page-metadata"; + +export const metadata = pageMetadata("heygen"); + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/app/heygen/page.mdx b/apps/web/app/heygen/page.mdx new file mode 100644 index 00000000..2190c472 --- /dev/null +++ b/apps/web/app/heygen/page.mdx @@ -0,0 +1,65 @@ +# HeyGen API + +OAuth 2.0 authorization code flow with PKCE for local HeyGen auth testing. + +## Routes + +- `GET /oauth/authorize` - authorization endpoint (renders user-picker UI) +- `POST /oauth/authorize/callback` - form submission; issues code and redirects +- `POST /v1/oauth/token` - token exchange (authorization code grant with PKCE) +- `POST /v1/oauth/refresh_token` - refresh token grant +- `GET /v1/user/me` - get user profile + +## Start + +```bash +npx emulate --service heygen +# http://localhost:4007 +``` + +## Environment Variables + +Point your app at the emulator by overriding these variables: + +```bash +HEYGEN_OAUTH_AUTHORIZE_URL=http://localhost:4007/oauth/authorize +HEYGEN_OAUTH_TOKEN_URL=http://localhost:4007/v1/oauth/token +HEYGEN_OAUTH_REFRESH_URL=http://localhost:4007/v1/oauth/refresh_token +HEYGEN_USER_ME_URL=http://localhost:4007/v1/user/me +HEYGEN_OAUTH_CLIENT_ID=dev_client_id +``` + +## User Response + +The `GET /v1/user/me` response matches HeyGen's production shape: + +```json +{ + "code": 100, + "data": { + "user": { + "user_id": "heygen_a1b2c3d4", + "email": "testuser@heygen.com", + "username": "Test User", + "email_verified": true + } + }, + "message": "Success" +} +``` + +## Seed Config + +```yaml +heygen: + users: + - email: alice@example.com + name: Alice + picture: https://example.com/alice.jpg + oauth_clients: + - client_id: my_client_id + client_secret: my_client_secret + name: My App (dev) + redirect_uris: + - http://localhost:3000/api/auth/callback +``` diff --git a/apps/web/components/docs-mobile-nav.tsx b/apps/web/components/docs-mobile-nav.tsx index 88f9a410..d1c7ecb5 100644 --- a/apps/web/components/docs-mobile-nav.tsx +++ b/apps/web/components/docs-mobile-nav.tsx @@ -15,6 +15,7 @@ const nav = [ { href: "/apple", label: "Apple Sign In" }, { href: "/microsoft", label: "Microsoft Entra ID" }, { href: "/aws", label: "AWS" }, + { href: "/heygen", label: "HeyGen API" }, { href: "/authentication", label: "Authentication" }, { href: "/architecture", label: "Architecture" }, ]; diff --git a/apps/web/components/docs-nav.tsx b/apps/web/components/docs-nav.tsx index d1f8b05a..55176154 100644 --- a/apps/web/components/docs-nav.tsx +++ b/apps/web/components/docs-nav.tsx @@ -13,6 +13,7 @@ const nav = [ { href: "/apple", label: "Apple Sign In" }, { href: "/microsoft", label: "Microsoft Entra ID" }, { href: "/aws", label: "AWS" }, + { href: "/heygen", label: "HeyGen API" }, { href: "/authentication", label: "Authentication" }, { href: "/architecture", label: "Architecture" }, ]; diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index fe334e66..f635d49a 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -13,6 +13,7 @@ export const allDocsPages: NavItem[] = [ { name: "Apple Sign In", href: "/apple" }, { name: "Microsoft Entra ID", href: "/microsoft" }, { name: "AWS", href: "/aws" }, + { name: "HeyGen API", href: "/heygen" }, { name: "Authentication", href: "/authentication" }, { name: "Architecture", href: "/architecture" }, ]; diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 6d2f8583..b1f7204b 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = { apple: "Apple Sign In", microsoft: "Microsoft Entra ID", aws: "AWS", + heygen: "HeyGen API", authentication: "Authentication", architecture: "Architecture", }; diff --git a/packages/@emulators/heygen/package.json b/packages/@emulators/heygen/package.json new file mode 100644 index 00000000..57fc8796 --- /dev/null +++ b/packages/@emulators/heygen/package.json @@ -0,0 +1,44 @@ +{ + "name": "@emulators/heygen", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "homepage": "https://emulate.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/emulate.git", + "directory": "packages/@emulators/heygen" + }, + "bugs": { + "url": "https://github.com/vercel-labs/emulate/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "vitest run", + "clean": "rm -rf dist .turbo" + }, + "dependencies": { + "@emulators/core": "workspace:*", + "hono": "^4" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/packages/@emulators/heygen/src/entities.ts b/packages/@emulators/heygen/src/entities.ts new file mode 100644 index 00000000..12d5072b --- /dev/null +++ b/packages/@emulators/heygen/src/entities.ts @@ -0,0 +1,15 @@ +import type { Entity } from "@emulators/core"; + +export interface HeyGenUser extends Entity { + user_id: string; + email: string; + name: string; + picture: string | null; +} + +export interface HeyGenOAuthClient extends Entity { + client_id: string; + client_secret: string; + name: string; + redirect_uris: string[]; +} diff --git a/packages/@emulators/heygen/src/index.ts b/packages/@emulators/heygen/src/index.ts new file mode 100644 index 00000000..d9efa2d8 --- /dev/null +++ b/packages/@emulators/heygen/src/index.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "crypto"; +import type { + AppEnv, + RouteContext, + ServicePlugin, + Store, + TokenMap, + WebhookDispatcher, +} from "@emulators/core"; +import type { Hono } from "hono"; +import { oauthRoutes } from "./routes/oauth.js"; +import { getHeyGenStore } from "./store.js"; + +export { getHeyGenStore, type HeyGenStore } from "./store.js"; +export * from "./entities.js"; + +export interface HeyGenSeedUser { + email: string; + name?: string; + picture?: string; +} + +export interface HeyGenSeedConfig { + port?: number; + users?: HeyGenSeedUser[]; + oauth_clients?: Array<{ + client_id: string; + client_secret: string; + name?: string; + redirect_uris: string[]; + }>; +} + +function generateUserId(): string { + return "heygen_" + randomBytes(8).toString("hex"); +} + +function seedDefaults(store: Store): void { + const hs = getHeyGenStore(store); + const defaultEmail = "testuser@heygen.com"; + + if (!hs.users.findOneBy("email", defaultEmail)) { + hs.users.insert({ + user_id: generateUserId(), + email: defaultEmail, + name: "Test User", + picture: null, + }); + } +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: HeyGenSeedConfig): void { + const hs = getHeyGenStore(store); + + if (config.users) { + for (const user of config.users) { + const existing = hs.users.findOneBy("email", user.email); + if (!existing) { + hs.users.insert({ + user_id: generateUserId(), + email: user.email, + name: user.name ?? user.email.split("@")[0], + picture: user.picture ?? null, + }); + } + } + } + + if (config.oauth_clients) { + for (const client of config.oauth_clients) { + const existing = hs.oauthClients.findOneBy("client_id", client.client_id); + if (existing) continue; + hs.oauthClients.insert({ + client_id: client.client_id, + client_secret: client.client_secret, + name: client.name ?? "App (HeyGen)", + redirect_uris: client.redirect_uris, + }); + } + } + + if (!config.users || config.users.length === 0) { + seedDefaults(store); + } +} + +export const heygenPlugin: ServicePlugin = { + name: "heygen", + register( + app: Hono, + store: Store, + webhooks: WebhookDispatcher, + baseUrl: string, + tokenMap?: TokenMap, + ): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + oauthRoutes(ctx); + }, + seed(store: Store): void { + seedDefaults(store); + }, +}; + +export default heygenPlugin; diff --git a/packages/@emulators/heygen/src/routes/oauth.ts b/packages/@emulators/heygen/src/routes/oauth.ts new file mode 100644 index 00000000..f9099ab4 --- /dev/null +++ b/packages/@emulators/heygen/src/routes/oauth.ts @@ -0,0 +1,308 @@ +import { createHash, randomBytes } from "crypto"; +import type { RouteContext } from "@emulators/core"; +import { + renderCardPage, + renderErrorPage, + renderUserButton, + matchesRedirectUri, + escapeHtml, + bodyStr, + debug, + type Store, +} from "@emulators/core"; +import { getHeyGenStore } from "../store.js"; + +type PendingCode = { + email: string; + redirectUri: string; + clientId: string; + codeChallenge: string; + codeChallengeMethod: string; + created_at: number; +}; + +const PENDING_CODE_TTL_MS = 10 * 60 * 1000; +const TOKEN_EXPIRES_IN = 864_000; + +type RefreshTokenRecord = { + email: string; + clientId: string; +}; + +function getPendingCodes(store: Store): Map { + let map = store.getData>("heygen.oauth.pendingCodes"); + if (!map) { + map = new Map(); + store.setData("heygen.oauth.pendingCodes", map); + } + return map; +} + +function getRefreshTokens(store: Store): Map { + let map = store.getData>("heygen.oauth.refreshTokens"); + if (!map) { + map = new Map(); + store.setData("heygen.oauth.refreshTokens", map); + } + return map; +} + +function isPendingCodeExpired(p: PendingCode): boolean { + return Date.now() - p.created_at > PENDING_CODE_TTL_MS; +} + +function parseBody(rawText: string, contentType: string): Record { + if (contentType.includes("application/json")) { + try { return JSON.parse(rawText); } catch { return {}; } + } + return Object.fromEntries(new URLSearchParams(rawText)); +} + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +const SERVICE_LABEL = "HeyGen"; + +export function oauthRoutes({ app, store, tokenMap }: RouteContext): void { + const hs = getHeyGenStore(store); + + app.get("/oauth/authorize", (c) => { + const client_id = c.req.query("client_id") ?? ""; + const redirect_uri = c.req.query("redirect_uri") ?? ""; + const state = c.req.query("state") ?? ""; + const code_challenge = c.req.query("code_challenge") ?? ""; + const code_challenge_method = c.req.query("code_challenge_method") ?? ""; + + const clientsConfigured = hs.oauthClients.all().length > 0; + let clientName = ""; + if (clientsConfigured) { + const client = hs.oauthClients.findOneBy("client_id", client_id); + if (!client) { + return c.html( + renderErrorPage("Application not found", `The client_id '${client_id}' is not registered.`, SERVICE_LABEL), + 400, + ); + } + if (redirect_uri && !matchesRedirectUri(redirect_uri, client.redirect_uris)) { + return c.html( + renderErrorPage("Redirect URI mismatch", "The redirect_uri is not registered for this application.", SERVICE_LABEL), + 400, + ); + } + clientName = client.name; + } + + const subtitleText = clientName + ? `Sign in to ${escapeHtml(clientName)} with your HeyGen account.` + : "Choose a seeded user to continue."; + + const users = hs.users.all(); + const userButtons = users + .map((user) => + renderUserButton({ + letter: (user.email[0] ?? "?").toUpperCase(), + login: user.email, + name: user.name, + email: user.email, + formAction: "/oauth/authorize/callback", + hiddenFields: { + email: user.email, + redirect_uri, + state, + client_id, + code_challenge, + code_challenge_method, + }, + }), + ) + .join("\n"); + + const body = + users.length === 0 + ? '

No users in the emulator store.

' + : userButtons; + + return c.html(renderCardPage("Sign in to HeyGen", subtitleText, body, SERVICE_LABEL)); + }); + + app.post("/oauth/authorize/callback", async (c) => { + const formBody = await c.req.parseBody(); + const email = bodyStr(formBody.email); + const redirect_uri = bodyStr(formBody.redirect_uri); + const state = bodyStr(formBody.state); + const client_id = bodyStr(formBody.client_id); + const code_challenge = bodyStr(formBody.code_challenge); + const code_challenge_method = bodyStr(formBody.code_challenge_method); + + const code = randomBytes(20).toString("hex"); + + getPendingCodes(store).set(code, { + email, + redirectUri: redirect_uri, + clientId: client_id, + codeChallenge: code_challenge || "", + codeChallengeMethod: code_challenge_method || "", + created_at: Date.now(), + }); + + debug("heygen.oauth", `[HeyGen callback] code=${code.slice(0, 8)}... email=${email}`); + + const url = new URL(redirect_uri); + url.searchParams.set("code", code); + if (state) url.searchParams.set("state", state); + + return c.redirect(url.toString(), 302); + }); + + app.post("/v1/oauth/token", async (c) => { + const rawText = await c.req.text(); + const body = parseBody(rawText, c.req.header("Content-Type") ?? ""); + + const code = str(body.code); + const grant_type = str(body.grant_type); + const code_verifier = str(body.code_verifier) || undefined; + const redirect_uri = str(body.redirect_uri); + const bodyClientId = str(body.client_id); + + if (grant_type !== "authorization_code") { + return c.json({ error: "unsupported_grant_type", error_description: "Only authorization_code is supported on this endpoint." }, 400); + } + + const clientsConfigured = hs.oauthClients.all().length > 0; + if (clientsConfigured) { + const client = hs.oauthClients.findOneBy("client_id", bodyClientId); + if (!client) { + return c.json({ error: "invalid_client", error_description: "The client_id is incorrect." }, 401); + } + } + + const pendingMap = getPendingCodes(store); + const pending = pendingMap.get(code); + if (!pending) { + return c.json({ error: "invalid_grant", error_description: "The code is incorrect or expired." }, 400); + } + if (isPendingCodeExpired(pending)) { + pendingMap.delete(code); + return c.json({ error: "invalid_grant", error_description: "The code is incorrect or expired." }, 400); + } + + if (pending.redirectUri && redirect_uri && pending.redirectUri !== redirect_uri) { + pendingMap.delete(code); + return c.json({ error: "invalid_grant", error_description: "The redirect_uri does not match." }, 400); + } + + if (pending.codeChallenge) { + if (!code_verifier) { + return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); + } + const method = (pending.codeChallengeMethod || "S256").toUpperCase(); + if (method === "S256") { + const expected = createHash("sha256").update(code_verifier).digest("base64url"); + if (expected !== pending.codeChallenge) { + return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); + } + } else if (method === "PLAIN") { + if (code_verifier !== pending.codeChallenge) { + return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); + } + } else { + return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); + } + } + + pendingMap.delete(code); + + const user = hs.users.findOneBy("email", pending.email); + if (!user) { + return c.json({ error: "invalid_grant", error_description: "User not found." }, 400); + } + + const accessToken = "heygen_" + randomBytes(20).toString("base64url"); + const refreshToken = "heygen_refresh_" + randomBytes(24).toString("base64url"); + + if (tokenMap) { + tokenMap.set(accessToken, { login: user.email, id: user.id, scopes: [] }); + } + getRefreshTokens(store).set(refreshToken, { + email: user.email, + clientId: pending.clientId, + }); + + debug("heygen.oauth", `[HeyGen token] issued token for ${user.email}`); + + return c.json({ + token_type: "Bearer", + access_token: accessToken, + expires_in: TOKEN_EXPIRES_IN, + refresh_token: refreshToken, + }); + }); + + app.post("/v1/oauth/refresh_token", async (c) => { + const rawText = await c.req.text(); + const body = parseBody(rawText, c.req.header("Content-Type") ?? ""); + + const grant_type = str(body.grant_type); + const refreshToken = str(body.refresh_token); + + if (grant_type !== "refresh_token") { + return c.json({ error: "unsupported_grant_type", error_description: "Only refresh_token is supported on this endpoint." }, 400); + } + + const record = getRefreshTokens(store).get(refreshToken); + if (!record) { + return c.json({ error: "invalid_grant", error_description: "The refresh token is invalid." }, 400); + } + + const user = hs.users.findOneBy("email", record.email); + if (!user) { + return c.json({ error: "invalid_grant", error_description: "User not found." }, 400); + } + + const newAccessToken = "heygen_" + randomBytes(20).toString("base64url"); + const newRefreshToken = "heygen_refresh_" + randomBytes(24).toString("base64url"); + + getRefreshTokens(store).delete(refreshToken); + getRefreshTokens(store).set(newRefreshToken, { + email: user.email, + clientId: record.clientId, + }); + + if (tokenMap) { + tokenMap.set(newAccessToken, { login: user.email, id: user.id, scopes: [] }); + } + + return c.json({ + token_type: "Bearer", + access_token: newAccessToken, + expires_in: TOKEN_EXPIRES_IN, + refresh_token: newRefreshToken, + }); + }); + + app.get("/v1/user/me", (c) => { + const authUser = c.get("authUser"); + if (!authUser) { + return c.json({ error: "invalid_token", error_description: "Authentication required." }, 401); + } + + const user = hs.users.findOneBy("email", authUser.login); + if (!user) { + return c.json({ error: "invalid_token", error_description: "User not found." }, 401); + } + + return c.json({ + code: 100, + data: { + user: { + user_id: user.user_id, + email: user.email, + username: user.name, + email_verified: true, + }, + }, + message: "Success", + }); + }); +} diff --git a/packages/@emulators/heygen/src/store.ts b/packages/@emulators/heygen/src/store.ts new file mode 100644 index 00000000..6868c48d --- /dev/null +++ b/packages/@emulators/heygen/src/store.ts @@ -0,0 +1,14 @@ +import type { Store, Collection } from "@emulators/core"; +import type { HeyGenUser, HeyGenOAuthClient } from "./entities.js"; + +export interface HeyGenStore { + users: Collection; + oauthClients: Collection; +} + +export function getHeyGenStore(store: Store): HeyGenStore { + return { + users: store.collection("heygen.users", ["user_id", "email"]), + oauthClients: store.collection("heygen.oauth_clients", ["client_id"]), + }; +} diff --git a/packages/@emulators/heygen/tsconfig.json b/packages/@emulators/heygen/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/heygen/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/heygen/tsup.config.ts b/packages/@emulators/heygen/tsup.config.ts new file mode 100644 index 00000000..59a7354c --- /dev/null +++ b/packages/@emulators/heygen/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 1c16d527..806815ba 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -68,6 +68,7 @@ "@emulators/vercel": "workspace:*", "@emulators/resend": "workspace:*", "@emulators/stripe": "workspace:*", + "@emulators/heygen": "workspace:*", "tsup": "^8", "typescript": "^5.7" } diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 764d0148..50fe3746 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -14,7 +14,7 @@ export interface ServiceEntry { initConfig: Record; } -const SERVICE_NAME_LIST = ["vercel", "github", "google", "slack", "apple", "microsoft", "okta", "aws", "resend", "stripe", "mongoatlas"] as const; +const SERVICE_NAME_LIST = ["vercel", "github", "google", "slack", "apple", "microsoft", "okta", "aws", "resend", "stripe", "mongoatlas", "heygen"] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -298,6 +298,31 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + heygen: { + label: "HeyGen OAuth 2.0 emulator", + endpoints: "OAuth authorize, PKCE token exchange, refresh token, user info", + async load() { + const mod = await import("@emulators/heygen"); + return { plugin: mod.heygenPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback(cfg) { + const firstEmail = (cfg?.users as Array<{ email?: string }> | undefined)?.[0]?.email ?? "testuser@heygen.com"; + return { login: firstEmail, id: 1, scopes: [] }; + }, + initConfig: { + heygen: { + users: [{ email: "testuser@heygen.com", name: "Test User" }], + oauth_clients: [ + { + client_id: "dev_client_id", + client_secret: "dev_client_secret", + name: "HyperFrames (dev)", + redirect_uris: ["http://localhost:3000/api/auth/callback"], + }, + ], + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4d8625..9e873179 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,25 @@ importers: specifier: ^4.1.0 version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.13(@types/node@22.19.15)(typescript@5.9.3))(vite@8.0.1(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.2)) + packages/@emulators/heygen: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + hono: + specifier: ^4 + version: 4.12.8 + devDependencies: + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(msw@2.12.13(@types/node@22.19.15)(typescript@5.9.3))(vite@8.0.1(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.2)) + packages/@emulators/microsoft: dependencies: '@emulators/core': @@ -455,6 +474,9 @@ importers: '@emulators/google': specifier: workspace:* version: link:../@emulators/google + '@emulators/heygen': + specifier: workspace:* + version: link:../@emulators/heygen '@emulators/microsoft': specifier: workspace:* version: link:../@emulators/microsoft diff --git a/skills/heygen/SKILL.md b/skills/heygen/SKILL.md new file mode 100644 index 00000000..41340236 --- /dev/null +++ b/skills/heygen/SKILL.md @@ -0,0 +1,137 @@ +--- +name: heygen +description: Emulated HeyGen OAuth 2.0 for local development and testing. Use when the user needs to test HeyGen sign-in locally, emulate HeyGen token exchange, configure HeyGen OAuth clients, work with HeyGen user/me endpoint without hitting real HeyGen APIs, or test PKCE flows against a local HeyGen auth server. Triggers include "HeyGen OAuth", "emulate HeyGen", "mock HeyGen login", "test HeyGen sign-in", "HeyGen PKCE", "local HeyGen auth", or any task requiring a local HeyGen auth API. +allowed-tools: Bash(npx emulate:*), Bash(emulate:*), Bash(curl:*) +--- + +# HeyGen OAuth 2.0 Emulator + +OAuth 2.0 authorization code flow with mandatory PKCE (S256). Matches HeyGen's production API shape including the `{ code: 100, data, message }` response wrapper on `/v1/user/me`. + +## Start + +```bash +# HeyGen only +npx emulate --service heygen + +# Default port +# http://localhost:4007 +``` + +Or programmatically: + +```typescript +import { createEmulator } from 'emulate' + +const heygen = await createEmulator({ service: 'heygen', port: 4007 }) +// heygen.url === 'http://localhost:4007' +``` + +## Pointing Your App at the Emulator + +### Environment Variables + +Set these to override the default HeyGen OAuth endpoints: + +```bash +HEYGEN_OAUTH_AUTHORIZE_URL=http://localhost:4007/oauth/authorize +HEYGEN_OAUTH_TOKEN_URL=http://localhost:4007/v1/oauth/token +HEYGEN_OAUTH_REFRESH_URL=http://localhost:4007/v1/oauth/refresh_token +HEYGEN_USER_ME_URL=http://localhost:4007/v1/user/me +HEYGEN_OAUTH_CLIENT_ID=dev_client_id +``` + +### OAuth URL Mapping + +| Real HeyGen URL | Emulator URL | +|-----------------|-------------| +| `https://app.heygen.com/oauth/authorize` | `http://localhost:4007/oauth/authorize` | +| `https://api2.heygen.com/v1/oauth/token` | `http://localhost:4007/v1/oauth/token` | +| `https://api2.heygen.com/v1/oauth/refresh_token` | `http://localhost:4007/v1/oauth/refresh_token` | +| `https://api2.heygen.com/v1/user/me` | `http://localhost:4007/v1/user/me` | + +## Flow + +The emulator implements HeyGen's OAuth 2.0 authorization code flow with PKCE: + +1. Your app redirects to `/oauth/authorize` with `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, and `state` +2. A user-picker page is shown listing all seeded users +3. The user clicks their account; the emulator redirects to your `redirect_uri` with a `code` and `state` +4. Your app POSTs to `/v1/oauth/token` with `code`, `code_verifier`, `client_id`, `grant_type=authorization_code`, and `redirect_uri` +5. The emulator returns `access_token` and `refresh_token` (expires_in: 864000) +6. Your app GETs `/v1/user/me` with `Authorization: Bearer ` + +## User Response + +```json +{ + "code": 100, + "data": { + "user": { + "user_id": "heygen_a1b2c3d4", + "email": "testuser@heygen.com", + "username": "Test User", + "email_verified": true + } + }, + "message": "Success" +} +``` + +## Seed Config + +```yaml +heygen: + users: + - email: alice@example.com + name: Alice + picture: https://example.com/alice.jpg + - email: bob@example.com + name: Bob + oauth_clients: + - client_id: my_client_id + client_secret: my_client_secret + name: My App (dev) + redirect_uris: + - http://localhost:3000/api/auth/callback +``` + +If no `oauth_clients` are configured, any `client_id` is accepted. If clients are configured, only registered `client_id` values are validated (no `client_secret` required — HeyGen uses public PKCE clients). + +## API Reference + +### GET /oauth/authorize + +Renders the user-picker UI. Parameters: `client_id`, `redirect_uri`, `response_type=code`, `state`, `code_challenge`, `code_challenge_method`. + +### POST /oauth/authorize/callback + +Form submission from the user-picker. Stores the pending code and redirects to `redirect_uri` with `?code=...&state=...`. + +### POST /v1/oauth/token + +Exchanges an authorization code for tokens. Validates PKCE. Accepts `application/x-www-form-urlencoded` or `application/json`. + +**Request fields:** `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri` + +**Response:** +```json +{ + "token_type": "Bearer", + "access_token": "heygen_...", + "expires_in": 864000, + "refresh_token": "heygen_refresh_..." +} +``` + +### POST /v1/oauth/refresh_token + +Exchanges a refresh token for a new access token. Rotates the refresh token. + +**Request fields:** `grant_type=refresh_token`, `client_id`, `refresh_token` + +**Response:** Same shape as token exchange. + +### GET /v1/user/me + +Returns the authenticated user's profile in HeyGen's `{ code, data, message }` wrapper. Requires `Authorization: Bearer `.