From b55145d66c7af8fc0e2530ad1a6294de7ac7c1de Mon Sep 17 00:00:00 2001 From: Dale Alexander Webb Date: Wed, 1 Apr 2026 13:32:58 +0100 Subject: [PATCH] feat: add LinkedIn OAuth 2.0 / OIDC authentication emulator Co-authored-by: Dale Alexander Webb --- README.md | 41 +- packages/@emulators/linkedin/package.json | 45 ++ .../linkedin/src/__tests__/linkedin.test.ts | 421 +++++++++++++++++ packages/@emulators/linkedin/src/entities.ts | 20 + packages/@emulators/linkedin/src/helpers.ts | 8 + packages/@emulators/linkedin/src/index.ts | 89 ++++ .../@emulators/linkedin/src/routes/oauth.ts | 431 ++++++++++++++++++ packages/@emulators/linkedin/src/store.ts | 14 + packages/@emulators/linkedin/tsconfig.json | 8 + packages/@emulators/linkedin/tsup.config.ts | 19 + packages/@emulators/linkedin/vitest.config.ts | 7 + packages/emulate/package.json | 1 + packages/emulate/src/registry.ts | 24 +- pnpm-lock.yaml | 25 + 14 files changed, 1151 insertions(+), 2 deletions(-) create mode 100644 packages/@emulators/linkedin/package.json create mode 100644 packages/@emulators/linkedin/src/__tests__/linkedin.test.ts create mode 100644 packages/@emulators/linkedin/src/entities.ts create mode 100644 packages/@emulators/linkedin/src/helpers.ts create mode 100644 packages/@emulators/linkedin/src/index.ts create mode 100644 packages/@emulators/linkedin/src/routes/oauth.ts create mode 100644 packages/@emulators/linkedin/src/store.ts create mode 100644 packages/@emulators/linkedin/tsconfig.json create mode 100644 packages/@emulators/linkedin/tsup.config.ts create mode 100644 packages/@emulators/linkedin/vitest.config.ts diff --git a/README.md b/README.md index 417b1ae6..05f25e3f 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,17 @@ aws: roles: - role_name: lambda-execution-role description: Role for Lambda function execution + +linkedin: + users: + - email: testuser@linkedin.com + name: Test User + oauth_clients: + - client_id: my-linkedin-client-id + client_secret: my-linkedin-client-secret + name: My LinkedIn App + redirect_uris: + - http://localhost:3000/api/auth/callback/linkedin ``` ## OAuth & Integrations @@ -286,6 +297,20 @@ github: If no `oauth_apps` are configured, the emulator accepts any `client_id` (backward-compatible). With apps configured, strict validation is enforced. +### LinkedIn OAuth + +```yaml +linkedin: + oauth_clients: + - client_id: "my-linkedin-client-id" + client_secret: "my-linkedin-client-secret" + name: "My LinkedIn App" + redirect_uris: + - "http://localhost:3000/api/auth/callback/linkedin" +``` + +LinkedIn uses the same strict validation pattern. If `oauth_clients` are configured, `client_id`, `client_secret`, and `redirect_uri` are all validated. + ### GitHub Apps Full GitHub App support with JWT authentication and installation access tokens: @@ -641,6 +666,19 @@ All operations via `POST /iam/` with `Action` parameter: All operations via `POST /sts/` with `Action` parameter: - `GetCallerIdentity`, `AssumeRole` +## LinkedIn OAuth (OpenID Connect) + +Sign In with LinkedIn using OpenID Connect. Matches the surface used by Better Auth, Auth.js, and other OIDC-aware libraries. + +- `GET /.well-known/openid-configuration` - OIDC discovery document +- `GET /oauth2/v3/certs` - JSON Web Key Set (JWKS) +- `GET /oauth/v2/authorization` - authorization endpoint (renders user picker) +- `POST /oauth/v2/accessToken` - token exchange (`authorization_code`, `refresh_token`) +- `GET /v2/userinfo` - user profile (`sub`, `name`, `given_name`, `family_name`, `picture`, `locale`, `email`, `email_verified`) +- `POST /oauth/v2/revoke` - token revocation + +Supports PKCE (plain + S256), `client_secret_post`, and `client_secret_basic` authentication. + ## Architecture ``` @@ -650,11 +688,12 @@ packages/ core/ # HTTP server, in-memory store, plugin interface, middleware vercel/ # Vercel API service github/ # GitHub API service - google/ # Google OAuth 2.0 / OIDC + Gmail, Calendar, Drive + google/ # Google OAuth 2.0 / OIDC + Gmail, Calendar, and Drive APIs slack/ # Slack Web API, OAuth v2, incoming webhooks apple/ # Apple Sign In / OIDC microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC + Graph /me aws/ # AWS S3, SQS, IAM, STS + linkedin/ # LinkedIn OAuth 2.0 / OpenID Connect apps/ web/ # Documentation site (Next.js) ``` diff --git a/packages/@emulators/linkedin/package.json b/packages/@emulators/linkedin/package.json new file mode 100644 index 00000000..af788cac --- /dev/null +++ b/packages/@emulators/linkedin/package.json @@ -0,0 +1,45 @@ +{ + "name": "@emulators/linkedin", + "version": "0.3.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/linkedin" + }, + "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", + "jose": "^6" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/packages/@emulators/linkedin/src/__tests__/linkedin.test.ts b/packages/@emulators/linkedin/src/__tests__/linkedin.test.ts new file mode 100644 index 00000000..c801fc36 --- /dev/null +++ b/packages/@emulators/linkedin/src/__tests__/linkedin.test.ts @@ -0,0 +1,421 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { Store, WebhookDispatcher, authMiddleware, type TokenMap } from "@emulators/core"; +import { linkedinPlugin, seedFromConfig, getLinkedInStore } from "../index.js"; +import { decodeJwt } from "jose"; + +const base = "http://localhost:4000"; + +function createTestApp() { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + + const app = new Hono(); + app.use("*", authMiddleware(tokenMap)); + linkedinPlugin.register(app as any, store, webhooks, base, tokenMap); + linkedinPlugin.seed?.(store, base); + seedFromConfig(store, base, { + users: [{ email: "testuser@example.com", name: "Test User" }], + oauth_clients: [ + { + client_id: "test-client", + client_secret: "test-secret", + name: "Test App", + redirect_uris: ["http://localhost:3000/callback"], + }, + ], + }); + + return { app, store, webhooks, tokenMap }; +} + +async function getAuthCode( + app: Hono, + options: { + email?: string; + client_id?: string; + redirect_uri?: string; + scope?: string; + state?: string; + nonce?: string; + } = {}, +): Promise<{ code: string; state: string }> { + const email = options.email ?? "testuser@example.com"; + const redirect_uri = options.redirect_uri ?? "http://localhost:3000/callback"; + const scope = options.scope ?? "openid email profile"; + const state = options.state ?? "test-state"; + const nonce = options.nonce ?? "test-nonce"; + const client_id = options.client_id ?? "test-client"; + + const formData = new URLSearchParams({ + email, + redirect_uri, + scope, + state, + nonce, + client_id, + code_challenge: "", + code_challenge_method: "", + }); + + const res = await app.request(`${base}/oauth/v2/authorization/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + + const location = res.headers.get("location") ?? ""; + const url = new URL(location); + return { + code: url.searchParams.get("code") ?? "", + state: url.searchParams.get("state") ?? "", + }; +} + +async function exchangeCode( + app: Hono, + code: string, + options: { + client_id?: string; + client_secret?: string; + redirect_uri?: string; + } = {}, +): Promise { + const formData = new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: options.client_id ?? "test-client", + client_secret: options.client_secret ?? "test-secret", + redirect_uri: options.redirect_uri ?? "http://localhost:3000/callback", + }); + + return app.request(`${base}/oauth/v2/accessToken`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); +} + +describe("LinkedIn plugin integration", () => { + let app: Hono; + let store: Store; + let tokenMap: TokenMap; + + beforeEach(() => { + const testApp = createTestApp(); + app = testApp.app; + store = testApp.store; + tokenMap = testApp.tokenMap; + }); + + // --- OIDC Discovery --- + + it("GET /.well-known/openid-configuration returns LinkedIn OIDC discovery document", async () => { + const res = await app.request(`${base}/.well-known/openid-configuration`); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body.issuer).toBe(base); + expect(body.authorization_endpoint).toBe(`${base}/oauth/v2/authorization`); + expect(body.token_endpoint).toBe(`${base}/oauth/v2/accessToken`); + expect(body.userinfo_endpoint).toBe(`${base}/v2/userinfo`); + expect(body.revocation_endpoint).toBe(`${base}/oauth/v2/revoke`); + expect(body.jwks_uri).toBe(`${base}/oauth2/v3/certs`); + expect(body.response_types_supported).toEqual(["code"]); + expect(body.subject_types_supported).toEqual(["public"]); + expect(body.id_token_signing_alg_values_supported).toEqual(["HS256"]); + expect(body.scopes_supported).toContain("openid"); + expect(body.scopes_supported).toContain("email"); + expect(body.scopes_supported).toContain("profile"); + expect(body.claims_supported).toContain("sub"); + expect(body.claims_supported).toContain("email"); + expect(body.claims_supported).toContain("picture"); + expect(body.code_challenge_methods_supported).toEqual(["plain", "S256"]); + }); + + // --- JWKS --- + + it("GET /oauth2/v3/certs returns empty JWKS", async () => { + const res = await app.request(`${base}/oauth2/v3/certs`); + expect(res.status).toBe(200); + const body = await res.json() as { keys: unknown[] }; + expect(body.keys).toEqual([]); + }); + + // --- Authorization page --- + + it("GET /oauth/v2/authorization returns an HTML sign-in page", async () => { + const url = `${base}/oauth/v2/authorization?client_id=test-client&redirect_uri=${encodeURIComponent("http://localhost:3000/callback")}&response_type=code&scope=openid%20email%20profile`; + const res = await app.request(url); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/text\/html/); + const html = await res.text(); + expect(html.length).toBeGreaterThan(0); + expect(html).toMatch(/Sign in/i); + expect(html).toMatch(/LinkedIn/i); + }); + + it("returns error for unknown client_id when clients are configured", async () => { + const url = `${base}/oauth/v2/authorization?client_id=unknown-client&redirect_uri=${encodeURIComponent("http://localhost:3000/callback")}`; + const res = await app.request(url); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("Application not found"); + }); + + it("callback rejects unknown client_id when clients are configured", async () => { + const formData = new URLSearchParams({ + email: "testuser@example.com", + redirect_uri: "http://localhost:3000/callback", + scope: "openid", + state: "s", + nonce: "", + client_id: "unknown-client", + code_challenge: "", + code_challenge_method: "", + }); + + const res = await app.request(`${base}/oauth/v2/authorization/callback`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + expect(res.status).toBe(400); + const html = await res.text(); + expect(html).toContain("Application not found"); + }); + + // --- Full OAuth flow --- + + it("completes full OAuth authorization_code flow", async () => { + const { code, state } = await getAuthCode(app); + expect(code).toBeTruthy(); + expect(state).toBe("test-state"); + + const tokenRes = await exchangeCode(app, code); + expect(tokenRes.status).toBe(200); + const tokenBody = await tokenRes.json() as Record; + expect(tokenBody.access_token).toBeDefined(); + expect((tokenBody.access_token as string).startsWith("linkedin_")).toBe(true); + expect(tokenBody.refresh_token).toBeDefined(); + expect((tokenBody.refresh_token as string).startsWith("linkedin_refresh_")).toBe(true); + expect(tokenBody.token_type).toBe("Bearer"); + expect(tokenBody.expires_in).toBe(3600); + expect(tokenBody.id_token).toBeDefined(); + expect(tokenBody.scope).toBeDefined(); + + const claims = decodeJwt(tokenBody.id_token as string); + expect(claims.iss).toBe(base); + expect(claims.aud).toBe("test-client"); + expect(claims.sub).toBeDefined(); + expect(claims.email).toBe("testuser@example.com"); + expect(claims.name).toBe("Test User"); + expect(claims.email_verified).toBe(true); + expect(claims.nonce).toBe("test-nonce"); + }); + + // --- Refresh token flow --- + + it("exchanges refresh_token for new access_token", async () => { + const { code } = await getAuthCode(app); + const tokenRes = await exchangeCode(app, code); + const tokenBody = await tokenRes.json() as Record; + const refreshToken = tokenBody.refresh_token as string; + + const refreshFormData = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: "test-client", + client_secret: "test-secret", + }); + + const refreshRes = await app.request(`${base}/oauth/v2/accessToken`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: refreshFormData.toString(), + }); + + expect(refreshRes.status).toBe(200); + const refreshBody = await refreshRes.json() as Record; + expect(refreshBody.access_token).toBeDefined(); + expect((refreshBody.access_token as string).startsWith("linkedin_")).toBe(true); + expect(refreshBody.token_type).toBe("Bearer"); + expect(refreshBody.expires_in).toBe(3600); + }); + + // --- Authorization code is single-use --- + + it("rejects second use of authorization code", async () => { + const { code } = await getAuthCode(app); + + const res1 = await exchangeCode(app, code); + expect(res1.status).toBe(200); + + const res2 = await exchangeCode(app, code); + expect(res2.status).toBe(400); + const body = await res2.json() as Record; + expect(body.error).toBe("invalid_grant"); + }); + + // --- Unsupported grant type --- + + it("rejects unsupported grant type", async () => { + const formData = new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: "test-client", + client_secret: "test-secret", + }); + + const res = await app.request(`${base}/oauth/v2/accessToken`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + + expect(res.status).toBe(400); + const body = await res.json() as Record; + expect(body.error).toBe("unsupported_grant_type"); + }); + + // --- UserInfo endpoint --- + + it("GET /v2/userinfo returns user info when authenticated", async () => { + const { code } = await getAuthCode(app); + const tokenRes = await exchangeCode(app, code); + const tokenBody = await tokenRes.json() as Record; + const accessToken = tokenBody.access_token as string; + + const res = await app.request(`${base}/v2/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body.sub).toBeDefined(); + expect(body.email).toBe("testuser@example.com"); + expect(body.name).toBe("Test User"); + expect(body.email_verified).toBe(true); + expect(body.given_name).toBe("Test"); + expect(body.family_name).toBe("User"); + expect(body.locale).toBe("en_US"); + }); + + // --- Token revocation --- + + it("POST /oauth/v2/revoke returns 200", async () => { + const formData = new URLSearchParams({ + token: "some-token", + }); + + const res = await app.request(`${base}/oauth/v2/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + + expect(res.status).toBe(200); + }); + + // --- Client secret validation --- + + it("rejects incorrect client_secret", async () => { + const { code } = await getAuthCode(app); + const res = await exchangeCode(app, code, { client_secret: "wrong-secret" }); + expect(res.status).toBe(401); + const body = await res.json() as Record; + expect(body.error).toBe("invalid_client"); + }); + + // --- client_secret_basic authentication --- + + it("accepts client credentials via Authorization Basic header", async () => { + const { code } = await getAuthCode(app); + + const credentials = Buffer.from("test-client:test-secret").toString("base64"); + const formData = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:3000/callback", + }); + + const res = await app.request(`${base}/oauth/v2/accessToken`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${credentials}`, + }, + body: formData.toString(), + }); + + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body.access_token).toBeDefined(); + expect((body.access_token as string).startsWith("linkedin_")).toBe(true); + }); + + it("rejects incorrect secret via Authorization Basic header", async () => { + const { code } = await getAuthCode(app); + + const credentials = Buffer.from("test-client:wrong-secret").toString("base64"); + const formData = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: "http://localhost:3000/callback", + }); + + const res = await app.request(`${base}/oauth/v2/accessToken`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: `Basic ${credentials}`, + }, + body: formData.toString(), + }); + + expect(res.status).toBe(401); + const body = await res.json() as Record; + expect(body.error).toBe("invalid_client"); + }); + + // --- Seed from config --- + + it("seeds users and clients from config", () => { + const testStore = new Store(); + const webhooks = new WebhookDispatcher(); + const testTokenMap: TokenMap = new Map(); + const testApp = new Hono(); + testApp.use("*", authMiddleware(testTokenMap)); + linkedinPlugin.register(testApp as any, testStore, webhooks, base, testTokenMap); + + seedFromConfig(testStore, base, { + users: [ + { email: "alice@linkedin.com", name: "Alice Smith" }, + { email: "bob@linkedin.com", name: "Bob Jones", locale: "de_DE" }, + ], + oauth_clients: [ + { + client_id: "my-app", + client_secret: "my-secret", + name: "My App", + redirect_uris: ["http://localhost:3000/callback"], + }, + ], + }); + + const li = getLinkedInStore(testStore); + + const alice = li.users.findOneBy("email", "alice@linkedin.com"); + expect(alice).toBeDefined(); + expect(alice!.name).toBe("Alice Smith"); + expect(alice!.given_name).toBe("Alice"); + expect(alice!.family_name).toBe("Smith"); + expect(alice!.locale).toBe("en_US"); + + const bob = li.users.findOneBy("email", "bob@linkedin.com"); + expect(bob).toBeDefined(); + expect(bob!.locale).toBe("de_DE"); + + const client = li.oauthClients.findOneBy("client_id", "my-app"); + expect(client).toBeDefined(); + expect(client!.name).toBe("My App"); + }); +}); diff --git a/packages/@emulators/linkedin/src/entities.ts b/packages/@emulators/linkedin/src/entities.ts new file mode 100644 index 00000000..ed316328 --- /dev/null +++ b/packages/@emulators/linkedin/src/entities.ts @@ -0,0 +1,20 @@ +import type { Entity } from "@emulators/core"; + +export interface LinkedInUser extends Entity { + /** Subject identifier (unique user ID) */ + sub: string; + email: string; + name: string; + given_name: string; + family_name: string; + picture: string | null; + locale: string; + email_verified: boolean; +} + +export interface LinkedInOAuthClient extends Entity { + client_id: string; + client_secret: string; + name: string; + redirect_uris: string[]; +} diff --git a/packages/@emulators/linkedin/src/helpers.ts b/packages/@emulators/linkedin/src/helpers.ts new file mode 100644 index 00000000..4c284a24 --- /dev/null +++ b/packages/@emulators/linkedin/src/helpers.ts @@ -0,0 +1,8 @@ +import { randomUUID } from "crypto"; + +/** + * Generate a LinkedIn-style subject identifier (UUID v4). + */ +export function generateSub(): string { + return randomUUID(); +} diff --git a/packages/@emulators/linkedin/src/index.ts b/packages/@emulators/linkedin/src/index.ts new file mode 100644 index 00000000..a2a43233 --- /dev/null +++ b/packages/@emulators/linkedin/src/index.ts @@ -0,0 +1,89 @@ +import type { Hono } from "hono"; +import type { ServicePlugin, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext } from "@emulators/core"; +import { getLinkedInStore } from "./store.js"; +import { generateSub } from "./helpers.js"; +import { oauthRoutes } from "./routes/oauth.js"; + +export { getLinkedInStore, type LinkedInStore } from "./store.js"; +export * from "./entities.js"; + +export interface LinkedInSeedConfig { + users?: Array<{ + email: string; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + locale?: string; + }>; + oauth_clients?: Array<{ + client_id: string; + client_secret: string; + name: string; + redirect_uris: string[]; + }>; +} + +function seedDefaults(store: Store, _baseUrl: string): void { + const li = getLinkedInStore(store); + + li.users.insert({ + sub: generateSub(), + email: "testuser@linkedin.com", + name: "Test User", + given_name: "Test", + family_name: "User", + picture: null, + email_verified: true, + locale: "en_US", + }); +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: LinkedInSeedConfig): void { + const li = getLinkedInStore(store); + + if (config.users) { + for (const u of config.users) { + const existing = li.users.findOneBy("email", u.email); + if (existing) continue; + + const nameParts = (u.name ?? "").split(/\s+/).filter(Boolean); + li.users.insert({ + sub: generateSub(), + email: u.email, + name: u.name ?? u.email.split("@")[0], + given_name: u.given_name ?? nameParts[0] ?? "", + family_name: u.family_name ?? nameParts.slice(1).join(" "), + picture: u.picture ?? null, + email_verified: true, + locale: u.locale ?? "en_US", + }); + } + } + + if (config.oauth_clients) { + for (const client of config.oauth_clients) { + const existing = li.oauthClients.findOneBy("client_id", client.client_id); + if (existing) continue; + li.oauthClients.insert({ + client_id: client.client_id, + client_secret: client.client_secret, + name: client.name, + redirect_uris: client.redirect_uris, + }); + } + } +} + +export const linkedinPlugin: ServicePlugin = { + name: "linkedin", + 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, baseUrl: string): void { + seedDefaults(store, baseUrl); + }, +}; + +export default linkedinPlugin; diff --git a/packages/@emulators/linkedin/src/routes/oauth.ts b/packages/@emulators/linkedin/src/routes/oauth.ts new file mode 100644 index 00000000..3ac783ce --- /dev/null +++ b/packages/@emulators/linkedin/src/routes/oauth.ts @@ -0,0 +1,431 @@ +import { createHash, randomBytes } from "crypto"; +import { SignJWT } from "jose"; +import type { RouteContext } from "@emulators/core"; +import { + escapeHtml, + escapeAttr, + renderCardPage, + renderErrorPage, + renderUserButton, + matchesRedirectUri, + constantTimeSecretEqual, + bodyStr, + debug, + type Store, +} from "@emulators/core"; +import { getLinkedInStore } from "../store.js"; +import type { LinkedInUser } from "../entities.js"; + +const JWT_SECRET = new TextEncoder().encode("emulate-linkedin-jwt-secret"); + +type PendingCode = { + email: string; + scope: string; + redirectUri: string; + clientId: string; + nonce: string | null; + codeChallenge: string | null; + codeChallengeMethod: string | null; + created_at: number; +}; + +const PENDING_CODE_TTL_MS = 10 * 60 * 1000; + +type RefreshTokenRecord = { + email: string; + scope: string; + clientId: string; +}; + +function getPendingCodes(store: Store): Map { + let map = store.getData>("linkedin.oauth.pendingCodes"); + if (!map) { + map = new Map(); + store.setData("linkedin.oauth.pendingCodes", map); + } + return map; +} + +function getRefreshTokens(store: Store): Map { + let map = store.getData>("linkedin.oauth.refreshTokens"); + if (!map) { + map = new Map(); + store.setData("linkedin.oauth.refreshTokens", map); + } + return map; +} + +function isPendingCodeExpired(p: PendingCode): boolean { + return Date.now() - p.created_at > PENDING_CODE_TTL_MS; +} + +const SERVICE_LABEL = "LinkedIn"; + +async function createIdToken( + user: LinkedInUser, + clientId: string, + nonce: string | null, + baseUrl: string, +): Promise { + const builder = new SignJWT({ + sub: user.sub, + email: user.email, + email_verified: user.email_verified, + name: user.name, + given_name: user.given_name, + family_name: user.family_name, + picture: user.picture, + locale: user.locale, + ...(nonce ? { nonce } : {}), + }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuer(baseUrl) + .setAudience(clientId) + .setIssuedAt() + .setExpirationTime("1h"); + + return builder.sign(JWT_SECRET); +} + +export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): void { + const li = getLinkedInStore(store); + + // ---------- OIDC Discovery ---------- + + app.get("/.well-known/openid-configuration", (c) => { + return c.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/oauth/v2/authorization`, + token_endpoint: `${baseUrl}/oauth/v2/accessToken`, + userinfo_endpoint: `${baseUrl}/v2/userinfo`, + revocation_endpoint: `${baseUrl}/oauth/v2/revoke`, + jwks_uri: `${baseUrl}/oauth2/v3/certs`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["HS256"], + scopes_supported: ["openid", "email", "profile"], + token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], + claims_supported: [ + "sub", "email", "email_verified", "name", + "given_name", "family_name", "picture", "locale", + ], + code_challenge_methods_supported: ["plain", "S256"], + }); + }); + + // ---------- JWKS (stub) ---------- + + app.get("/oauth2/v3/certs", (c) => { + return c.json({ keys: [] }); + }); + + // ---------- Authorization page ---------- + + app.get("/oauth/v2/authorization", (c) => { + const client_id = c.req.query("client_id") ?? ""; + const redirect_uri = c.req.query("redirect_uri") ?? ""; + const scope = c.req.query("scope") ?? ""; + const state = c.req.query("state") ?? ""; + const nonce = c.req.query("nonce") ?? ""; + const code_challenge = c.req.query("code_challenge") ?? ""; + const code_challenge_method = c.req.query("code_challenge_method") ?? ""; + + const clientsConfigured = li.oauthClients.all().length > 0; + let clientName = ""; + if (clientsConfigured) { + const client = li.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 LinkedIn account.` + : "Choose a seeded user to continue."; + + const users = li.users.all(); + const userButtons = users + .map((user) => { + return renderUserButton({ + letter: (user.email[0] ?? "?").toUpperCase(), + login: user.email, + name: user.name, + email: user.email, + formAction: "/oauth/v2/authorization/callback", + hiddenFields: { + email: user.email, + redirect_uri, + scope, + state, + nonce, + 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 with LinkedIn", subtitleText, body, SERVICE_LABEL)); + }); + + // ---------- Authorization callback ---------- + + app.post("/oauth/v2/authorization/callback", async (c) => { + const body = await c.req.parseBody(); + const email = bodyStr(body.email); + const redirect_uri = bodyStr(body.redirect_uri); + const scope = bodyStr(body.scope); + const state = bodyStr(body.state); + const client_id = bodyStr(body.client_id); + const nonce = bodyStr(body.nonce); + const code_challenge = bodyStr(body.code_challenge); + const code_challenge_method = bodyStr(body.code_challenge_method); + + const clientsConfigured = li.oauthClients.all().length > 0; + if (clientsConfigured) { + const client = li.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, + ); + } + } + + const code = randomBytes(20).toString("hex"); + + getPendingCodes(store).set(code, { + email, + scope, + redirectUri: redirect_uri, + clientId: client_id, + nonce: nonce || null, + codeChallenge: code_challenge || null, + codeChallengeMethod: code_challenge_method || null, + created_at: Date.now(), + }); + + debug("linkedin.oauth", `[LinkedIn 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); + }); + + // ---------- Token exchange ---------- + + app.post("/oauth/v2/accessToken", async (c) => { + const contentType = c.req.header("Content-Type") ?? ""; + const rawText = await c.req.text(); + + let body: Record; + if (contentType.includes("application/json")) { + try { body = JSON.parse(rawText); } catch { body = {}; } + } else { + body = Object.fromEntries(new URLSearchParams(rawText)); + } + + const code = typeof body.code === "string" ? body.code : ""; + const redirect_uri = typeof body.redirect_uri === "string" ? body.redirect_uri : ""; + const grant_type = typeof body.grant_type === "string" ? body.grant_type : ""; + const code_verifier = typeof body.code_verifier === "string" ? body.code_verifier : undefined; + let client_id = typeof body.client_id === "string" ? body.client_id : ""; + let client_secret = typeof body.client_secret === "string" ? body.client_secret : ""; + + const authHeader = c.req.header("Authorization") ?? ""; + if (authHeader.startsWith("Basic ")) { + const decoded = Buffer.from(authHeader.slice(6), "base64").toString(); + const sep = decoded.indexOf(":"); + if (sep !== -1) { + const headerId = decodeURIComponent(decoded.slice(0, sep)); + const headerSecret = decodeURIComponent(decoded.slice(sep + 1)); + if (!client_id) client_id = headerId; + if (!client_secret) client_secret = headerSecret; + } + } + + const clientsConfigured = li.oauthClients.all().length > 0; + if (clientsConfigured) { + const client = li.oauthClients.findOneBy("client_id", client_id); + if (!client) { + return c.json({ error: "invalid_client", error_description: "The client_id is incorrect." }, 401); + } + if (!constantTimeSecretEqual(client_secret, client.client_secret)) { + return c.json({ error: "invalid_client", error_description: "The client_secret is incorrect." }, 401); + } + } + + if (grant_type === "refresh_token") { + const refreshToken = typeof body.refresh_token === "string" ? body.refresh_token : ""; + const record = getRefreshTokens(store).get(refreshToken); + if (!record) { + return c.json({ error: "invalid_grant", error_description: "The refresh token is invalid." }, 400); + } + if (clientsConfigured && record.clientId !== client_id) { + return c.json({ error: "invalid_grant", error_description: "The refresh token is invalid." }, 400); + } + + const user = li.users.findOneBy("email", record.email as LinkedInUser["email"]); + if (!user) { + return c.json({ error: "invalid_grant", error_description: "User not found." }, 400); + } + + const accessToken = "linkedin_" + randomBytes(20).toString("base64url"); + const scopes = record.scope ? record.scope.split(/\s+/).filter(Boolean) : []; + + if (tokenMap) { + tokenMap.set(accessToken, { login: user.email, id: user.id, scopes }); + } + + return c.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + scope: record.scope || "openid email profile", + }); + } + + if (grant_type !== "authorization_code") { + return c.json({ error: "unsupported_grant_type", error_description: "Only authorization_code and refresh_token are supported." }, 400); + } + + 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.codeChallenge != null) { + if (code_verifier === undefined) { + return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); + } + const method = (pending.codeChallengeMethod ?? "plain").toLowerCase(); + 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 = li.users.findOneBy("email", pending.email as LinkedInUser["email"]); + if (!user) { + return c.json({ error: "invalid_grant", error_description: "User not found." }, 400); + } + + const accessToken = "linkedin_" + randomBytes(20).toString("base64url"); + const refreshToken = "linkedin_refresh_" + randomBytes(24).toString("base64url"); + const scopes = pending.scope ? pending.scope.split(/\s+/).filter(Boolean) : []; + + if (tokenMap) { + tokenMap.set(accessToken, { login: user.email, id: user.id, scopes }); + } + getRefreshTokens(store).set(refreshToken, { + email: user.email, + scope: pending.scope, + clientId: pending.clientId, + }); + + const idToken = await createIdToken(user, pending.clientId, pending.nonce, baseUrl); + + debug("linkedin.oauth", `[LinkedIn token] issued token for ${user.email}`); + + return c.json({ + access_token: accessToken, + refresh_token: refreshToken, + id_token: idToken, + token_type: "Bearer", + expires_in: 3600, + scope: pending.scope || "openid email profile", + }); + }); + + // ---------- User info ---------- + + app.get("/v2/userinfo", (c) => { + const authUser = c.get("authUser"); + if (!authUser) { + return c.json({ error: "invalid_token", error_description: "Authentication required." }, 401); + } + + const user = li.users.findOneBy("email", authUser.login as LinkedInUser["email"]); + if (!user) { + return c.json({ error: "invalid_token", error_description: "User not found." }, 401); + } + + return c.json({ + sub: user.sub, + email: user.email, + email_verified: user.email_verified, + name: user.name, + given_name: user.given_name, + family_name: user.family_name, + picture: user.picture, + locale: user.locale, + }); + }); + + // ---------- Token revocation ---------- + + app.post("/oauth/v2/revoke", async (c) => { + const contentType = c.req.header("Content-Type") ?? ""; + const rawText = await c.req.text(); + + let token: string; + if (contentType.includes("application/json")) { + try { + const parsed = JSON.parse(rawText); + token = typeof parsed.token === "string" ? parsed.token : ""; + } catch { + token = ""; + } + } else { + const params = new URLSearchParams(rawText); + token = params.get("token") ?? ""; + } + + if (token && tokenMap) { + tokenMap.delete(token); + } + if (token) { + getRefreshTokens(store).delete(token); + } + + return c.body(null, 200); + }); +} diff --git a/packages/@emulators/linkedin/src/store.ts b/packages/@emulators/linkedin/src/store.ts new file mode 100644 index 00000000..5e82246c --- /dev/null +++ b/packages/@emulators/linkedin/src/store.ts @@ -0,0 +1,14 @@ +import { Store, type Collection } from "@emulators/core"; +import type { LinkedInUser, LinkedInOAuthClient } from "./entities.js"; + +export interface LinkedInStore { + users: Collection; + oauthClients: Collection; +} + +export function getLinkedInStore(store: Store): LinkedInStore { + return { + users: store.collection("linkedin.users", ["sub", "email"]), + oauthClients: store.collection("linkedin.oauth_clients", ["client_id"]), + }; +} diff --git a/packages/@emulators/linkedin/tsconfig.json b/packages/@emulators/linkedin/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/linkedin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/linkedin/tsup.config.ts b/packages/@emulators/linkedin/tsup.config.ts new file mode 100644 index 00000000..59a7354c --- /dev/null +++ b/packages/@emulators/linkedin/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/@emulators/linkedin/vitest.config.ts b/packages/@emulators/linkedin/vitest.config.ts new file mode 100644 index 00000000..e2ec3329 --- /dev/null +++ b/packages/@emulators/linkedin/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 1c16d527..7f504b71 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -64,6 +64,7 @@ "@emulators/aws": "workspace:*", "@emulators/google": "workspace:*", "@emulators/mongoatlas": "workspace:*", + "@emulators/linkedin": "workspace:*", "@emulators/slack": "workspace:*", "@emulators/vercel": "workspace:*", "@emulators/resend": "workspace:*", diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 764d0148..f463052d 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", "linkedin"] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -298,6 +298,28 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + + linkedin: { + label: "LinkedIn OAuth 2.0 / OpenID Connect emulator", + endpoints: "OAuth authorize, token exchange, userinfo, OIDC discovery, token revocation", + async load() { + const mod = await import("@emulators/linkedin"); + return { plugin: mod.linkedinPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback(cfg) { + const firstEmail = (cfg?.users as Array<{ email?: string }> | undefined)?.[0]?.email ?? "testuser@linkedin.com"; + return { login: firstEmail, id: 1, scopes: ["openid", "email", "profile"] }; + }, + initConfig: { + linkedin: { + users: [{ email: "testuser@linkedin.com", name: "Test User" }], + oauth_clients: [{ + client_id: "example-linkedin-client-id", client_secret: "example-linkedin-client-secret", + name: "My LinkedIn App", redirect_uris: ["http://localhost:3000/api/auth/callback/linkedin"], + }], + }, + }, + }, }; export const DEFAULT_TOKENS = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd4d8625..e4cd2e20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,28 @@ 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/linkedin: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + hono: + specifier: ^4 + version: 4.12.8 + jose: + specifier: ^6 + version: 6.2.2 + 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 +477,9 @@ importers: '@emulators/google': specifier: workspace:* version: link:../@emulators/google + '@emulators/linkedin': + specifier: workspace:* + version: link:../@emulators/linkedin '@emulators/microsoft': specifier: workspace:* version: link:../@emulators/microsoft