Project 6: OAuth-Protected Integration App
Project 6: OAuth-Protected Integration App
Build a GitHub-connected ChatGPT app that uses OAuth 2.1 to access user repositories, list issues, and create pull requestsโmastering the complete authentication flow required for real-world integrations.
Table of Contents
- Project Overview
- Theoretical Foundation
- Solution Architecture
- Step-by-Step Implementation Guide
- Testing & Validation
- Common Pitfalls & Debugging
- Performance Optimization
- Security Considerations
- Production Deployment
- Variations & Extensions
- Additional Resources
- Exercises & Challenges
- Summary & Next Steps
1. Project Overview
What Youโll Build
A production-grade ChatGPT app that integrates with GitHub using OAuth 2.1 authentication. Users will be able to:
- Connect their GitHub account to ChatGPT
- View their repositories with details
- List and search issues
- Create new issues
- Generate pull requests
Live Example:
User: "Show my GitHub repositories"
ChatGPT: "To access your GitHub repositories, you'll need to connect your account."
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๐ Connect to GitHub โ
โ โ
โ This app needs access to: โ
โ โข View your repositories โ
โ โข Read and write issues โ
โ โข Create pull requests โ
โ โ
โ [Connect GitHub Account] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
[After OAuth flow completes...]
User: "Show my GitHub repositories"
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ๐ฆ Your Repositories (12) โ
โ โ
โ โญ my-awesome-project โ
โ TypeScript ยท Updated 2 hours ago ยท 3 open issues โ
โ โ
โ ๐ dotfiles โ
โ Shell ยท Updated 1 week ago ยท 0 issues โ
โ โ
โ [View on GitHub] [Show Issues] [Create Issue] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Project Metadata
- File: LEARN_CHATGPT_APPS_DEEP_DIVE.md
- Main Programming Language: TypeScript/Node.js
- Alternative Programming Languages: Python (FastAPI)
- Coolness Level: Level 4: Hardcore Tech Flex
- Business Potential: 4. The โOpen Coreโ Infrastructure
- Difficulty: Level 3: Advanced
- Knowledge Area: OAuth 2.1 / Authentication / Security
- Software or Tool: Passport.js, Auth0, or custom OAuth server
- Main Book: โOAuth 2 in Actionโ by Justin Richer & Antonio Sanso
Learning Objectives
By completing this project, you will:
- Master OAuth 2.1: Understand the complete authorization flow with PKCE
- Implement Secure Authentication: Build production-ready token validation
- Handle Identity Providers: Integrate with GitHub, Auth0, and custom OAuth servers
- Manage Token Lifecycle: Implement token refresh, expiration, and revocation
- Build Well-Known Endpoints: Expose OAuth metadata for discovery
- Implement DCR: Support Dynamic Client Registration
- Secure MCP Tools: Protect API endpoints with proper authentication
- Handle Auth Failures: Implement WWW-Authenticate responses
- Store Tokens Securely: Manage sensitive credentials
- Debug OAuth Flows: Troubleshoot common authentication issues
Prerequisites
- Completion of Projects 1-5
- Understanding of HTTP and REST APIs
- Basic knowledge of JWT (JSON Web Tokens)
- Familiarity with public/private key cryptography
- Experience with environment variables and secrets management
- Node.js/TypeScript or Python/FastAPI proficiency
Time Estimate
2-3 weeks for full implementation:
- Week 1: OAuth server setup, metadata endpoints, basic flow
- Week 2: Token validation, MCP tool protection, error handling
- Week 3: Integration testing, security hardening, production prep
2. Theoretical Foundation
2.1 OAuth 2.1: The Modern Authorization Standard
OAuth 2.1 is the updated OAuth specification that consolidates best practices from OAuth 2.0, including mandatory PKCE and removing implicit flow.
Why OAuth 2.1 for ChatGPT Apps?
- User Authorization, Not Authentication: OAuth delegates access without sharing passwords
- Scoped Permissions: Users grant specific capabilities (read repos, write issues)
- Token-Based: Short-lived access tokens with refresh capabilities
- Standard Protocol: Works across platforms and identity providers
- Security Best Practices: PKCE prevents authorization code interception
OAuth 2.1 vs OAuth 2.0 Key Differences:
OAuth 2.0 โ OAuth 2.1 Changes:
โโโ PKCE is now REQUIRED (was optional)
โโโ Implicit flow REMOVED (insecure)
โโโ Resource Owner Password flow REMOVED (anti-pattern)
โโโ Bearer token usage clarified
โโโ Refresh token rotation recommended
2.2 The OAuth 2.1 Authorization Flow
The complete flow has multiple steps involving the user, ChatGPT, your auth server, and your MCP server:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OAuth 2.1 Flow with PKCE โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ 1. User requests protected resource in ChatGPT โ
โ "Show my GitHub repositories" โ
โ โ
โ 2. ChatGPT โ MCP Server: Calls list_repositories tool โ
โ โ
โ 3. MCP Server โ ChatGPT: Returns 401 with WWW-Authenticate โ
โ { โ
โ "_meta": { โ
โ "mcp/www_authenticate": { โ
โ "scheme": "Bearer", โ
โ "realm": "github-integration" โ
โ } โ
โ } โ
โ } โ
โ โ
โ 4. ChatGPT discovers auth server from well-known endpoint โ
โ GET /.well-known/oauth-protected-resource โ
โ Response: { "authorization_servers": ["https://auth.app.com"] } โ
โ โ
โ 5. ChatGPT generates PKCE parameters: โ
โ code_verifier = random_string(43-128 chars) โ
โ code_challenge = base64url(sha256(code_verifier)) โ
โ code_challenge_method = "S256" โ
โ โ
โ 6. ChatGPT โ Auth Server: Authorization request โ
โ https://auth.app.com/authorize? โ
โ response_type=code& โ
โ client_id=CHATGPT_CLIENT_ID& โ
โ redirect_uri=https://chatgpt.com/oauth/callback& โ
โ scope=read:repos write:issues& โ
โ state=RANDOM_STATE& โ
โ code_challenge=CHALLENGE& โ
โ code_challenge_method=S256 โ
โ โ
โ 7. User โ Auth Server: User logs in and grants permissions โ
โ โ
โ 8. Auth Server โ ChatGPT: Redirect with authorization code โ
โ https://chatgpt.com/oauth/callback? โ
โ code=AUTH_CODE& โ
โ state=RANDOM_STATE โ
โ โ
โ 9. ChatGPT โ Auth Server: Token exchange with code_verifier โ
โ POST /token โ
โ { โ
โ "grant_type": "authorization_code", โ
โ "code": "AUTH_CODE", โ
โ "redirect_uri": "https://chatgpt.com/oauth/callback", โ
โ "client_id": "CHATGPT_CLIENT_ID", โ
โ "code_verifier": "ORIGINAL_VERIFIER" โ
โ } โ
โ โ
โ 10. Auth Server validates code_verifier: โ
โ base64url(sha256(code_verifier)) == stored_code_challenge โ
โ โ
โ 11. Auth Server โ ChatGPT: Access and refresh tokens โ
โ { โ
โ "access_token": "eyJhbG...", โ
โ "token_type": "Bearer", โ
โ "expires_in": 3600, โ
โ "refresh_token": "eyJhbG...", โ
โ "scope": "read:repos write:issues" โ
โ } โ
โ โ
โ 12. ChatGPT โ MCP Server: Retry request with token โ
โ Authorization: Bearer eyJhbG... โ
โ โ
โ 13. MCP Server validates token and returns data โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
2.3 PKCE (Proof Key for Code Exchange)
PKCE prevents authorization code interception attacks, especially important for public clients like ChatGPT.
How PKCE Works:
- Code Verifier Generation:
// Generate random string (43-128 characters) const codeVerifier = crypto.randomBytes(64).toString('base64url'); // Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" - Code Challenge Creation:
// SHA-256 hash of verifier, then base64url encode const hash = crypto.createHash('sha256').update(codeVerifier).digest(); const codeChallenge = hash.toString('base64url'); // Example: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" - Authorization Request (includes challenge):
GET /authorize? response_type=code& code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& code_challenge_method=S256& ... - Token Exchange (includes verifier):
POST /token { "grant_type": "authorization_code", "code": "AUTH_CODE", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" } - Server Verification:
// Server checks: SHA256(code_verifier) == stored_code_challenge const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); if (hash !== storedCodeChallenge) { throw new Error('Invalid code_verifier'); }
Why PKCE is Critical:
WITHOUT PKCE:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Attacker intercepts authorization code โ
โ โ โ
โ Attacker exchanges code for token (no verification) โ
โ โ โ
โ Attacker gains access to user's account โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
WITH PKCE:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Attacker intercepts authorization code โ
โ โ โ
โ Attacker tries to exchange code for token โ
โ โ โ
โ Server requires code_verifier (attacker doesn't have it) โ
โ โ โ
โ Token exchange FAILS - user protected โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
2.4 JWT (JSON Web Tokens)
Access tokens in OAuth 2.1 are typically JWTs, which are self-contained and verifiable.
JWT Structure:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ
โโโ Header (Algorithm & Token Type)
โ {
โ "alg": "RS256", // RSA with SHA-256
โ "typ": "JWT" // Token type
โ }
โ
โโโ Payload (Claims)
โ {
โ "sub": "user_123", // Subject (user ID)
โ "iss": "https://auth.app.com", // Issuer
โ "aud": "https://mcp.app.com", // Audience (your MCP server)
โ "exp": 1735689600, // Expiration timestamp
โ "iat": 1735686000, // Issued at
โ "scope": "read:repos write:issues",
โ "github_token": "gho_xxx" // Custom claim
โ }
โ
โโโ Signature (Verification)
RSASHA256(
base64urlEncode(header) + "." + base64urlEncode(payload),
privateKey
)
JWT Validation Steps:
function validateJWT(token: string) {
// 1. Decode without verification
const decoded = jwt.decode(token, { complete: true });
// 2. Get public key (from JWKS endpoint)
const publicKey = await getPublicKey(decoded.header.kid);
// 3. Verify signature
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.app.com',
audience: 'https://mcp.app.com'
});
// 4. Check expiration
if (verified.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
// 5. Check scopes
const scopes = verified.scope.split(' ');
if (!scopes.includes('read:repos')) {
throw new Error('Insufficient scope');
}
return verified;
}
2.5 Dynamic Client Registration (DCR)
DCR allows ChatGPT to register itself as an OAuth client dynamically, without manual configuration.
DCR Flow:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 1. ChatGPT discovers auth server from well-known โ
โ GET /.well-known/oauth-protected-resource โ
โ โ
โ 2. Server returns registration endpoint โ
โ { โ
โ "authorization_servers": ["https://auth.app.com"],โ
โ "registration_endpoint": โ
โ "https://auth.app.com/oauth/register" โ
โ } โ
โ โ
โ 3. ChatGPT registers as a client โ
โ POST https://auth.app.com/oauth/register โ
โ { โ
โ "client_name": "ChatGPT", โ
โ "redirect_uris": [ โ
โ "https://chatgpt.com/oauth/callback" โ
โ ], โ
โ "token_endpoint_auth_method": "none", โ
โ "grant_types": ["authorization_code", "refresh"], โ
โ "response_types": ["code"], โ
โ "scope": "read:repos write:issues read:user" โ
โ } โ
โ โ
โ 4. Server creates client and returns credentials โ
โ { โ
โ "client_id": "chatgpt_abc123", โ
โ "client_secret": null, // Public client โ
โ "redirect_uris": [...], โ
โ "token_endpoint_auth_method": "none" โ
โ } โ
โ โ
โ 5. ChatGPT stores client_id for future auth flows โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DCR Implementation:
// Server-side DCR endpoint
app.post('/oauth/register', async (req, res) => {
const registration = req.body;
// Validate registration request
validateRegistrationRequest(registration);
// Generate client_id
const clientId = `chatgpt_${crypto.randomUUID()}`;
// For public clients (ChatGPT), no client_secret
const client = {
client_id: clientId,
client_name: registration.client_name,
redirect_uris: registration.redirect_uris,
token_endpoint_auth_method: 'none', // Public client
grant_types: registration.grant_types,
response_types: registration.response_types,
scope: registration.scope
};
// Store client in database
await db.clients.create(client);
res.json(client);
});
2.6 Token Lifecycle Management
Token States:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Token Lifecycle โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ 1. ISSUED โ
โ Access token: expires in 1 hour โ
โ Refresh token: expires in 90 days โ
โ โ
โ 2. ACTIVE โ
โ Token is valid and can be used โ
โ Server validates on every request โ
โ โ
โ 3. EXPIRED โ
โ Access token expired (exp claim exceeded) โ
โ Must use refresh token to get new access token โ
โ โ
โ 4. REFRESHED โ
โ POST /token with grant_type=refresh_token โ
โ Old refresh token rotated (invalidated) โ
โ New access + refresh tokens issued โ
โ โ
โ 5. REVOKED โ
โ User disconnects app or token manually revoked โ
โ All tokens (access + refresh) invalidated โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Refresh Token Flow:
// When access token expires, ChatGPT automatically refreshes
async function refreshAccessToken(refreshToken: string) {
const response = await fetch('https://auth.app.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: 'chatgpt_abc123'
})
});
const tokens = await response.json();
// {
// "access_token": "new_access_token",
// "refresh_token": "new_refresh_token", // Token rotation
// "expires_in": 3600
// }
return tokens;
}
2.7 Well-Known Endpoints
OAuth discovery uses standardized .well-known endpoints:
Resource Server Metadata (/.well-known/oauth-protected-resource):
{
"resource": "https://mcp.app.com",
"authorization_servers": [
"https://auth.app.com"
],
"bearer_methods_supported": ["header"],
"scopes_supported": [
"read:repos",
"write:issues",
"read:user"
]
}
Authorization Server Metadata (/.well-known/oauth-authorization-server):
{
"issuer": "https://auth.app.com",
"authorization_endpoint": "https://auth.app.com/oauth/authorize",
"token_endpoint": "https://auth.app.com/oauth/token",
"registration_endpoint": "https://auth.app.com/oauth/register",
"jwks_uri": "https://auth.app.com/.well-known/jwks.json",
"scopes_supported": ["read:repos", "write:issues", "read:user"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"code_challenge_methods_supported": ["S256"]
}
JWKS (JSON Web Key Set) (/.well-known/jwks.json):
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key-2025-01",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
"e": "AQAB"
}
]
}
2.8 Security Best Practices
Critical Security Considerations:
- Always Validate Tokens:
// WRONG - Trusting token without validation const payload = JSON.parse(atob(token.split('.')[1])); // RIGHT - Full JWT verification const payload = jwt.verify(token, publicKey, options); - Use HTTPS Everywhere:
- All OAuth endpoints MUST use HTTPS
- Tokens transmitted over HTTPS only
- No mixed content (HTTP + HTTPS)
- Rotate Refresh Tokens:
// Invalidate old refresh token when issuing new one await db.refreshTokens.delete(oldRefreshToken); await db.refreshTokens.create(newRefreshToken); - Store Tokens Securely:
- Use encryption at rest for stored tokens
- Never log tokens in plaintext
- Use environment variables for secrets
- Implement Token Revocation:
// Revoke endpoint app.post('/oauth/revoke', async (req, res) => { const { token } = req.body; await db.tokens.delete({ token }); res.status(200).send(); }); - Validate Redirect URIs:
// Exact match only - no pattern matching if (redirectUri !== client.redirect_uris.find(uri => uri === redirectUri)) { throw new Error('Invalid redirect_uri'); } - Implement Rate Limiting:
// Prevent brute force attacks const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // 5 requests per window handler: (req, res) => { res.status(429).json({ error: 'too_many_requests' }); } }); app.use('/oauth/token', limiter);
3. Solution Architecture
3.1 High-Level Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OAuth-Protected ChatGPT App โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ ChatGPT โโโโโโโโโโโค Widget โโโโโโโโโโโค User โ โ
โ โ โ iframe โ (React/TS) โ clicks โ โ โ
โ โโโโโโโโฌโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โ
โ โ โ
โ โ HTTP + Bearer Token โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ MCP Server (Protected Resource) โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Token Validation Middleware โ โ โ
โ โ โ 1. Extract Bearer token โ โ โ
โ โ โ 2. Verify JWT signature with public key โ โ โ
โ โ โ 3. Check expiration, issuer, audience โ โ โ
โ โ โ 4. Validate scopes โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Protected MCP Tools โ โ โ
โ โ โ โข list_repositories() โ โ โ
โ โ โ โข get_repository_issues(repo_id) โ โ โ
โ โ โ โข create_issue(repo_id, title, body) โ โ โ
โ โ โ โข create_pull_request(repo_id, branch, title) โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ OAuth Metadata Endpoint โ โ โ
โ โ โ GET /.well-known/oauth-protected-resource โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โ 401 Unauthorized โ triggers OAuth flow โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Authorization Server โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ DCR Endpoint โ โ โ
โ โ โ POST /oauth/register โ โ โ
โ โ โ โ Returns client_id โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Authorization Endpoint โ โ โ
โ โ โ GET /oauth/authorize โ โ โ
โ โ โ โ User login & consent UI โ โ โ
โ โ โ โ Store code_challenge (PKCE) โ โ โ
โ โ โ โ Redirect with authorization code โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Token Endpoint โ โ โ
โ โ โ POST /oauth/token โ โ โ
โ โ โ โ Validate code_verifier (PKCE) โ โ โ
โ โ โ โ Exchange code for access token โ โ โ
โ โ โ โ Return JWT + refresh token โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ JWKS Endpoint โ โ โ
โ โ โ GET /.well-known/jwks.json โ โ โ
โ โ โ โ Public keys for JWT verification โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ OAuth Metadata Endpoint โ โ โ
โ โ โ GET /.well-known/oauth-authorization-server โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โ Calls GitHub API with user's token โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ GitHub API โ โ
โ โ โข GET /user/repos โ โ
โ โ โข GET /repos/:owner/:repo/issues โ โ
โ โ โข POST /repos/:owner/:repo/issues โ โ
โ โ โข POST /repos/:owner/:repo/pulls โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
3.2 Component Breakdown
1. MCP Server (Resource Server):
- Exposes protected tools that require authentication
- Validates JWT on every request
- Returns 401 with WWW-Authenticate when unauthenticated
- Serves OAuth metadata for discovery
2. Authorization Server:
- Handles user authentication and consent
- Issues access and refresh tokens
- Implements PKCE validation
- Supports Dynamic Client Registration
- Manages token lifecycle
3. Widget (UI):
- Displays data from protected tools
- Shows โConnectโ button when unauthenticated
- Handles OAuth callback (managed by ChatGPT)
3.3 Data Flow
First Request (Unauthenticated):
1. User: "Show my repos"
2. ChatGPT โ MCP: GET /tools/list_repositories
Headers: (no Authorization)
3. MCP โ ChatGPT: 401 Unauthorized
{
"error": "unauthorized",
"_meta": {
"mcp/www_authenticate": {
"scheme": "Bearer",
"realm": "github-integration"
}
}
}
4. ChatGPT โ MCP: GET /.well-known/oauth-protected-resource
5. MCP โ ChatGPT: OAuth metadata
{
"resource": "https://mcp.app.com",
"authorization_servers": ["https://auth.app.com"]
}
6. ChatGPT โ User: Shows "Connect" button in widget
OAuth Authorization:
7. User clicks "Connect"
8. ChatGPT โ Auth Server: GET /.well-known/oauth-authorization-server
โ Discovers endpoints
9. ChatGPT โ Auth Server: POST /oauth/register (if DCR enabled)
โ Gets client_id
10. ChatGPT generates PKCE:
code_verifier = random()
code_challenge = SHA256(code_verifier)
11. ChatGPT โ Auth Server: Redirect to /oauth/authorize
?response_type=code
&client_id=chatgpt_abc123
&redirect_uri=https://chatgpt.com/oauth/callback
&scope=read:repos write:issues
&state=xyz
&code_challenge=ABC123
&code_challenge_method=S256
12. User โ Auth Server: Logs in with GitHub OAuth
13. Auth Server: Shows consent screen
"ChatGPT wants to access:
โข View your repositories
โข Create and edit issues"
14. User: Clicks "Authorize"
15. Auth Server: Stores code_challenge
16. Auth Server โ ChatGPT: Redirect to callback
https://chatgpt.com/oauth/callback?code=ABC&state=xyz
17. ChatGPT โ Auth Server: POST /oauth/token
{
"grant_type": "authorization_code",
"code": "ABC",
"redirect_uri": "https://chatgpt.com/oauth/callback",
"client_id": "chatgpt_abc123",
"code_verifier": "ORIGINAL_VERIFIER"
}
18. Auth Server: Validates PKCE
SHA256(code_verifier) == stored_code_challenge
19. Auth Server โ ChatGPT: Access token
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbG...",
"scope": "read:repos write:issues"
}
Authenticated Request:
20. ChatGPT โ MCP: GET /tools/list_repositories
Headers: Authorization: Bearer eyJhbG...
21. MCP: Validates JWT
- Verify signature with public key
- Check expiration
- Validate issuer, audience
- Check scopes
22. MCP: Extracts github_token from JWT claims
23. MCP โ GitHub API: GET /user/repos
Headers: Authorization: Bearer gho_xxx
24. GitHub โ MCP: Repository list
25. MCP โ ChatGPT: Tool result + widget
{
"structuredContent": { "repositories": [...] },
"uiComponent": { "type": "iframe", "url": "..." }
}
26. ChatGPT: Renders widget with repository list
3.4 Database Schema
Clients Table (for DCR):
CREATE TABLE oauth_clients (
client_id VARCHAR(255) PRIMARY KEY,
client_name VARCHAR(255) NOT NULL,
client_secret VARCHAR(255), -- NULL for public clients
redirect_uris TEXT[] NOT NULL,
token_endpoint_auth_method VARCHAR(50) NOT NULL,
grant_types TEXT[] NOT NULL,
response_types TEXT[] NOT NULL,
scope VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Authorization Codes Table (temporary):
CREATE TABLE authorization_codes (
code VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) NOT NULL REFERENCES oauth_clients(client_id),
user_id VARCHAR(255) NOT NULL,
redirect_uri VARCHAR(500) NOT NULL,
scope VARCHAR(500) NOT NULL,
code_challenge VARCHAR(255) NOT NULL,
code_challenge_method VARCHAR(10) NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_expires_at (expires_at)
);
Access Tokens Table:
CREATE TABLE access_tokens (
token_id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) NOT NULL REFERENCES oauth_clients(client_id),
user_id VARCHAR(255) NOT NULL,
scope VARCHAR(500) NOT NULL,
github_token VARCHAR(500) NOT NULL, -- Encrypted
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
);
Refresh Tokens Table:
CREATE TABLE refresh_tokens (
token_id VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255) NOT NULL REFERENCES oauth_clients(client_id),
user_id VARCHAR(255) NOT NULL,
scope VARCHAR(500) NOT NULL,
github_refresh_token VARCHAR(500), -- Encrypted
expires_at TIMESTAMP NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_revoked (revoked)
);
Users Table:
CREATE TABLE users (
user_id VARCHAR(255) PRIMARY KEY,
github_user_id VARCHAR(255) UNIQUE NOT NULL,
github_username VARCHAR(255) NOT NULL,
email VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
3.5 File Structure
oauth-protected-app/
โโโ packages/
โ โโโ auth-server/ # Authorization server
โ โ โโโ src/
โ โ โ โโโ server.ts # Express app
โ โ โ โโโ routes/
โ โ โ โ โโโ authorize.ts # GET /oauth/authorize
โ โ โ โ โโโ token.ts # POST /oauth/token
โ โ โ โ โโโ register.ts # POST /oauth/register
โ โ โ โ โโโ jwks.ts # GET /.well-known/jwks.json
โ โ โ โ โโโ metadata.ts # GET /.well-known/oauth-authorization-server
โ โ โ โโโ middleware/
โ โ โ โ โโโ validatePKCE.ts
โ โ โ โ โโโ rateLimit.ts
โ โ โ โโโ services/
โ โ โ โ โโโ tokenService.ts
โ โ โ โ โโโ githubService.ts
โ โ โ โ โโโ cryptoService.ts
โ โ โ โโโ models/
โ โ โ โ โโโ Client.ts
โ โ โ โ โโโ AuthorizationCode.ts
โ โ โ โ โโโ AccessToken.ts
โ โ โ โ โโโ RefreshToken.ts
โ โ โ โโโ views/
โ โ โ โโโ login.ejs
โ โ โ โโโ consent.ejs
โ โ โโโ keys/
โ โ โ โโโ private.key # RSA private key
โ โ โ โโโ public.key # RSA public key
โ โ โโโ package.json
โ โ โโโ tsconfig.json
โ โ
โ โโโ mcp-server/ # MCP resource server
โ โ โโโ src/
โ โ โ โโโ server.ts # FastMCP server
โ โ โ โโโ middleware/
โ โ โ โ โโโ validateToken.ts
โ โ โ โโโ tools/
โ โ โ โ โโโ listRepositories.ts
โ โ โ โ โโโ getIssues.ts
โ โ โ โ โโโ createIssue.ts
โ โ โ โ โโโ createPullRequest.ts
โ โ โ โโโ routes/
โ โ โ โ โโโ wellKnown.ts # /.well-known/oauth-protected-resource
โ โ โ โโโ services/
โ โ โ โโโ githubClient.ts
โ โ โโโ package.json
โ โ โโโ tsconfig.json
โ โ
โ โโโ widget/ # UI widget
โ โโโ src/
โ โ โโโ App.tsx # Main component
โ โ โโโ components/
โ โ โ โโโ ConnectButton.tsx
โ โ โ โโโ RepositoryList.tsx
โ โ โ โโโ IssueList.tsx
โ โ โ โโโ CreateIssueForm.tsx
โ โ โโโ main.tsx
โ โโโ index.html
โ โโโ vite.config.ts
โ โโโ package.json
โ โโโ tsconfig.json
โ
โโโ docker-compose.yml # Local development
โโโ .env.example # Environment variables template
โโโ README.md
4. Step-by-Step Implementation Guide
Step 1: Project Setup
1.1 Initialize the Monorepo:
mkdir oauth-protected-app
cd oauth-protected-app
# Initialize root package.json
npm init -y
# Create workspaces
mkdir -p packages/{auth-server,mcp-server,widget}
# Set up workspace in root package.json
Root package.json:
{
"name": "oauth-protected-app",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"dev:auth": "npm run dev --workspace=auth-server",
"dev:mcp": "npm run dev --workspace=mcp-server",
"dev:widget": "npm run dev --workspace=widget",
"dev": "concurrently \"npm:dev:*\"",
"build": "npm run build --workspaces"
},
"devDependencies": {
"concurrently": "^8.2.2",
"typescript": "^5.3.3"
}
}
1.2 Generate RSA Keys for JWT Signing:
cd packages/auth-server
mkdir keys
# Generate private key
openssl genpkey -algorithm RSA -out keys/private.key -pkeyopt rsa_keygen_bits:2048
# Extract public key
openssl rsa -pubout -in keys/private.key -out keys/public.key
# Add keys to .gitignore
echo "keys/*.key" >> ../../.gitignore
1.3 Set Up Environment Variables:
.env.example:
# Auth Server
AUTH_SERVER_PORT=3000
AUTH_SERVER_URL=https://auth.localhost:3000
GITHUB_CLIENT_ID=your_github_oauth_app_client_id
GITHUB_CLIENT_SECRET=your_github_oauth_app_secret
GITHUB_CALLBACK_URL=https://auth.localhost:3000/github/callback
SESSION_SECRET=your_random_session_secret
DATABASE_URL=postgresql://user:pass@localhost:5432/oauth_app
# MCP Server
MCP_SERVER_PORT=3001
MCP_SERVER_URL=https://mcp.localhost:3001
AUTH_SERVER_JWKS_URL=https://auth.localhost:3000/.well-known/jwks.json
# Widget
VITE_MCP_SERVER_URL=https://mcp.localhost:3001
1.4 Create GitHub OAuth App:
- Go to GitHub Settings โ Developer settings โ OAuth Apps
- Click โNew OAuth Appโ
- Fill in:
- Application name: โChatGPT GitHub Integration (Dev)โ
- Homepage URL:
https://auth.localhost:3000 - Authorization callback URL:
https://auth.localhost:3000/github/callback
- Copy Client ID and Client Secret to
.env
Step 2: Build the Authorization Server
2.1 Initialize Auth Server:
cd packages/auth-server
npm init -y
npm install express express-session ejs
npm install passport passport-github2
npm install jsonwebtoken jose
npm install pg drizzle-orm
npm install dotenv cors
npm install --save-dev @types/express @types/node @types/passport tsx nodemon
package.json:
{
"name": "auth-server",
"scripts": {
"dev": "nodemon --watch src --exec tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
2.2 Create Database Schema:
src/db/schema.ts:
import { pgTable, varchar, text, timestamp, boolean, pgEnum } from 'drizzle-orm/pg-core';
export const clients = pgTable('oauth_clients', {
clientId: varchar('client_id', { length: 255 }).primaryKey(),
clientName: varchar('client_name', { length: 255 }).notNull(),
clientSecret: varchar('client_secret', { length: 255 }),
redirectUris: text('redirect_uris').array().notNull(),
tokenEndpointAuthMethod: varchar('token_endpoint_auth_method', { length: 50 }).notNull(),
grantTypes: text('grant_types').array().notNull(),
responseTypes: text('response_types').array().notNull(),
scope: varchar('scope', { length: 500 }).notNull(),
createdAt: timestamp('created_at').defaultNow()
});
export const authorizationCodes = pgTable('authorization_codes', {
code: varchar('code', { length: 255 }).primaryKey(),
clientId: varchar('client_id', { length: 255 }).notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
redirectUri: varchar('redirect_uri', { length: 500 }).notNull(),
scope: varchar('scope', { length: 500 }).notNull(),
codeChallenge: varchar('code_challenge', { length: 255 }).notNull(),
codeChallengeMethod: varchar('code_challenge_method', { length: 10 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow()
});
export const accessTokens = pgTable('access_tokens', {
tokenId: varchar('token_id', { length: 255 }).primaryKey(),
clientId: varchar('client_id', { length: 255 }).notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
scope: varchar('scope', { length: 500 }).notNull(),
githubToken: varchar('github_token', { length: 500 }).notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow()
});
export const refreshTokens = pgTable('refresh_tokens', {
tokenId: varchar('token_id', { length: 255 }).primaryKey(),
clientId: varchar('client_id', { length: 255 }).notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
scope: varchar('scope', { length: 500 }).notNull(),
githubRefreshToken: varchar('github_refresh_token', { length: 500 }),
expiresAt: timestamp('expires_at').notNull(),
revoked: boolean('revoked').default(false),
createdAt: timestamp('created_at').defaultNow()
});
export const users = pgTable('users', {
userId: varchar('user_id', { length: 255 }).primaryKey(),
githubUserId: varchar('github_user_id', { length: 255 }).unique().notNull(),
githubUsername: varchar('github_username', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow()
});
2.3 Token Service:
src/services/tokenService.ts:
import * as jose from 'jose';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
const PRIVATE_KEY = fs.readFileSync(path.join(__dirname, '../../keys/private.key'), 'utf-8');
const PUBLIC_KEY = fs.readFileSync(path.join(__dirname, '../../keys/public.key'), 'utf-8');
export class TokenService {
// Generate access token (JWT)
async generateAccessToken(payload: {
userId: string;
clientId: string;
scope: string;
githubToken: string;
}): Promise<string> {
const privateKey = await jose.importPKCS8(PRIVATE_KEY, 'RS256');
const jwt = await new jose.SignJWT({
sub: payload.userId,
scope: payload.scope,
github_token: payload.githubToken
})
.setProtectedHeader({ alg: 'RS256', kid: 'key-2025-01' })
.setIssuer(process.env.AUTH_SERVER_URL!)
.setAudience(process.env.MCP_SERVER_URL!)
.setExpirationTime('1h')
.setIssuedAt()
.sign(privateKey);
return jwt;
}
// Generate refresh token (opaque)
generateRefreshToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
// Validate JWT
async validateAccessToken(token: string) {
const publicKey = await jose.importSPKI(PUBLIC_KEY, 'RS256');
const { payload } = await jose.jwtVerify(token, publicKey, {
issuer: process.env.AUTH_SERVER_URL,
audience: process.env.MCP_SERVER_URL
});
return payload;
}
// Get JWKS (public keys for verification)
async getJWKS() {
const publicKey = await jose.importSPKI(PUBLIC_KEY, 'RS256');
const jwk = await jose.exportJWK(publicKey);
return {
keys: [
{
...jwk,
kid: 'key-2025-01',
use: 'sig',
alg: 'RS256'
}
]
};
}
}
2.4 PKCE Validation Middleware:
src/middleware/validatePKCE.ts:
import crypto from 'crypto';
export function validatePKCE(codeVerifier: string, codeChallenge: string, method: string): boolean {
if (method !== 'S256') {
throw new Error('Only S256 code_challenge_method supported');
}
// Compute SHA-256 hash of verifier
const hash = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Compare with stored challenge
return hash === codeChallenge;
}
2.5 Dynamic Client Registration Endpoint:
src/routes/register.ts:
import { Router } from 'express';
import crypto from 'crypto';
import { db } from '../db';
import { clients } from '../db/schema';
const router = Router();
router.post('/oauth/register', async (req, res) => {
try {
const {
client_name,
redirect_uris,
token_endpoint_auth_method = 'none',
grant_types = ['authorization_code', 'refresh_token'],
response_types = ['code'],
scope
} = req.body;
// Validate request
if (!client_name || !redirect_uris || !Array.isArray(redirect_uris)) {
return res.status(400).json({ error: 'invalid_request' });
}
// Generate client_id
const clientId = `chatgpt_${crypto.randomUUID()}`;
// For public clients, no client_secret
const clientSecret = token_endpoint_auth_method === 'none'
? null
: crypto.randomBytes(32).toString('base64url');
// Store client
const client = {
clientId,
clientName: client_name,
clientSecret,
redirectUris: redirect_uris,
tokenEndpointAuthMethod: token_endpoint_auth_method,
grantTypes: grant_types,
responseTypes: response_types,
scope
};
await db.insert(clients).values(client);
res.json({
client_id: clientId,
client_secret: clientSecret,
client_name: client_name,
redirect_uris,
token_endpoint_auth_method,
grant_types,
response_types,
scope
});
} catch (error) {
console.error('DCR error:', error);
res.status(500).json({ error: 'server_error' });
}
});
export default router;
2.6 Authorization Endpoint:
src/routes/authorize.ts:
import { Router } from 'express';
import crypto from 'crypto';
import { db } from '../db';
import { clients, authorizationCodes } from '../db/schema';
import { eq } from 'drizzle-orm';
const router = Router();
router.get('/oauth/authorize', async (req, res) => {
const {
response_type,
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method
} = req.query;
// Validate parameters
if (response_type !== 'code') {
return res.redirect(`${redirect_uri}?error=unsupported_response_type&state=${state}`);
}
if (!code_challenge || code_challenge_method !== 'S256') {
return res.redirect(`${redirect_uri}?error=invalid_request&state=${state}`);
}
// Verify client_id and redirect_uri
const [client] = await db.select().from(clients).where(eq(clients.clientId, client_id as string));
if (!client || !client.redirectUris.includes(redirect_uri as string)) {
return res.status(400).send('Invalid client_id or redirect_uri');
}
// Check if user is authenticated (via GitHub OAuth)
if (!req.isAuthenticated()) {
// Store OAuth params in session and redirect to GitHub login
req.session.oauthParams = {
response_type,
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method
};
return res.redirect('/auth/github');
}
// User is authenticated - show consent screen
res.render('consent', {
client,
scope: (scope as string).split(' '),
user: req.user
});
});
// Consent handler
router.post('/oauth/authorize/consent', async (req, res) => {
const { allow } = req.body;
const oauthParams = req.session.oauthParams;
if (!allow) {
return res.redirect(`${oauthParams.redirect_uri}?error=access_denied&state=${oauthParams.state}`);
}
// Generate authorization code
const code = crypto.randomBytes(32).toString('base64url');
// Store authorization code with PKCE challenge
await db.insert(authorizationCodes).values({
code,
clientId: oauthParams.client_id,
userId: req.user.id,
redirectUri: oauthParams.redirect_uri,
scope: oauthParams.scope,
codeChallenge: oauthParams.code_challenge,
codeChallengeMethod: oauthParams.code_challenge_method,
expiresAt: new Date(Date.now() + 10 * 60 * 1000) // 10 minutes
});
// Redirect back to client with code
res.redirect(`${oauthParams.redirect_uri}?code=${code}&state=${oauthParams.state}`);
});
export default router;
src/views/consent.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Authorize Application</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 500px;
margin: 50px auto;
padding: 20px;
}
.app-info {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.permissions {
list-style: none;
padding: 0;
}
.permissions li {
padding: 10px;
border-bottom: 1px solid #eee;
}
.permissions li:before {
content: "โ ";
color: green;
}
button {
padding: 12px 24px;
margin: 10px 5px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.allow {
background: #0066cc;
color: white;
}
.deny {
background: #666;
color: white;
}
</style>
</head>
<body>
<div class="app-info">
<h2><%= client.clientName %> wants to access your account</h2>
<p>This will allow <strong><%= client.clientName %></strong> to:</p>
<ul class="permissions">
<% scope.forEach(function(permission) { %>
<li><%= permission %></li>
<% }); %>
</ul>
</div>
<form method="POST" action="/oauth/authorize/consent">
<button type="submit" name="allow" value="true" class="allow">
Authorize
</button>
<button type="submit" name="allow" value="false" class="deny">
Deny
</button>
</form>
</body>
</html>
2.7 Token Endpoint:
src/routes/token.ts:
import { Router } from 'express';
import { db } from '../db';
import { authorizationCodes, accessTokens, refreshTokens, clients } from '../db/schema';
import { eq, and, gt } from 'drizzle-orm';
import { TokenService } from '../services/tokenService';
import { validatePKCE } from '../middleware/validatePKCE';
const router = Router();
const tokenService = new TokenService();
router.post('/oauth/token', async (req, res) => {
const { grant_type } = req.body;
if (grant_type === 'authorization_code') {
return handleAuthorizationCodeGrant(req, res);
} else if (grant_type === 'refresh_token') {
return handleRefreshTokenGrant(req, res);
} else {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
});
async function handleAuthorizationCodeGrant(req, res) {
const {
code,
redirect_uri,
client_id,
code_verifier
} = req.body;
// Verify client
const [client] = await db.select().from(clients).where(eq(clients.clientId, client_id));
if (!client) {
return res.status(401).json({ error: 'invalid_client' });
}
// Get authorization code
const [authCode] = await db
.select()
.from(authorizationCodes)
.where(
and(
eq(authorizationCodes.code, code),
eq(authorizationCodes.clientId, client_id),
gt(authorizationCodes.expiresAt, new Date())
)
);
if (!authCode) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Validate PKCE
try {
const isValid = validatePKCE(
code_verifier,
authCode.codeChallenge,
authCode.codeChallengeMethod
);
if (!isValid) {
return res.status(400).json({ error: 'invalid_grant' });
}
} catch (error) {
return res.status(400).json({ error: 'invalid_request' });
}
// Validate redirect_uri
if (redirect_uri !== authCode.redirectUri) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Delete authorization code (one-time use)
await db.delete(authorizationCodes).where(eq(authorizationCodes.code, code));
// Get user's GitHub token from session or database
const githubToken = req.user.githubAccessToken; // Stored during GitHub OAuth
// Generate access token (JWT)
const accessToken = await tokenService.generateAccessToken({
userId: authCode.userId,
clientId: client_id,
scope: authCode.scope,
githubToken
});
// Generate refresh token
const refreshToken = tokenService.generateRefreshToken();
// Store tokens
const tokenId = crypto.randomUUID();
await db.insert(accessTokens).values({
tokenId,
clientId: client_id,
userId: authCode.userId,
scope: authCode.scope,
githubToken,
expiresAt: new Date(Date.now() + 60 * 60 * 1000) // 1 hour
});
await db.insert(refreshTokens).values({
tokenId: crypto.randomUUID(),
clientId: client_id,
userId: authCode.userId,
scope: authCode.scope,
githubRefreshToken: null, // GitHub doesn't provide refresh tokens for web flow
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
revoked: false
});
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
scope: authCode.scope
});
}
async function handleRefreshTokenGrant(req, res) {
const { refresh_token, client_id } = req.body;
// Verify client
const [client] = await db.select().from(clients).where(eq(clients.clientId, client_id));
if (!client) {
return res.status(401).json({ error: 'invalid_client' });
}
// Get refresh token
const [storedRefreshToken] = await db
.select()
.from(refreshTokens)
.where(
and(
eq(refreshTokens.tokenId, refresh_token),
eq(refreshTokens.clientId, client_id),
eq(refreshTokens.revoked, false),
gt(refreshTokens.expiresAt, new Date())
)
);
if (!storedRefreshToken) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Revoke old refresh token (token rotation)
await db
.update(refreshTokens)
.set({ revoked: true })
.where(eq(refreshTokens.tokenId, refresh_token));
// Generate new tokens
const newAccessToken = await tokenService.generateAccessToken({
userId: storedRefreshToken.userId,
clientId: client_id,
scope: storedRefreshToken.scope,
githubToken: '...' // Retrieve from database
});
const newRefreshToken = tokenService.generateRefreshToken();
// Store new tokens
// ... (similar to authorization_code grant)
res.json({
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken,
scope: storedRefreshToken.scope
});
}
export default router;
2.8 Metadata Endpoints:
src/routes/metadata.ts:
import { Router } from 'express';
import { TokenService } from '../services/tokenService';
const router = Router();
const tokenService = new TokenService();
// Authorization Server Metadata
router.get('/.well-known/oauth-authorization-server', (req, res) => {
res.json({
issuer: process.env.AUTH_SERVER_URL,
authorization_endpoint: `${process.env.AUTH_SERVER_URL}/oauth/authorize`,
token_endpoint: `${process.env.AUTH_SERVER_URL}/oauth/token`,
registration_endpoint: `${process.env.AUTH_SERVER_URL}/oauth/register`,
jwks_uri: `${process.env.AUTH_SERVER_URL}/.well-known/jwks.json`,
scopes_supported: ['read:repos', 'write:issues', 'read:user'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
code_challenge_methods_supported: ['S256']
});
});
// JWKS Endpoint
router.get('/.well-known/jwks.json', async (req, res) => {
const jwks = await tokenService.getJWKS();
res.json(jwks);
});
export default router;
2.9 GitHub OAuth Integration:
src/config/passport.ts:
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { db } from '../db';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackURL: process.env.GITHUB_CALLBACK_URL!
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
let [user] = await db
.select()
.from(users)
.where(eq(users.githubUserId, profile.id));
if (!user) {
[user] = await db.insert(users).values({
userId: crypto.randomUUID(),
githubUserId: profile.id,
githubUsername: profile.username,
email: profile.emails?.[0]?.value
}).returning();
}
// Store GitHub access token in user session
user.githubAccessToken = accessToken;
done(null, user);
} catch (error) {
done(error);
}
}
));
passport.serializeUser((user: any, done) => {
done(null, user.userId);
});
passport.deserializeUser(async (userId: string, done) => {
try {
const [user] = await db.select().from(users).where(eq(users.userId, userId));
done(null, user);
} catch (error) {
done(error);
}
});
src/routes/auth.ts:
import { Router } from 'express';
import passport from 'passport';
const router = Router();
router.get('/auth/github', passport.authenticate('github', {
scope: ['user:email', 'repo']
}));
router.get('/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
// Redirect back to authorization endpoint
const params = req.session.oauthParams;
const query = new URLSearchParams(params).toString();
res.redirect(`/oauth/authorize?${query}`);
}
);
export default router;
2.10 Main Server:
src/server.ts:
import express from 'express';
import session from 'express-session';
import passport from 'passport';
import cors from 'cors';
import dotenv from 'dotenv';
import registerRouter from './routes/register';
import authorizeRouter from './routes/authorize';
import tokenRouter from './routes/token';
import metadataRouter from './routes/metadata';
import authRouter from './routes/auth';
import './config/passport';
dotenv.config();
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production' }
}));
app.use(passport.initialize());
app.use(passport.session());
// Routes
app.use(registerRouter);
app.use(authorizeRouter);
app.use(tokenRouter);
app.use(metadataRouter);
app.use(authRouter);
const PORT = process.env.AUTH_SERVER_PORT || 3000;
app.listen(PORT, () => {
console.log(`Auth server running on ${process.env.AUTH_SERVER_URL}`);
});
Step 3: Build the MCP Server
3.1 Initialize MCP Server:
cd packages/mcp-server
npm init -y
npm install fastmcp
npm install jose
npm install @octokit/rest
npm install dotenv
npm install --save-dev @types/node tsx nodemon typescript
3.2 Token Validation Middleware:
src/middleware/validateToken.ts:
import * as jose from 'jose';
import { Request, Response, NextFunction } from 'express';
let cachedJWKS: jose.JSONWebKeySet | null = null;
export async function validateToken(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'unauthorized',
_meta: {
'mcp/www_authenticate': {
scheme: 'Bearer',
realm: 'github-integration'
}
}
});
}
const token = authHeader.substring(7);
try {
// Fetch JWKS if not cached
if (!cachedJWKS) {
const response = await fetch(process.env.AUTH_SERVER_JWKS_URL!);
cachedJWKS = await response.json();
}
const JWKS = jose.createRemoteJWKSet(new URL(process.env.AUTH_SERVER_JWKS_URL!));
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer: process.env.AUTH_SERVER_URL,
audience: process.env.MCP_SERVER_URL
});
// Check expiration
if (payload.exp && payload.exp < Date.now() / 1000) {
return res.status(401).json({
error: 'token_expired',
_meta: {
'mcp/www_authenticate': {
scheme: 'Bearer',
realm: 'github-integration',
error: 'invalid_token',
error_description: 'Token expired'
}
}
});
}
// Attach user info to request
req.user = {
userId: payload.sub as string,
scope: (payload.scope as string).split(' '),
githubToken: payload.github_token as string
};
next();
} catch (error) {
console.error('Token validation error:', error);
return res.status(401).json({
error: 'invalid_token',
_meta: {
'mcp/www_authenticate': {
scheme: 'Bearer',
realm: 'github-integration',
error: 'invalid_token'
}
}
});
}
}
// Scope validation middleware
export function requireScope(...requiredScopes: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const userScopes = req.user?.scope || [];
const hasScope = requiredScopes.every(scope => userScopes.includes(scope));
if (!hasScope) {
return res.status(403).json({
error: 'insufficient_scope',
required_scope: requiredScopes.join(' ')
});
}
next();
};
}
3.3 GitHub Client Service:
src/services/githubClient.ts:
import { Octokit } from '@octokit/rest';
export class GitHubClient {
private octokit: Octokit;
constructor(accessToken: string) {
this.octokit = new Octokit({ auth: accessToken });
}
async listRepositories() {
const { data } = await this.octokit.repos.listForAuthenticatedUser({
sort: 'updated',
per_page: 100
});
return data.map(repo => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description,
language: repo.language,
stargazers_count: repo.stargazers_count,
forks_count: repo.forks_count,
open_issues_count: repo.open_issues_count,
updated_at: repo.updated_at,
html_url: repo.html_url,
private: repo.private
}));
}
async getRepositoryIssues(owner: string, repo: string, state: 'open' | 'closed' | 'all' = 'open') {
const { data } = await this.octokit.issues.listForRepo({
owner,
repo,
state,
per_page: 50
});
return data.map(issue => ({
id: issue.id,
number: issue.number,
title: issue.title,
body: issue.body,
state: issue.state,
user: issue.user?.login,
labels: issue.labels.map((label: any) => label.name),
created_at: issue.created_at,
updated_at: issue.updated_at,
html_url: issue.html_url
}));
}
async createIssue(owner: string, repo: string, title: string, body?: string, labels?: string[]) {
const { data } = await this.octokit.issues.create({
owner,
repo,
title,
body,
labels
});
return {
id: data.id,
number: data.number,
title: data.title,
html_url: data.html_url
};
}
async createPullRequest(
owner: string,
repo: string,
title: string,
head: string,
base: string,
body?: string
) {
const { data } = await this.octokit.pulls.create({
owner,
repo,
title,
head,
base,
body
});
return {
id: data.id,
number: data.number,
title: data.title,
html_url: data.html_url,
state: data.state
};
}
}
3.4 Protected MCP Tools:
src/tools/listRepositories.ts:
import { FastMCP } from 'fastmcp';
import { GitHubClient } from '../services/githubClient';
export function registerListRepositoriesTool(mcp: FastMCP) {
mcp.tool({
name: 'list_repositories',
description: 'Use this when user wants to see their GitHub repositories',
annotations: {
readOnlyHint: true,
securitySchemes: ['oauth2']
}
}, async (params, context) => {
const githubToken = context.user.githubToken;
const client = new GitHubClient(githubToken);
const repositories = await client.listRepositories();
return {
structuredContent: {
repositories,
count: repositories.length
},
uiComponent: {
type: 'iframe',
url: `${process.env.WIDGET_URL}/repositories.html`
}
};
});
}
src/tools/getIssues.ts:
export function registerGetIssues Tool(mcp: FastMCP) {
mcp.tool({
name: 'get_repository_issues',
description: 'Use this when user wants to see issues for a specific repository',
parameters: {
type: 'object',
properties: {
owner: {
type: 'string',
description: 'Repository owner username'
},
repo: {
type: 'string',
description: 'Repository name'
},
state: {
type: 'string',
enum: ['open', 'closed', 'all'],
description: 'Issue state filter',
default: 'open'
}
},
required: ['owner', 'repo']
},
annotations: {
readOnlyHint: true,
securitySchemes: ['oauth2']
}
}, async (params, context) => {
const { owner, repo, state = 'open' } = params;
const githubToken = context.user.githubToken;
const client = new GitHubClient(githubToken);
const issues = await client.getRepositoryIssues(owner, repo, state as any);
return {
structuredContent: {
issues,
repository: `${owner}/${repo}`,
count: issues.length
},
uiComponent: {
type: 'iframe',
url: `${process.env.WIDGET_URL}/issues.html`
}
};
});
}
src/tools/createIssue.ts:
export function registerCreateIssueTool(mcp: FastMCP) {
mcp.tool({
name: 'create_issue',
description: 'Use this when user wants to create a new issue in a repository',
parameters: {
type: 'object',
properties: {
owner: { type: 'string' },
repo: { type: 'string' },
title: { type: 'string' },
body: { type: 'string' },
labels: {
type: 'array',
items: { type: 'string' },
description: 'Issue labels'
}
},
required: ['owner', 'repo', 'title']
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
securitySchemes: ['oauth2']
}
}, async (params, context) => {
const { owner, repo, title, body, labels } = params;
const githubToken = context.user.githubToken;
const client = new GitHubClient(githubToken);
const issue = await client.createIssue(owner, repo, title, body, labels);
return {
structuredContent: {
success: true,
issue,
message: `Issue #${issue.number} created successfully`
}
};
});
}
3.5 Well-Known Endpoint for MCP Server:
src/routes/wellKnown.ts:
import { Router } from 'express';
const router = Router();
router.get('/.well-known/oauth-protected-resource', (req, res) => {
res.json({
resource: process.env.MCP_SERVER_URL,
authorization_servers: [
process.env.AUTH_SERVER_URL
],
bearer_methods_supported: ['header'],
scopes_supported: [
'read:repos',
'write:issues',
'read:user'
]
});
});
export default router;
3.6 MCP Server Main:
src/server.ts:
import { FastMCP } from 'fastmcp';
import express from 'express';
import dotenv from 'dotenv';
import { validateToken, requireScope } from './middleware/validateToken';
import wellKnownRouter from './routes/wellKnown';
import { registerListRepositoriesTool } from './tools/listRepositories';
import { registerGetIssuesTool } from './tools/getIssues';
import { registerCreateIssueTool } from './tools/createIssue';
import { registerCreatePullRequestTool } from './tools/createPullRequest';
dotenv.config();
const mcp = new FastMCP('GitHub Integration');
// Register protected tools
registerListRepositoriesTool(mcp);
registerGetIssuesTool(mcp);
registerCreateIssueTool(mcp);
registerCreatePullRequestTool(mcp);
const app = express();
// Well-known endpoint (no auth required)
app.use(wellKnownRouter);
// Apply token validation to all MCP tool routes
app.use('/tools', validateToken);
// Mount MCP server
app.use(mcp.middleware());
const PORT = process.env.MCP_SERVER_PORT || 3001;
app.listen(PORT, () => {
console.log(`MCP server running on ${process.env.MCP_SERVER_URL}`);
});
Step 4: Build the Widget
4.1 Initialize Widget:
cd packages/widget
npm init -y
npm install react react-dom
npm install @openai/apps-sdk-ui
npm install --save-dev vite @vitejs/plugin-react typescript
npm install --save-dev vite-plugin-singlefile
vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
outDir: 'dist',
rollupOptions: {
output: {
inlineDynamicImports: true
}
}
}
});
4.2 Repository List Component:
src/components/RepositoryList.tsx:
import { useEffect, useState } from 'react';
import { Button, Badge } from '@openai/apps-sdk-ui';
interface Repository {
id: number;
name: string;
full_name: string;
description: string;
language: string;
stargazers_count: number;
open_issues_count: number;
updated_at: string;
html_url: string;
private: boolean;
}
export function RepositoryList() {
const [repositories, setRepositories] = useState<Repository[]>([]);
useEffect(() => {
const data = window.openai?.toolOutput;
if (data?.repositories) {
setRepositories(data.repositories);
}
}, []);
const handleViewIssues = (repo: Repository) => {
const [owner, repoName] = repo.full_name.split('/');
window.openai?.callTool('get_repository_issues', {
owner,
repo: repoName,
state: 'open'
});
};
const handleCreateIssue = (repo: Repository) => {
window.openai?.sendFollowUpMessage(
`Create a new issue in ${repo.full_name}`
);
};
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold">
๐ฆ Your Repositories ({repositories.length})
</h2>
</div>
<div className="space-y-3">
{repositories.map(repo => (
<div key={repo.id} className="border rounded-lg p-4 hover:shadow-md transition">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">
{repo.private ? '๐' : '๐'} {repo.name}
</h3>
{repo.language && (
<Badge variant="secondary">{repo.language}</Badge>
)}
</div>
{repo.description && (
<p className="text-sm text-gray-600 mt-1">
{repo.description}
</p>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>โญ {repo.stargazers_count}</span>
<span>๐ {repo.open_issues_count} issues</span>
<span>
Updated {new Date(repo.updated_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
<div className="flex gap-2 mt-3">
<Button
size="sm"
variant="secondary"
onClick={() => window.open(repo.html_url, '_blank')}
>
View on GitHub
</Button>
<Button
size="sm"
onClick={() => handleViewIssues(repo)}
>
Show Issues
</Button>
<Button
size="sm"
onClick={() => handleCreateIssue(repo)}
>
Create Issue
</Button>
</div>
</div>
))}
</div>
</div>
);
}
4.3 Connect Button Component:
src/components/ConnectButton.tsx:
export function ConnectButton() {
return (
<div className="p-8 text-center">
<div className="max-w-md mx-auto bg-gray-50 rounded-lg p-6">
<h2 className="text-2xl font-bold mb-4">๐ Connect to GitHub</h2>
<p className="text-gray-600 mb-6">
To access your GitHub repositories, you'll need to connect your account.
</p>
<div className="bg-white border rounded-lg p-4 mb-6 text-left">
<p className="font-semibold mb-2">This app needs access to:</p>
<ul className="space-y-1 text-sm">
<li>โ View your repositories</li>
<li>โ Read and write issues</li>
<li>โ Create pull requests</li>
</ul>
</div>
<p className="text-xs text-gray-500">
ChatGPT will redirect you to authorize this connection.
The OAuth flow will handle authentication automatically.
</p>
</div>
</div>
);
}
4.4 Main App Component:
src/App.tsx:
import { useEffect, useState } from 'react';
import { ConnectButton } from './components/ConnectButton';
import { RepositoryList } from './components/RepositoryList';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
const toolOutput = window.openai?.toolOutput;
if (toolOutput) {
setIsAuthenticated(true);
setData(toolOutput);
} else {
setIsAuthenticated(false);
}
}, []);
if (!isAuthenticated) {
return <ConnectButton />;
}
return <RepositoryList />;
}
export default App;
5. Testing & Validation
5.1 Local Testing Setup
Using ngrok for HTTPS Tunnels:
# Install ngrok
brew install ngrok
# Start your servers
npm run dev:auth # Port 3000
npm run dev:mcp # Port 3001
npm run dev:widget # Port 5173
# Create tunnels
ngrok http 3000 --domain=auth.your-domain.ngrok.app
ngrok http 3001 --domain=mcp.your-domain.ngrok.app
ngrok http 5173 --domain=widget.your-domain.ngrok.app
Update .env with ngrok URLs:
AUTH_SERVER_URL=https://auth.your-domain.ngrok.app
MCP_SERVER_URL=https://mcp.your-domain.ngrok.app
WIDGET_URL=https://widget.your-domain.ngrok.app
5.2 Testing OAuth Flow
Test 1: Well-Known Discovery:
# Test MCP server metadata
curl https://mcp.your-domain.ngrok.app/.well-known/oauth-protected-resource
# Expected response:
{
"resource": "https://mcp.your-domain.ngrok.app",
"authorization_servers": ["https://auth.your-domain.ngrok.app"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["read:repos", "write:issues", "read:user"]
}
# Test auth server metadata
curl https://auth.your-domain.ngrok.app/.well-known/oauth-authorization-server
# Should return authorization endpoints
Test 2: Dynamic Client Registration:
curl -X POST https://auth.your-domain.ngrok.app/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Test Client",
"redirect_uris": ["https://example.com/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read:repos write:issues"
}'
# Save the returned client_id for next tests
Test 3: Unauthenticated Request:
curl https://mcp.your-domain.ngrok.app/tools/list_repositories
# Expected: 401 with WWW-Authenticate
{
"error": "unauthorized",
"_meta": {
"mcp/www_authenticate": {
"scheme": "Bearer",
"realm": "github-integration"
}
}
}
Test 4: Full OAuth Flow (manual browser test):
- Open browser to authorization endpoint:
https://auth.your-domain.ngrok.app/oauth/authorize? response_type=code& client_id=YOUR_CLIENT_ID& redirect_uri=https://example.com/callback& scope=read:repos%20write:issues& state=random_state& code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& code_challenge_method=S256 - Log in with GitHub
- Grant permissions
- Copy authorization code from redirect
- Exchange for token:
curl -X POST https://auth.your-domain.ngrok.app/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=AUTHORIZATION_CODE" \ -d "redirect_uri=https://example.com/callback" \ -d "client_id=YOUR_CLIENT_ID" \ -d "code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Test 5: Authenticated Request:
curl https://mcp.your-domain.ngrok.app/tools/list_repositories \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Should return repository list
Test 6: Token Refresh:
curl -X POST https://auth.your-domain.ngrok.app/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID"
# Should return new access and refresh tokens
5.3 Testing with ChatGPT
- Configure App in ChatGPT:
- Go to ChatGPT Apps dashboard
- Create new app
- Set MCP Server URL:
https://mcp.your-domain.ngrok.app
- Test in ChatGPT:
User: "Show my GitHub repositories"- Should trigger OAuth flow
- User sees connect button
- After authorizing, sees repository list
- Test Protected Operations:
User: "Show issues in my awesome-project repository" User: "Create an issue titled 'Test issue' in awesome-project"
5.4 Automated Tests
packages/auth-server/tests/token.test.ts:
import { describe, it, expect } from 'vitest';
import { TokenService } from '../src/services/tokenService';
describe('TokenService', () => {
it('should generate valid JWT', async () => {
const service = new TokenService();
const token = await service.generateAccessToken({
userId: 'user_123',
clientId: 'client_123',
scope: 'read:repos',
githubToken: 'gho_xxx'
});
expect(token).toBeTruthy();
expect(token.split('.')).toHaveLength(3);
});
it('should validate JWT correctly', async () => {
const service = new TokenService();
const token = await service.generateAccessToken({
userId: 'user_123',
clientId: 'client_123',
scope: 'read:repos',
githubToken: 'gho_xxx'
});
const payload = await service.validateAccessToken(token);
expect(payload.sub).toBe('user_123');
expect(payload.scope).toBe('read:repos');
});
it('should reject expired tokens', async () => {
// Create token with past expiration
// ... test implementation
});
});
packages/auth-server/tests/pkce.test.ts:
import { describe, it, expect } from 'vitest';
import { validatePKCE } from '../src/middleware/validatePKCE';
import crypto from 'crypto';
describe('PKCE Validation', () => {
it('should validate correct code_verifier', () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
const isValid = validatePKCE(verifier, challenge, 'S256');
expect(isValid).toBe(true);
});
it('should reject invalid code_verifier', () => {
const verifier = 'wrong_verifier';
const challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
const isValid = validatePKCE(verifier, challenge, 'S256');
expect(isValid).toBe(false);
});
});
6. Common Pitfalls & Debugging
6.1 Common Errors
Error: โInvalid redirect_uriโ
Cause: Redirect URI doesnโt match registered URIs exactly.
Solution:
// WRONG - Pattern matching
if (redirectUri.startsWith(client.redirect_uris[0])) { ... }
// RIGHT - Exact match
if (client.redirect_uris.includes(redirectUri)) { ... }
Error: โInvalid code_verifierโ
Cause: PKCE validation failing.
Debug:
console.log('Received verifier:', codeVerifier);
console.log('Stored challenge:', storedCodeChallenge);
const computed = crypto.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
console.log('Computed challenge:', computed);
console.log('Match:', computed === storedCodeChallenge);
Error: โToken signature verification failedโ
Cause: Using wrong public key or algorithm mismatch.
Solution:
- Verify JWKS endpoint returns correct public key
- Ensure algorithm is RS256 (not HS256)
- Check key IDs (kid) match
Error: โCORS blockedโ
Cause: Missing CORS headers.
Solution:
app.use(cors({
origin: [
'https://chatgpt.com',
'https://widget.your-domain.ngrok.app'
],
credentials: true
}));
6.2 Debugging Tools
JWT Debugger:
# Decode JWT without verification
node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')))" YOUR_JWT
Online Tools:
- jwt.io - Decode and verify JWTs
- oauth.tools - Test OAuth flows
- requestbin.com - Inspect webhook payloads
Logging Middleware:
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
console.log('Headers:', req.headers);
console.log('Body:', req.body);
next();
});
6.3 Database Query Debugging
// Enable Drizzle query logging
import { drizzle } from 'drizzle-orm/node-postgres';
const db = drizzle(pool, {
logger: {
logQuery(query, params) {
console.log('SQL:', query);
console.log('Params:', params);
}
}
});
7. Performance Optimization
7.1 JWKS Caching
Instead of fetching JWKS on every request:
import NodeCache from 'node-cache';
const jwksCache = new NodeCache({ stdTTL: 3600 }); // 1 hour
async function getJWKS() {
let jwks = jwksCache.get('jwks');
if (!jwks) {
const response = await fetch(process.env.AUTH_SERVER_JWKS_URL!);
jwks = await response.json();
jwksCache.set('jwks', jwks);
}
return jwks;
}
7.2 Database Connection Pooling
import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
7.3 Token Cleanup Job
Remove expired tokens periodically:
import cron from 'node-cron';
// Run every hour
cron.schedule('0 * * * *', async () => {
await db.delete(authorizationCodes)
.where(lt(authorizationCodes.expiresAt, new Date()));
await db.delete(accessTokens)
.where(lt(accessTokens.expiresAt, new Date()));
await db.delete(refreshTokens)
.where(
and(
eq(refreshTokens.revoked, true),
lt(refreshTokens.expiresAt, new Date())
)
);
console.log('Cleaned up expired tokens');
});
8. Security Considerations
8.1 Environment Variables
Never commit secrets:
# .gitignore
.env
.env.local
.env.production
keys/*.key
Use secrets management in production:
- AWS Secrets Manager
- HashiCorp Vault
- Azure Key Vault
8.2 Rate Limiting
Prevent abuse:
import rateLimit from 'express-rate-limit';
const tokenLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: { error: 'too_many_requests' },
standardHeaders: true,
legacyHeaders: false
});
app.use('/oauth/token', tokenLimiter);
const authorizeLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10
});
app.use('/oauth/authorize', authorizeLimiter);
8.3 SQL Injection Prevention
Always use parameterized queries (Drizzle handles this):
// SAFE - Drizzle uses parameterized queries
await db.select().from(users).where(eq(users.userId, userId));
// UNSAFE - Never do this
await db.execute(`SELECT * FROM users WHERE user_id = '${userId}'`);
8.4 XSS Prevention
Sanitize user input in consent screens:
import DOMPurify from 'isomorphic-dompurify';
res.render('consent', {
client: {
clientName: DOMPurify.sanitize(client.clientName)
}
});
9. Production Deployment
9.1 Docker Setup
Dockerfile (Auth Server):
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
docker-compose.yml:
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: oauth_app
POSTGRES_USER: oauth_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
auth-server:
build: ./packages/auth-server
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://oauth_user:${DB_PASSWORD}@postgres:5432/oauth_app
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
depends_on:
- postgres
mcp-server:
build: ./packages/mcp-server
ports:
- "3001:3001"
environment:
AUTH_SERVER_JWKS_URL: https://auth.yourdomain.com/.well-known/jwks.json
depends_on:
- auth-server
volumes:
postgres_data:
9.2 Deploy to Railway/Render
Railway:
# Install Railway CLI
npm i -g @railway/cli
# Login
railway login
# Initialize project
railway init
# Deploy
railway up
Render:
- Connect GitHub repo
- Create Blueprint:
render.yaml:
services:
- type: web
name: auth-server
env: node
buildCommand: cd packages/auth-server && npm install && npm run build
startCommand: cd packages/auth-server && npm start
envVars:
- key: DATABASE_URL
fromDatabase:
name: oauth-db
property: connectionString
- key: GITHUB_CLIENT_ID
sync: false
- key: GITHUB_CLIENT_SECRET
sync: false
- type: web
name: mcp-server
env: node
buildCommand: cd packages/mcp-server && npm install && npm run build
startCommand: cd packages/mcp-server && npm start
databases:
- name: oauth-db
databaseName: oauth_app
user: oauth_user
9.3 SSL/TLS Setup
Use Letโs Encrypt with Caddy:
Caddyfile:
auth.yourdomain.com {
reverse_proxy localhost:3000
}
mcp.yourdomain.com {
reverse_proxy localhost:3001
}
10. Variations & Extensions
10.1 Multi-Provider Support
Support multiple OAuth providers (GitHub, Google, etc.):
// Add provider selection to consent screen
const providers = ['github', 'google', 'azure'];
app.get('/oauth/authorize', (req, res) => {
res.render('select-provider', { providers });
});
app.post('/oauth/authorize/provider', (req, res) => {
const { provider } = req.body;
req.session.selectedProvider = provider;
res.redirect(`/auth/${provider}`);
});
10.2 Token Introspection Endpoint
Allow resource servers to validate tokens:
app.post('/oauth/introspect', async (req, res) => {
const { token } = req.body;
try {
const payload = await tokenService.validateAccessToken(token);
res.json({
active: true,
sub: payload.sub,
scope: payload.scope,
exp: payload.exp
});
} catch {
res.json({ active: false });
}
});
10.3 Token Revocation
Allow users to revoke access:
app.post('/oauth/revoke', async (req, res) => {
const { token, token_type_hint } = req.body;
if (token_type_hint === 'access_token') {
await db.delete(accessTokens).where(eq(accessTokens.tokenId, token));
} else if (token_type_hint === 'refresh_token') {
await db.update(refreshTokens)
.set({ revoked: true })
.where(eq(refreshTokens.tokenId, token));
}
res.status(200).send();
});
10.4 Scoped Permissions UI
Show detailed permission descriptions:
const scopeDescriptions = {
'read:repos': {
title: 'View your repositories',
description: 'Read metadata, code, and commit history'
},
'write:issues': {
title: 'Manage issues and pull requests',
description: 'Create, edit, and comment on issues and PRs'
},
'read:user': {
title: 'Access your profile information',
description: 'Read your username, email, and avatar'
}
};
11. Additional Resources
11.1 Official Documentation
- OAuth 2.1 Draft Specification
- RFC 7636: PKCE
- RFC 7519: JWT
- RFC 8414: OAuth Discovery
- OpenAI Apps SDK Auth Guide
11.2 Books
- โOAuth 2 in Actionโ by Justin Richer & Antonio Sanso
- โAPI Security in Actionโ by Neil Madden
- โDesigning Secure Softwareโ by Loren Kohnfelder
11.3 Tools & Libraries
- jose: JWT library for Node.js
- Passport.js: Authentication middleware
- Auth0: Managed OAuth provider
- Okta: Enterprise identity platform
11.4 Security Resources
12. Exercises & Challenges
Exercise 1: Implement Device Flow
Add OAuth 2.0 Device Authorization Grant for CLI tools:
// POST /oauth/device/code
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://example.com/device",
"expires_in": 900,
"interval": 5
}
Exercise 2: Add Consent Management
Build a user dashboard to:
- View all authorized apps
- Revoke access per app
- See granted scopes
- View last access time
Exercise 3: Implement Token Binding
Bind tokens to specific devices/browsers using:
- TLS channel binding
- Device fingerprinting
- Certificate-bound access tokens
Exercise 4: Add Audit Logging
Log all OAuth events:
- Authorization requests
- Token grants
- Token refreshes
- Token revocations
- Failed authentication attempts
Exercise 5: Build Admin Dashboard
Create admin UI to:
- Monitor active sessions
- View OAuth metrics
- Manage registered clients
- Investigate security incidents
13. Summary & Next Steps
What Youโve Learned
- OAuth 2.1 authorization flow with PKCE
- Dynamic Client Registration (DCR)
- JWT generation and validation
- Token lifecycle management
- Secure credential storage
- WWW-Authenticate responses
- Well-known endpoint discovery
- Integration with third-party OAuth providers
Key Takeaways
- Always use PKCE: Protects against code interception
- Validate everything: Never trust input, always verify tokens
- Use HTTPS everywhere: OAuth requires secure connections
- Rotate refresh tokens: Implement token rotation for security
- Monitor and log: Track OAuth events for security audits
- Follow specs: OAuth 2.1 and related RFCs are your guide
Production Checklist
- All secrets in environment variables
- HTTPS enabled on all endpoints
- Rate limiting configured
- JWKS caching implemented
- Token cleanup job running
- Database backups configured
- Monitoring and alerting set up
- Audit logging enabled
- Security headers configured
- CORS properly restricted
- Input validation on all endpoints
- Error messages donโt leak sensitive info
Next Steps
- Complete Project 7: Real-Time Dashboard App
- Build on OAuth knowledge with live data
- Implement WebSocket authentication
- Explore Advanced OAuth:
- Rich Authorization Requests (RAR)
- Demonstrating Proof of Possession (DPoP)
- JWT-Secured Authorization Requests (JAR)
- Add More Integrations:
- Google Calendar
- Salesforce
- Slack
- Notion
- Study Security:
- Common OAuth vulnerabilities
- Penetration testing OAuth flows
- Security auditing techniques
Congratulations!
Youโve built a production-ready OAuth 2.1 protected ChatGPT app! This is advanced material that many developers struggle with. You now understand:
- How modern authentication works
- How to secure API endpoints
- How ChatGPT apps handle user identity
- How to integrate with third-party services
This knowledge is valuable for any web application, not just ChatGPT apps. OAuth is used by virtually every major platform (GitHub, Google, Facebook, Twitter, etc.), and you now know how to implement it correctly.
Ready to publish your app? Move on to Project 9: Full App Store Submission to take this to 800+ million ChatGPT users!