Skip to content

Commit 361cec6

Browse files
theo-learnerclaude
andcommitted
feat: add PKCE + Dynamic Client Registration for ChatGPT OAuth compliance
ChatGPT Apps SDK requires two things our OAuth implementation was missing: 1. Dynamic Client Registration (RFC 7591): - New POST /api/oauth/register endpoint - ChatGPT registers before starting the OAuth flow to get client_id/secret - registration_endpoint added to /.well-known/oauth-authorization-server 2. PKCE with S256 (RFC 7636) — mandatory for ChatGPT: - authorize endpoint now stores code_challenge with each auth code - token endpoint verifies code_verifier via SHA-256 base64url comparison - client_credentials flow preserved for legacy direct API access Also: allow ChatGPT redirect URI (chatgpt.com, platform.openai.com), refactor validateBearerToken to check both random issued tokens (new) and legacy HMAC-derived tokens (backward compat). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a699c7 commit 361cec6

File tree

6 files changed

+302
-51
lines changed

6 files changed

+302
-51
lines changed

src/app/.well-known/oauth-authorization-server/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
33
* Required for MCP 2025-03-26 OAuth discovery.
4-
* ChatGPT uses this to discover token/authorize endpoints automatically.
4+
* ChatGPT uses this to discover token/authorize/register endpoints automatically.
55
*/
66

77
import { NextRequest, NextResponse } from 'next/server';
@@ -16,6 +16,7 @@ export async function GET(request: NextRequest) {
1616
issuer: base,
1717
authorization_endpoint: `${base}/api/oauth/authorize`,
1818
token_endpoint: `${base}/api/oauth/token`,
19+
registration_endpoint: `${base}/api/oauth/register`,
1920
response_types_supported: ['code'],
2021
grant_types_supported: ['authorization_code', 'client_credentials'],
2122
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],

src/app/api/oauth/authorize/route.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,41 @@
11
/**
2-
* OAuth 2.0 Authorization Endpoint
3-
* Handles authorization code flow for ChatGPT MCP app registration.
4-
* Auto-approves requests with valid client_id (no user login required for M2M).
2+
* OAuth 2.0 Authorization Endpoint — RFC 6749 + PKCE (RFC 7636)
3+
* Handles authorization code flow with PKCE for ChatGPT MCP app registration.
4+
* Auto-approves all requests (no user login needed — MCP is a service connection).
55
*/
66

77
import { NextRequest, NextResponse } from 'next/server';
8-
import { getOAuthClientId, issueAuthCode } from '@/lib/oauth-token';
8+
import { getOAuthClientId, getDynamicClient, issueAuthCode } from '@/lib/oauth-token';
99

1010
export const dynamic = 'force-dynamic';
1111

