feat: add OIDC userinfo endpoint for ChatGPT domain claiming

ChatGPT enables OIDC when it finds /.well-known/openid-configuration
and uses the userinfo endpoint to fetch the authenticated user's email
for authorization domain claiming.

- Add GET /oauth/userinfo: validates Bearer token, returns sub/email/
  email_verified/preferred_username from the OAuth access token
- Add userinfo_endpoint to /.well-known/openid-configuration response
- Add /oauth/userinfo to open-CORS pre-middleware
This commit is contained in:
jubnl
2026-05-05 14:41:15 +02:00
parent 895f34deba
commit 55ef0f3ca9
2 changed files with 33 additions and 3 deletions
+9 -3
View File
@@ -108,6 +108,7 @@ export function createApp(): express.Application {
req.path.startsWith('/.well-known/') || req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' || req.path === '/oauth/register' ||
req.path === '/oauth/authorize' || req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp' req.path === '/mcp'
) { ) {
cors({ origin: '*', credentials: false })(req, _res, next); cors({ origin: '*', credentials: false })(req, _res, next);
@@ -424,10 +425,15 @@ export function createApp(): express.Application {
}); });
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
// /.well-known/openid-configuration. Serve the same typed AS metadata so // /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
// they can find registration_endpoint, authorization_endpoint, token_endpoint. // userinfo_endpoint so ChatGPT can fetch the authenticated user's email
// for authorization domain claiming.
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
res.json(getOAuthMetadata()); const meta = getOAuthMetadata();
res.json({
...meta,
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
});
}); });
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
+24
View File
@@ -20,6 +20,7 @@ import {
rotateOAuthClientSecret, rotateOAuthClientSecret,
listOAuthSessions, listOAuthSessions,
revokeSession, revokeSession,
getUserByAccessToken,
AuthorizeParams, AuthorizeParams,
} from '../services/oauthService'; } from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
@@ -153,6 +154,29 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
}); });
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const auth = req.headers['authorization'];
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
return res.status(401).json({ error: 'invalid_token' });
}
const token = auth.slice(7);
const info = getUserByAccessToken(token);
if (!info) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
return res.status(401).json({ error: 'invalid_token' });
}
return res.json({
sub: String(info.user.id),
email: info.user.email,
email_verified: true,
preferred_username: info.user.username,
});
});
// Token revocation endpoint (RFC 7009) // Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();