{"id":42594781,"url":"https://github.com/plaid/core-exchange-node-example","last_synced_at":"2026-01-28T23:36:22.196Z","repository":{"id":320584389,"uuid":"1082653623","full_name":"plaid/core-exchange-node-example","owner":"plaid","description":"Core Exchange Sample Node.js App","archived":false,"fork":false,"pushed_at":"2026-01-22T22:33:20.000Z","size":694,"stargazers_count":2,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-01-23T14:30:55.713Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/plaid.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-10-24T15:01:26.000Z","updated_at":"2026-01-22T22:32:10.000Z","dependencies_parsed_at":"2025-10-24T17:17:17.364Z","dependency_job_id":"b02b60ad-ae3e-42e0-b46d-3b63d30c082f","html_url":"https://github.com/plaid/core-exchange-node-example","commit_stats":null,"previous_names":["plaid/core-exchange-node-example"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/plaid/core-exchange-node-example","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/plaid%2Fcore-exchange-node-example","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/plaid%2Fcore-exchange-node-example/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/plaid%2Fcore-exchange-node-example/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/plaid%2Fcore-exchange-node-example/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/plaid","download_url":"https://codeload.github.com/plaid/core-exchange-node-example/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/plaid%2Fcore-exchange-node-example/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28855344,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-28T22:56:21.783Z","status":"ssl_error","status_checked_at":"2026-01-28T22:56:00.861Z","response_time":57,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-01-28T23:36:21.486Z","updated_at":"2026-01-28T23:36:22.190Z","avatar_url":"https://github.com/plaid.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Build FDX APIs using Plaid\n\n## Core Exchange Sample Implementation with Node.js\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"apps/app/public/plaidypus-200.png\" alt=\"Plaidypus Logo\" width=\"200\"\u003e\n\u003c/p\u003e\n\nA working example of [Plaid Core Exchange](https://plaid.com/core-exchange/docs/) with OpenID Connect and FDX API v6.3. We built this with TypeScript, Express, and battle-tested OAuth libraries so you can see how all the pieces fit together.\n\n\u003e [!TIP]\n\u003e **Try it live!** View the online demo at [app.plaidypus.dev](https://app.plaidypus.dev/)\n\n## What's Inside\n\n**The Core Stuff:**\n\n- **TypeScript** (v5.9) with ESM modules everywhere\n- **Node.js** (v22+) - Yes, you need the latest\n- **pnpm** (v10) - For managing our monorepo workspace\n\n**OAuth/OIDC (the important bits):**\n\n- **oidc-provider** (v9) - Standards-compliant OpenID Provider\n- **openid-client** (v6) - Certified Relying Party client\n- **jose** (v6) - JWT validation and JWKS handling\n\n**Infrastructure:**\n\n- **Express** (v5) - Our HTTP server framework\n- **Caddy** - Reverse proxy that handles HTTPS with zero config\n- **Pino** - Fast, structured JSON logging\n- **Helmet** - Security headers on by default\n\n**Frontend:**\n\n- **EJS** - Server-side templates (keeping it simple)\n- **Tailwind CSS** (v4) - Utility-first styling\n- **tsx** - TypeScript execution with hot reload\n\n**Development:**\n\n- **concurrently** - Runs multiple services at once\n- **ESLint** - Keeps our code consistent\n\n## How It's Organized\n\nThis monorepo contains three apps and some shared utilities:\n\n### Authorization Server (`apps/auth`)\n\nThis OpenID Provider lets users log in and grant permissions. `oidc-provider` (via Express) handles authentication, authorization, and tokens. Supports multiple clients, refresh tokens, and JWT access tokens. Uses in-memory storage, with plans for PostgreSQL.\n\n**What it does:** Login and consent UI (with EJS + Tailwind), configurable scopes and claims, forced interaction flows, RP-initiated logout\n\n### Resource Server (`apps/api`)\n\nImplements FDX v6.3 via Plaid Core Exchange. Validates JWT access tokens issued by the Auth server and checks their scopes. Customer and account data use a repository pattern.\n\n**Endpoints you get:** Customer info, account details, statements, transactions, contact info, payment and asset transfer network data\n\n### Client Application (`apps/app`)\n\nThe Relying Party app securely accesses protected data using openid-client and PKCE. Includes an API explorer, token debugging tools, and stores tokens in HTTP-only cookies.\n\n**The fun stuff:** API Explorer UI, token inspection, refresh token handling, automatic OIDC discovery that retries until it connects\n\n### Shared Package (`apps/shared`)\n\nCommon utilities and TypeScript configs that all three apps use. Managed with pnpm workspaces.\n\n## Getting Started\n\nFollow these steps to get the project running locally:\n\n1. Install prerequisites (Node.js, pnpm, Caddy)\n2. Install dependencies\n3. Configure environment variables\n4. Set up HTTPS with Caddy\n5. Start the services\n\n### What You Need (macOS)\n\n```bash\nbrew install node pnpm caddy\n```\n\n**Version requirements:**\n\n- Node.js ≥22.0.0 (we enforce this in `package.json`)\n- pnpm ≥10.15.1\n- Caddy (latest is fine)\n\n### Installation\n\n```bash\npnpm install\n```\n\nThis installs dependencies for all workspace packages. We're using pnpm workspaces with an `apps/*` pattern—it's a nice way to manage a monorepo.\n\n### Configure Environment\n\nEach app requires its own `.env` file. Copy the example files for each service as shown:\n\n```bash\n# Copy environment files for each app\ncp apps/auth/.env.example apps/auth/.env\ncp apps/api/.env.example apps/api/.env\ncp apps/app/.env.example apps/app/.env\n```\n\nDefaults in `.env.example` work out of the box for local dev using localtest.me for URLs; modify as needed.\n\n- **Service URLs**: Uses `*.localtest.me` subdomains (no `/etc/hosts` needed!)\n  - Auth: `https://id.localtest.me` (port 3001)\n  - API: `https://api.localtest.me` (port 3003)\n  - App: `https://app.localtest.me` (port 3004)\n- **OAuth Client**: Pre-configured with development credentials (`dev-rp` / `dev-secret`)\n- **Secrets**: Development-safe defaults (change for production!)\n\n**For production**, you'll need to generate secure secrets for `apps/auth/.env` and `apps/app/.env`:\n\n```bash\n# Generate all production secrets at once\nnode scripts/secrets.js all\n\n# Or generate them individually:\nnode scripts/secrets.js client   # OAuth client credentials\nnode scripts/secrets.js secrets  # Cookie secrets\nnode scripts/secrets.js jwks     # Token signing keys\n```\n\nSee the [Configuration](#configuration) section for detailed information about all available options.\n\n## Commands You'll Use\n\n**Note**: Before running these commands, make sure you've completed the setup steps above (environment configuration and Caddy setup).\n\n### Development Mode\n\n```bash\npnpm dev              # Run all three services with hot reload\npnpm dev:auth         # Just the Authorization Server\npnpm dev:api          # Just the Resource Server\npnpm dev:app          # Just the Client Application\n```\n\n### Production Mode\n\n```bash\npnpm build            # Build everything (TypeScript + CSS)\npnpm --filter @apps/auth start   # Start Auth server\npnpm --filter @apps/api start    # Start API server\npnpm --filter @apps/app start    # Start Client app\n```\n\n### Other Helpful Commands\n\n```bash\npnpm lint             # Check code style\npnpm lint:fix         # Fix what can be auto-fixed\npnpm caddy            # Start the reverse proxy (needs sudo)\n```\n\n### Setting Up HTTPS with Caddy\n\n**This step is required before running the apps.** Caddy generates its own internal CA and handles TLS certificates automatically.\n\n#### Option A: Bind to port 443 (recommended)\n\n```bash\n# From the repo root\nsudo caddy run --config ./caddyfile\n\n# In another terminal, trust Caddy's CA\nsudo caddy trust\n```\n\nThis gives you nice URLs:\n\n- `https://id.localtest.me` (Auth server)\n- `https://app.localtest.me` (Client app)\n- `https://api.localtest.me` (API server)\n\nIf your browser still complains about certificates, restart it or check your Keychain for the Caddy root CA.\n\n#### Option B: No sudo? Use a high port\n\nEdit the `caddyfile` and add a port to each site:\n\n```caddyfile\n:8443 {\n  tls internal\n  reverse_proxy localhost:3001\n}\n```\n\nThen update your `.env` to use `https://localhost:8443` for the issuer and redirect URIs. Port 443 is easier, but this works if you can't use sudo.\n\n### Running the Apps\n\nNode.js doesn't use macOS's system trust store for TLS, so we need to manually point it to Caddy's CA.\n\n```bash\n# The easy way—this sets NODE_EXTRA_CA_CERTS for you\npnpm dev\n\n# Running apps individually? Set this in your terminal first:\nexport NODE_EXTRA_CA_CERTS=\"$HOME/Library/Application Support/Caddy/pki/authorities/local/root.crt\"\n```\n\n**A few notes:**\n\n- You can actually start the Node apps before Caddy is ready. The client app will retry OIDC discovery until `https://id.localtest.me` responds. (You'll see some retry logs, but it'll eventually connect.)\n- That said, starting Caddy first is faster and less noisy.\n- If you switch terminals, remember to set `NODE_EXTRA_CA_CERTS` again—or just use `pnpm dev` which handles it for you.\n\n## Try It Out\n\nOnce everything's running, here's the fun part:\n\n1. **Check the discovery endpoint**: Visit \u003chttps://id.localtest.me/.well-known/openid-configuration\u003e. You should see JSON configuration data.\n\n2. **Log in**: Head to \u003chttps://app.localtest.me\u003e and click **Login**.\n\n3. **Use the demo account**: Email is `user@example.test`, password is `passw0rd!`.\n\n4. **Grant permissions**: You'll see a consent screen asking for:\n   - `openid` - Basic identity\n   - `profile` - Profile information\n   - `email` - Email address\n   - `offline_access` - Offline access (gives you refresh tokens)\n   - `accounts:read` - Account data\n\n5. **Explore the features**: Once you're logged in, check out:\n   - **API Explorer** at `/api-explorer` - Interactive UI to test all the FDX endpoints\n   - **Token Inspector** at `/token` - See your ID token claims and user info\n   - **Token Debug** at `/debug/tokens` - Inspect the raw and decoded tokens (access, ID, refresh)\n\n## What You Get\n\n### Authorization Server (Auth)\n\n- **Multiple client support** - Configure as many OAuth clients as you need via `apps/auth/.env.clients.json` (see `apps/auth/.env.clients.example.json`)\n- **Refresh tokens** - Automatically issued when `offline_access` scope is requested. You can also force-enable them per client with `force_refresh_token: true`\n- **Configurable token lifetimes**:\n  - Session: 1 day\n  - Access Token: 1 hour\n  - ID Token: 1 hour\n  - Refresh Token: 14 days\n  - Grant: 1 year\n- **Dynamic consent UI** - Shows all requested scopes with friendly descriptions\n\n### Resource Server (API)\n\nAll the FDX v6.3 endpoints you need for Plaid Core Exchange:\n\n- **Customer**: `/api/fdx/v6/customers/current`\n- **Accounts**: `/api/fdx/v6/accounts`, `/api/fdx/v6/accounts/{accountId}`\n- **Statements**: `/api/fdx/v6/accounts/{accountId}/statements`, `/api/fdx/v6/accounts/{accountId}/statements/{statementId}`\n- **Transactions**: `/api/fdx/v6/accounts/{accountId}/transactions`\n- **Contact**: `/api/fdx/v6/accounts/{accountId}/contact`\n- **Networks**: `/api/fdx/v6/accounts/{accountId}/payment-networks`, `/api/fdx/v6/accounts/{accountId}/asset-transfer-networks`\n\nEvery endpoint validates JWT access tokens and enforces the right scopes.\n\n### Client Application (APP)\n\n- **API Explorer** - Interactive UI for testing endpoints with query parameters\n- **Token management** - Stores access tokens, refresh tokens, and ID tokens in secure HTTP-only cookies\n- **Token debugging** - View raw and decoded JWT tokens at `/debug/tokens`\n- **Token inspector** - Display ID token claims at `/token`\n- **PKCE** - Uses Proof Key for Code Exchange (because security matters)\n\n## Troubleshooting\n\n**Getting 502 Bad Gateway or TLS errors like `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`?**\n\nMake sure Caddy is running and trusted. Check that the Auth server is reachable at `https://id.localtest.me/.well-known/openid-configuration`. And double-check that `NODE_EXTRA_CA_CERTS` points to Caddy's CA:\n\n```bash\nexport NODE_EXTRA_CA_CERTS=\"$HOME/Library/Application Support/Caddy/pki/authorities/local/root.crt\"\n```\n\n**Changed your ports or hostnames?**\n\nUpdate the relevant variables in each app's `.env` file:\n\n- `apps/auth/.env`: Update `OP_ISSUER`, `OP_PORT`, `REDIRECT_URI`\n- `apps/api/.env`: Update `API_HOST`, `API_PORT`, `OP_ISSUER`\n- `apps/app/.env`: Update `APP_HOST`, `APP_PORT`, `OP_ISSUER`, `REDIRECT_URI`, `API_BASE_URL`\n\nThen update your `caddyfile` to match the new routes.\n\n## Configuration\n\nIf you haven't already, create `.env` files for each app (see [Configure Environment](#configure-environment) in Getting Started). Here's a detailed breakdown of all configuration options:\n\n**Configuration files:**\n\n- `apps/auth/.env` - Authorization Server (OpenID Provider)\n- `apps/api/.env` - Resource Server (API)\n- `apps/app/.env` - Client Application (Relying Party)\n\n### Basic Setup\n\n```bash\n# Service URLs\nOP_ISSUER=https://id.localtest.me\nAPP_BASE_URL=https://app.localtest.me\nAPI_BASE_URL=https://api.localtest.me\n\n# Ports\nOP_PORT=3001\nAPP_PORT=3004\nAPI_PORT=3003\n\n# Single Client (default setup)\n# Use the scripts/secrets.js CLI app to generate new secrets\nCLIENT_ID=dev-rp-CHANGE-FOR-PRODUCTION\nCLIENT_SECRET=dev-secret-CHANGE-FOR-PRODUCTION\nREDIRECT_URI=https://app.localtest.me/callback\n\n# Security (please change these for production!)\n# Use the scripts/secrets.js CLI app to generate new secrets\nCOOKIE_SECRET=dev-cookie-secret-CHANGE-FOR-PRODUCTION\nAPI_AUDIENCE=api://my-api\n```\n\n### Generating Secure Secrets\n\nFor production deployments, you should generate cryptographically secure secrets instead of using the default development values. We provide a CLI tool that makes this easy:\n\n```bash\n# Generate OAuth client credentials (CLIENT_ID and CLIENT_SECRET)\nnode scripts/secrets.js client\n\n# Generate client credentials with a custom prefix\nnode scripts/secrets.js client --prefix myapp\n\n# Generate application secrets (COOKIE_SECRET, etc.)\nnode scripts/secrets.js secrets\n\n# Generate JWKS (JSON Web Key Set) for token signing\nnode scripts/secrets.js jwks\n\n# Generate everything at once (client, secrets, and JWKS)\nnode scripts/secrets.js all\n\n# Show help\nnode scripts/secrets.js --help\n```\n\nThe tool generates:\n\n- **CLIENT_ID**: URL-safe random string (32 characters, or 24 + prefix)\n- **CLIENT_SECRET**: Cryptographically secure hex string (64 characters)\n- **COOKIE_SECRET**: Secure hex string (64 characters)\n- **JWKS**: RSA key pair (RS256, 2048 bits) formatted as a JSON Web Key Set\n\n**Security best practices:**\n\n- Never commit generated secrets to version control\n- Use different secrets for each environment (dev, staging, production)\n- Store production secrets in secure environment variables or secret managers (AWS Secrets Manager, HashiCorp Vault, etc.)\n- Rotate secrets regularly in production\n\n### Token Signing Keys (JWKS)\n\n#### Development vs Production\n\n**Development (default):**\n\n- If you don't set the `JWKS` environment variable, `oidc-provider` will automatically generate ephemeral (temporary) signing keys on startup\n- These keys use the default key ID (`kid`) of `\"keystore-CHANGE-ME\"`\n- Keys are regenerated every time the auth server restarts, invalidating all existing tokens\n- This is perfectly fine for local development since tokens have short lifetimes (1 hour)\n\n**Production (required):**\n\n- You **MUST** provide your own JWKS to prevent token invalidation on server restarts\n- Generate persistent signing keys with `node scripts/secrets.js jwks`\n- Store the JWKS in a secure environment variable or secret manager\n- The generated JWKS contains **PRIVATE KEY material** - treat it like any other secret!\n\n#### Why This Matters\n\nWhen the Authorization Server signs JWT tokens (ID tokens and access tokens), it uses a private key and includes the key ID (`kid`) in the JWT header. The Resource Server (API) uses the public key from the JWKS endpoint to verify token signatures.\n\n**Problems with ephemeral keys in production:**\n\n1. **Service restarts invalidate all tokens** - Users and applications must re-authenticate\n2. **Load balancing issues** - Different servers may have different keys\n3. **No key rotation strategy** - Can't implement proper cryptographic key rotation\n4. **Debugging difficulties** - The generic `kid` value doesn't help identify which key was used\n\n**Benefits of persistent keys:**\n\n1. **Tokens survive restarts** - Access/ID tokens remain valid across deployments\n2. **Proper key rotation** - You can add new keys while keeping old ones for validation\n3. **Better security** - Control your cryptographic material instead of relying on auto-generated keys\n4. **Meaningful key IDs** - Generated keys have unique identifiers like `key-abc123def456`\n\n#### Setting Up JWKS for Production\n\n```bash\n# Generate JWKS\nnode scripts/secrets.js jwks\n\n# Add the output to your .env file or secret manager\nJWKS='{\"keys\":[{\"kty\":\"RSA\",\"n\":\"...\",\"e\":\"AQAB\",\"d\":\"...\",\"kid\":\"key-abc123\",\"alg\":\"RS256\",\"use\":\"sig\"}]}'\n```\n\nThe generated JWKS includes:\n\n- **Public components** (`kty`, `n`, `e`, `kid`, `alg`, `use`) - Exposed at `/.well-known/jwks.json`\n- **Private components** (`d`, `p`, `q`, `dp`, `dq`, `qi`) - Used for signing, never exposed\n\n**Important notes:**\n\n- The JWKS is a JSON string, so wrap it in single quotes in your `.env` file\n- Never commit this to version control - it contains private key material\n- For production, store in AWS Secrets Manager, HashiCorp Vault, or similar\n- You can have multiple keys in the `keys` array for key rotation\n\n### Refresh Token Controls\n\nWant refresh tokens even without the `offline_access` scope? Add a per-client flag in `apps/auth/.env.clients.json`:\n\n```json\n[\n  {\n    \"client_id\": \"dev-rp\",\n    \"client_secret\": \"dev-secret\",\n    \"redirect_uris\": [\"https://app.localtest.me/callback\"],\n    \"post_logout_redirect_uris\": [\"https://app.localtest.me\"],\n    \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n    \"response_types\": [\"code\"],\n    \"token_endpoint_auth_method\": \"client_secret_basic\",\n    \"force_refresh_token\": true\n  }\n]\n```\n\n### Multiple Client Setup\n\nNeed to support multiple OAuth/OIDC clients? Create a `.env.clients.json` file in the auth app directory:\n\n```json\n[\n  {\n    \"client_id\": \"app1\",\n    \"client_secret\": \"secret1\",\n    \"redirect_uris\": [\"https://app1.example.com/callback\"],\n    \"post_logout_redirect_uris\": [\"https://app1.example.com\"],\n    \"grant_types\": [\"authorization_code\", \"refresh_token\"],\n    \"response_types\": [\"code\"],\n    \"token_endpoint_auth_method\": \"client_secret_basic\"\n  }\n]\n```\n\nCheck out `apps/auth/.env.clients.example.json` for a complete example, then copy it to `apps/auth/.env.clients.json` and customize as needed.\n\n**Client loading priority:**\n\n1. `OIDC_CLIENTS` environment variable (JSON string)\n2. `apps/auth/.env.clients.json` file\n3. Falls back to single client from `CLIENT_ID`/`CLIENT_SECRET` in `apps/auth/.env`\n\nIf you change `OP_ISSUER` or ports, remember to update the client registration (especially redirect URIs) and restart everything.\n\n## Security\n\nThis reference implementation includes security best practices that you should carry forward into your own implementations. See [SECURITY.md](SECURITY.md) for our security policy and vulnerability reporting guidelines.\n\n### Security Features\n\n| Feature | Implementation |\n| ------- | -------------- |\n| **Input Validation** | Zod schemas validate all external inputs with allow-list approach |\n| **Security Headers** | Helmet.js sets secure HTTP headers (CSP, HSTS, X-Frame-Options, etc.) |\n| **HTTPS Required** | All services communicate over TLS via Caddy |\n| **Secure Cookies** | Tokens stored in HTTP-only, secure, same-site cookies |\n| **Password Security** | Timing-safe comparison prevents timing attacks |\n| **JWT Validation** | Access tokens validated against JWKS with proper audience/issuer checks |\n\n### Dependency Security\n\nWe use automated tools to keep dependencies secure:\n\n- **Dependabot**: Weekly automated PRs for dependency updates\n- **npm audit**: Security vulnerability scanning in CI\n- **CodeQL**: Static analysis for security issues\n- **Trivy**: Container image vulnerability scanning\n\n### What You Should Add for Production\n\nThis demo intentionally omits some production requirements:\n\n- **Rate limiting** - Prevent brute force and DoS attacks\n- **Audit logging** - Track authentication events and API access\n- **Account lockout** - Lock accounts after repeated failed login attempts\n- **Password hashing** - Replace demo passwords with bcrypt/Argon2 hashed passwords\n- **Persistent storage** - Replace in-memory stores with a database\n\n## Docker and CI/CD\n\nThis project includes containerization and CI/CD support for secure, repeatable deployments.\n\n### Docker\n\nEach service has a production-ready Dockerfile with multi-stage builds:\n\n```bash\n# Build and run all services\ndocker compose up --build\n\n# Or build individual images\ndocker build -f apps/auth/Dockerfile -t core-exchange-auth .\ndocker build -f apps/api/Dockerfile -t core-exchange-api .\ndocker build -f apps/app/Dockerfile -t core-exchange-app .\n```\n\n**Dockerfile features:**\n\n- Multi-stage builds for minimal image size\n- Non-root user for security\n- Health checks for orchestration\n- Production dependencies only\n\nSee `docker-compose.yml` for local development and `docker-compose.prod.example.yml` as a production template.\n\n### GitHub Actions\n\n| Workflow | Purpose |\n| -------- | ------- |\n| `ci.yml` | Lint, build, and security audit on PRs |\n| `security.yml` | Weekly CodeQL and container scanning |\n| `deploy-*.yml` | Deploy services to infrastructure |\n\n### Recommended Branch Protection\n\nConfigure these settings on your main branch:\n\n- Require pull request reviews before merging\n- Require status checks to pass (CI workflow)\n- Require branches to be up to date\n- Enable Dependabot security alerts\n\n## JWT Access Tokens with Resource Indicators (RFC 8707)\n\nWe use **Resource Indicators for OAuth 2.0 (RFC 8707)** to issue **JWT access tokens** instead of opaque tokens. This matters if your API needs to validate tokens locally without a callback to the auth server.\n\n### Why Resource Indicators?\n\nIn `oidc-provider` v7+, the old `formats.AccessToken: \"jwt\"` config was deprecated. Now, if you want JWT access tokens, you need to use Resource Indicators (`resourceIndicators`). It's a bit more work, but it's the right way to do it.\n\n### How It Works\n\nThe `accessTokenFormat` property in `getResourceServerInfo()` determines what kind of token you get:\n\n```typescript\nresourceIndicators: {\n  enabled: true,\n  getResourceServerInfo: async (ctx, resourceIndicator, client) =\u003e {\n    return {\n      scope: \"openid profile email accounts:read\",\n      audience: \"api://my-api\",\n      accessTokenFormat: \"jwt\",  // This is the magic line—JWT instead of opaque\n      accessTokenTTL: 3600\n    };\n  }\n}\n```\n\n### The Gotcha: Three Places to Add `resource`\n\nHere's the tricky part—you need to include the `resource` parameter in **three different places**:\n\n1. **Authorization Request** (`/login` route):\n\n   ```typescript\n   const url = client.buildAuthorizationUrl(config, {\n     redirect_uri: REDIRECT_URI,\n     scope: \"openid email profile offline_access accounts:read\",\n     resource: \"api://my-api\"  // Stores resource in the authorization code\n   });\n   ```\n\n2. **Token Exchange Request** (`/callback` route):\n\n   ```typescript\n   const tokenSet = await client.authorizationCodeGrant(\n     config,\n     currentUrl,\n     { pkceCodeVerifier, expectedState },\n     { resource: \"api://my-api\" }  // Triggers JWT token issuance\n   );\n   ```\n\n3. **Refresh Token Request** (`/refresh` route):\n\n   ```typescript\n   const tokenSet = await client.refreshTokenGrant(\n     config,\n     refreshToken,\n     { resource: \"api://my-api\" }  // Ensures refreshed token is also JWT\n   );\n   ```\n\n### Why You Need All Three\n\nIf you forget to include `resource` in the token exchange (step 2), `oidc-provider` does something unexpected:\n\n- When `openid` scope is present and there's no `resource` parameter in the token request\n- It issues an **opaque token** for the UserInfo endpoint instead\n- This happens even if you configured `getResourceServerInfo` to return JWT format\n\nIt's a quirk in how `oidc-provider` resolves resources (see `lib/helpers/resolve_resource.js` if you're curious). The fix is simple—just include `resource` in all three places.\n\n### How to Check If It's Working\n\nTurn on debug logging to see what kind of tokens you're getting:\n\n```bash\nLOG_LEVEL=debug pnpm dev\n```\n\nLook for the token response log. JWT tokens look like this:\n\n```json\n{\n  \"accessTokenLength\": 719,        // JWT: ~700-900 characters\n  \"accessTokenParts\": 3,           // JWT: 3 parts (header.payload.signature)\n  \"accessTokenPrefix\": \"eyJhbGci\"  // JWT: Base64 \"eyJ\" prefix\n}\n```\n\nIf you see opaque tokens (wrong!), they'll be:\n\n- Length: 43 characters\n- Parts: 1 (single random string)\n- No Base64 prefix\n\n### Resource Indicator Format Rules\n\nResource indicators need to be absolute URIs. Here's what works and what doesn't:\n\n```typescript\n// ✅ Good\n\"api://my-api\"\n\"https://api.example.com\"\n\"https://api.example.com/v1\"\n\n// ❌ Bad\n\"my-api\"                           // Not an absolute URI\n\"https://api.example.com#section\"  // Can't have fragments (#)\n```\n\n### Quick Reference\n\n**In the Auth Server** ([apps/auth/src/index.ts](apps/auth/src/index.ts)):\n\n- `resourceIndicators.enabled`: Set to `true`\n- `resourceIndicators.defaultResource()`: Fallback resource when client doesn't specify one\n- `resourceIndicators.getResourceServerInfo()`: Returns `accessTokenFormat: \"jwt\"` (this is the important one)\n- `resourceIndicators.useGrantedResource()`: Allows reusing the resource from the auth request\n\n**In the Client** ([apps/app/src/index.ts](apps/app/src/index.ts)):\n\n- Authorization URL: Add `resource` parameter\n- Token exchange: Add `resource` in the 4th parameter\n- Refresh token: Add `resource` in the 3rd parameter\n\n## Debugging OAuth Flows\n\nWant to see what's happening under the hood? Add this to your `.env`:\n\n```bash\nLOG_LEVEL=debug\n```\n\nYou'll get detailed logs about:\n\n- **Authorization requests** - client_id, redirect_uri, scopes, response_type, state, resource\n- **Login attempts** - email provided, success/failure\n- **Consent flow** - grants created/reused, scopes granted, claims requested, resource indicators\n- **Token issuance** - refresh token decisions, resource server info, token format (JWT vs opaque)\n- **Account lookups** - subject lookups and claim retrieval\n\nWe use Pino for structured JSON logging. Here's what a log entry looks like:\n\n```json\n{\n  \"level\": 20,\n  \"time\": 1234567890,\n  \"name\": \"op\",\n  \"uid\": \"abc123\",\n  \"clientId\": \"dev-rp\",\n  \"requestedScopes\": [\"openid\", \"email\", \"profile\", \"offline_access\"],\n  \"msg\": \"GET /interaction/:uid - Interaction details loaded\"\n}\n```\n\n**Helpful log filters:**\n\n```bash\n# Watch for debug and error messages\npnpm dev | grep -i \"debug\\|error\"\n\n# Filter by specific OAuth events\npnpm dev | grep \"interaction\\|login\\|consent\\|issueRefreshToken\\|getResourceServerInfo\"\n```\n\n## What's Next\n\nThis is a demo implementation with in-memory storage. If you're taking this to production, you'll want to add:\n\n- **Persistent storage** - Swap in a PostgreSQL adapter for `oidc-provider` so authorization codes, sessions, and grants survive restarts\n- **Real user authentication** - Replace the in-memory user store with a proper database and password hashing (bcrypt or Argon2)\n- **End-to-end tests** - Add Playwright or Cypress tests to verify the complete authentication flow\n- **Production hardening** - Rate limiting, audit logging, and monitoring instrumentation\n- **Dynamic client registration** - Let clients register themselves via an API endpoint instead of manual config files\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fplaid%2Fcore-exchange-node-example","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fplaid%2Fcore-exchange-node-example","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fplaid%2Fcore-exchange-node-example/lists"}