12+
/** ChatGPT's production redirect URI (must be allowed). */
13+
const ALLOWED_REDIRECT_ORIGINS = [
14+
'https://chatgpt.com',
15+
'https://platform.openai.com',
16+
];
17+
18+
function isAllowedRedirectUri(uri: string): boolean {
19+
try {
20+
const url = new URL(uri);
21+
// Allow ChatGPT's redirect URIs
22+
if (ALLOWED_REDIRECT_ORIGINS.some(origin => url.origin === origin)) return true;
23+
// Allow localhost for development
24+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return true;
25+
return false;
26+
} catch {
27+
return false;
28+
}
29+
}
30+
1231
export async function GET(request: NextRequest) {
1332
const { searchParams } = new URL(request.url);
1433
const clientId = searchParams.get('client_id');
1534
const redirectUri = searchParams.get('redirect_uri');
1635
const responseType = searchParams.get('response_type');
1736
const state = searchParams.get('state');
37+
const codeChallenge = searchParams.get('code_challenge') || undefined;
38+
const codeChallengeMethod = searchParams.get('code_challenge_method') || undefined;
1839

1940
if (responseType !== 'code') {
2041
return NextResponse.json(
@@ -23,7 +44,18 @@ export async function GET(request: NextRequest) {
2344
);
2445
}
2546

26-
if (clientId !== getOAuthClientId()) {
47+
if (!clientId) {
48+
return NextResponse.json(
49+
{ error: 'invalid_request', error_description: 'client_id is required.' },
50+
{ status: 400 }
51+
);
52+
}
53+
54+
// Accept both static client and dynamic DCR clients
55+
const isStaticClient = clientId === getOAuthClientId();
56+
const isDynamicClient = !isStaticClient && !!getDynamicClient(clientId);
57+
58+
if (!isStaticClient && !isDynamicClient) {
2759
return NextResponse.json(
2860
{ error: 'unauthorized_client', error_description: 'Unknown client_id.' },
2961
{ status: 401 }
@@ -37,6 +69,24 @@ export async function GET(request: NextRequest) {
3769
);
3870
}
3971

72+
if (!isAllowedRedirectUri(redirectUri)) {
73+
return NextResponse.json(
74+
{ error: 'invalid_request', error_description: 'redirect_uri is not allowed.' },
75+
{ status: 400 }
76+
);
77+
}
78+
79+
// PKCE: S256 is the only supported method
80+
if (codeChallenge && codeChallengeMethod && codeChallengeMethod !== 'S256') {
81+
return NextResponse.json(
82+
{ error: 'invalid_request', error_description: 'Only code_challenge_method=S256 is supported.' },
83+
{ status: 400 }
84+
);
85+
}
86+
87+
// Auto-approve: issue the authorization code
88+
const code = issueAuthCode(clientId, codeChallenge, codeChallengeMethod);
89+
4090
let redirectUrl: URL;
4191
try {
4292
redirectUrl = new URL(redirectUri);
@@ -47,7 +97,6 @@ export async function GET(request: NextRequest) {
4797
);
4898
}
4999

50-
const code = issueAuthCode(clientId);
51100
redirectUrl.searchParams.set('code', code);
52101
if (state) redirectUrl.searchParams.set('state', state);
53102

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* OAuth 2.0 Dynamic Client Registration Endpoint — RFC 7591
3+
* ChatGPT Apps SDK performs DCR before starting the OAuth flow.
4+
* Returns a unique client_id and client_secret for each registration.
5+
*/
6+
7+
import { NextRequest, NextResponse } from 'next/server';
8+
import { registerDynamicClient } from '@/lib/oauth-token';
9+
10+
export const dynamic = 'force-dynamic';
11+
12+
export async function POST(request: NextRequest) {
13+
let body: Record<string, unknown> = {};
14+
try {
15+
const text = await request.text();
16+
if (text) body = JSON.parse(text);
17+
} catch {
18+
// Accept registrations with no body (redirect_uris defaults to empty)
19+
}
20+
21+
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
22+
const { clientId, clientSecret } = registerDynamicClient(redirectUris);
23+
24+
// RFC 7591 response
25+
return NextResponse.json(
26+
{
27+
client_id: clientId,
28+
client_secret: clientSecret,
29+
client_id_issued_at: Math.floor(Date.now() / 1000),
30+
client_secret_expires_at: 0, // 0 = never expires
31+
redirect_uris: redirectUris,
32+
token_endpoint_auth_method: 'client_secret_basic',
33+
grant_types: ['authorization_code'],
34+
response_types: ['code'],
35+
},
36+
{ status: 201 }
37+
);
38+
}

src/app/api/oauth/token/route.ts

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
/**
2-
* OAuth 2.0 Token Endpoint
2+
* OAuth 2.0 Token Endpoint — RFC 6749 + PKCE (RFC 7636)
33
* Issues access tokens for ChatGPT MCP app authentication.
4-
* Supports: authorization_code, client_credentials grant types.
5-
* Client credentials accepted via Basic auth header or request body.
4+
* Supports:
5+
* - authorization_code + PKCE (ChatGPT's required flow)
6+
* - client_credentials (legacy / direct API users)
7+
* Client auth via Basic header or request body.
68
*/
79

810
import { NextRequest, NextResponse } from 'next/server';
911
import {
1012
getOAuthClientId,
1113
getOAuthClientSecret,
14+
validateDynamicClient,
1215
consumeAuthCode,
16+
issueAccessToken,
1317
deriveAccessToken,
1418
ACCESS_TOKEN_TTL_SECONDS,
1519
} from '@/lib/oauth-token';
@@ -22,13 +26,17 @@ function extractClientCredentials(
2226
): { clientId: string | null; clientSecret: string | null } {
2327
const authHeader = request.headers.get('authorization');
2428
if (authHeader?.startsWith('Basic ')) {
25-
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
26-
const colonIndex = decoded.indexOf(':');
27-
if (colonIndex !== -1) {
28-
return {
29-
clientId: decoded.slice(0, colonIndex) || null,
30-
clientSecret: decoded.slice(colonIndex + 1) || null,
31-
};
29+
try {
30+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
31+
const colonIndex = decoded.indexOf(':');
32+
if (colonIndex !== -1) {
33+
return {
34+
clientId: decoded.slice(0, colonIndex) || null,
35+
clientSecret: decoded.slice(colonIndex + 1) || null,
36+
};
37+
}
38+
} catch {
39+
// fall through to body
3240
}
3341
}
3442
return {
@@ -37,6 +45,19 @@ function extractClientCredentials(
3745
};
3846
}
3947

48+
/** Validate client credentials: accepts both static and DCR clients. */
49+
function isValidClient(clientId: string | null, clientSecret: string | null): boolean {
50+
if (!clientId || !clientSecret) return false;
51+
52+
// Static pre-configured client
53+
const staticId = getOAuthClientId();
54+
const staticSecret = getOAuthClientSecret();
55+
if (clientId === staticId && staticSecret && clientSecret === staticSecret) return true;
56+
57+
// Dynamic DCR client
58+
return validateDynamicClient(clientId, clientSecret);
59+
}
60+
4061
export async function POST(request: NextRequest) {
4162
let body: URLSearchParams;
4263
try {
@@ -46,39 +67,51 @@ export async function POST(request: NextRequest) {
4667
return NextResponse.json({ error: 'invalid_request' }, { status: 400 });
4768
}
4869

49-
const configuredSecret = getOAuthClientSecret();
50-
if (!configuredSecret) {
51-
return NextResponse.json(
52-
{ error: 'server_error', error_description: 'OAuth is not configured on this server.' },
53-
{ status: 500 }
54-
);
55-
}
56-
5770
const { clientId, clientSecret } = extractClientCredentials(request, body);
58-
const configuredClientId = getOAuthClientId();
5971

60-
if (clientId !== configuredClientId || clientSecret !== configuredSecret) {
72+
if (!isValidClient(clientId, clientSecret)) {
6173
return NextResponse.json({ error: 'invalid_client' }, { status: 401 });
6274
}
6375

6476
const grantType = body.get('grant_type');
6577

66-
if (grantType === 'client_credentials') {
78+
if (grantType === 'authorization_code') {
79+
const code = body.get('code');
80+
const codeVerifier = body.get('code_verifier') || undefined;
81+
82+
if (!code || !clientId) {
83+
return NextResponse.json(
84+
{ error: 'invalid_request', error_description: 'code is required.' },
85+
{ status: 400 }
86+
);
87+
}
88+
89+
if (!consumeAuthCode(code, clientId, codeVerifier)) {
90+
return NextResponse.json(
91+
{ error: 'invalid_grant', error_description: 'Authorization code is invalid, expired, or PKCE verification failed.' },
92+
{ status: 400 }
93+
);
94+
}
95+
6796
return NextResponse.json({
68-
access_token: deriveAccessToken(configuredSecret),
97+
access_token: issueAccessToken(),
6998
token_type: 'Bearer',
7099
expires_in: ACCESS_TOKEN_TTL_SECONDS,
71100
});
72101
}
73102

74-
if (grantType === 'authorization_code') {
75-
const code = body.get('code');
76-
if (!code || !consumeAuthCode(code, clientId)) {
103+
if (grantType === 'client_credentials') {
104+
const configuredSecret = getOAuthClientSecret();
105+
if (!configuredSecret) {
77106
return NextResponse.json(
78-
{ error: 'invalid_grant', error_description: 'Authorization code is invalid or expired.' },
79-
{ status: 400 }
107+
{ error: 'server_error', error_description: 'OAuth is not configured.' },
108+
{ status: 500 }
80109
);
81110
}
111+
// Static client only for client_credentials
112+
if (clientId !== getOAuthClientId()) {
113+
return NextResponse.json({ error: 'invalid_client' }, { status: 401 });
114+
}
82115
return NextResponse.json({
83116
access_token: deriveAccessToken(configuredSecret),
84117
token_type: 'Bearer',

0 commit comments

Comments
 (0)