Skip to content

Instantly share code, notes, and snippets.

@thekid
Last active January 1, 2026 16:54
Show Gist options
  • Select an option

  • Save thekid/e6ca3935b82a5e79e3337b1f2c3a596d to your computer and use it in GitHub Desktop.

Select an option

Save thekid/e6ca3935b82a5e79e3337b1f2c3a596d to your computer and use it in GitHub Desktop.
MCP OAuth flow POC
<?php
use io\streams\Streams;
use web\Routes;
use web\auth\Authentication;
use web\filters\Invocation;
use web\session\Sessions;
class OAuth2Gateway {
private $base, $tokens, $clients;
/** Creates an MCP authentication handler based on OAuth2 */
public function __construct(string $base, $tokens, $clients) {
$this->base= trim($base, '/');
$this->tokens= $tokens;
$this->clients= $clients;
}
/**
* Sends a 200 OK and the given value serialized as JSON
*
* @param web.Response $response
* @param var $value
* @return void
*/
private function result($response, $value) {
$response->answer(200);
$response->send(json_encode($value), 'application/json');
}
/**
* Sends a 302 Found and the given location
*
* @param web.Response $response
* @param string $location
* @return void
*/
private function redirect($response, $location) {
$response->answer(302);
$response->header('Location', $location);
}
/**
* Sends a 400 Bad Request and the given error and description serialized as JSON
*
* @param web.Response $response
* @param string $error
* @param string $description
* @return void
*/
private function error($response, $error, $description) {
$response->answer(400);
$response->send(json_encode(['error' => $error, 'error_description' => $description]), 'application/json');
}
/** @return string */
public function continuation() { return "/{$this->base}/continuation"; }
/** @return function(web.Request, web.Response) */
public function meta() {
return function($request, $response) {
$host= $request->uri()->base();
return $this->result($response, [
'issuer' => "{$host}",
'authorization_endpoint' => "{$host}/{$this->base}/authorize",
'token_endpoint' => "{$host}/{$this->base}/token",
'registration_endpoint' => "{$host}/{$this->base}/register",
'response_types_supported' => ['code'],
'grant_types_supported' => ['authorization_code', 'refresh_token'],
'code_challenge_methods_supported' => ['S256'],
'token_endpoint_auth_methods_supported' => ['none']
]);
};
}
/** @return function(web.Request, web.Response) */
public function flow(Authentication $auth, Sessions $sessions) {
return function($request, $response) use($auth, $sessions) {
$path= $request->method().' '.$request->uri()->path();
switch ($path) {
case "POST /{$this->base}/register":
$payload= json_decode(Streams::readAll($request->stream()), true);
$client= $this->clients->register($payload);
$response->trace('registered', $client);
return $this->result($response, $client);
case "GET /{$this->base}/authorize":
$client= $this->clients->lookup($request->param('client_id'));
if (!$client || !in_array($request->param('redirect_uri'), $client['redirect_uris'])) {
return $this->error(
$response,
'invalid_client',
'Cannot authorize client '.$request->param('client_id')
);
}
// Fall through
case "GET /{$this->base}/continuation":
return $auth->filter($request, $response, new Invocation(function($request, $response) use($sessions) {
$response->trace('client', $request->param('client_id'));
// Create a session, register user and flow
$session= $sessions->create();
$session->register('user', $request->value('user'));
$session->register('flow', [
'client' => $request->param('client_id'),
'redirect' => $request->param('redirect_uri'),
'method' => $request->param('code_challenge_method'),
'challenge' => $request->param('code_challenge'),
]);
$session->transmit($response);
// Then, redirect to the specified redirect_uri
return $this->redirect($response, sprintf(
'%s?code=%s&state=%s',
$request->param('redirect_uri'),
$session->id(),
$request->param('state')
));
}));
case "POST /{$this->base}/token":
$response->trace('client', $request->param('client_id'));
if ('authorization_code' !== $request->param('grant_type')) {
return $this->error($response, 'unsupported_grant_type', 'Grant type unsupported');
} else if (empty($verifier= $request->param('code_verifier'))) {
return $this->error($response, 'invalid_grant', 'Invalid authorization grant');
}
// Confirm that the code (:= session)
// - Exists
// - Was issued by your authorization server
// - Has not expired
// - Has not already been used (single-use)
$session= $sessions->open($request->param('code'));
if (null === $session) {
$flow= ['method' => ':EXPIRED', 'client' => '', 'redirect' => '', 'challenge' => ''];
} else {
$flow= $session->value('flow') ?? ['method' => ':REUSED', 'client' => '', 'redirect' => '', 'challenge' => ''];
}
// - Is associated with the same client_id and the same redirect_uri
// - Verifies according to PKCE method
$c= hash_equals($flow['client'], $request->param('client_id'));
$r= hash_equals($flow['redirect'], $request->param('redirect_uri'));
$v= hash_equals($flow['challenge'], match ($flow['method']) {
'S256' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'plain' => $verifier,
default => "!{$flow['challenge']}",
});
// Always execute all 3 hash_equals() checks to reduce timing attacks
// Do not give a precise error message to not give attackers any hint
if (!$c || !$r || !$v) {
return $this->error(
$response,
'invalid_grant',
'Invalid, expired or already used authorization code, or PKCE verification failed'
);
}
// Invalidate the flow, clients may retry the above step (RFC 6749 §4.1.2 and §4.1.3)
$session->remove('flow');
$session->transmit($response);
// Create token and return
return $this->result($response, $this->tokens->issue(
(string)$request->uri()->base(),
$flow['client'],
$session
));
default:
return $this->error($response, 'invalid_request', 'Cannot handle requests to '.$path);
}
};
}
/** @return function(web.Request, web.Response) */
public function protect($routing) {
$handler= Routes::cast($routing);
return function($request, $response) use($handler) {
$r= sscanf($request->header('Authorization') ?? '', 'Bearer %s', $bearer);
if (1 === $r && ($user= $this->tokens->use($bearer))) {
return $handler->handle($request->pass('user', $user), $response);
}
$response->answer(401);
$response->header('WWW-Authenticate', 'Bearer');
};
}
}
<?php
use io\modelcontextprotocol\McpServer;
use io\modelcontextprotocol\server\ImplementationsIn;
use util\log\Logging;
use web\auth\oauth\{OAuth2Flow, JWT};
use web\auth\{Authentication, SessionBased};
use web\session\InFileSystem;
use web\{Application, Filter, Filters};
class Test extends Application {
public function routes() {
// Allow direct connections from MCP inspector
$cors= new class() implements Filter {
public function filter($request, $response, $invokation) {
$response->header('Access-Control-Allow-Origin', '*');
$response->header('Access-Control-Allow-Headers', '*');
if ('OPTIONS' === $request->method()) {
$response->answer(200);
return;
}
return $invokation->proceed($request, $response);
}
};
// Store clients in a JSON file
$clients= new class($this->environment->path('clients.json')) {
public function __construct(private $path) { }
public function lookup(?string $id): ?array {
if (isset($id) && $this->path->exists()) {
$map= json_decode(file_get_contents($this->path), true);
return $map[$id] ?? null;
} else {
return null;
}
}
public function register(array $client): array {
$id= sha1(random_bytes(0x42));
$map= $this->path->exists() ? json_decode(file_get_contents($this->path), true) : [];
$map[$id]= $client;
file_put_contents($this->path, json_encode($map));
return ['client_id' => $id] + $client;
}
};
// Reuse authentication sessions as bearer tokens
$sessions= (new InFileSystem())->named('oauth');
$tokens= new class($sessions) {
public function __construct(private $sessions) { }
public function issue($issuer, $audience, $session) {
return ['token_type' => 'Bearer', 'access_token' => $session->id()];
}
public function use($token) {
if ($session= $this->sessions->open($token)) {
try {
return $session->value('user');
} finally {
$session->close();
}
}
return null;
}
};
$gateway= new OAuth2Gateway('/oauth', $tokens, $clients);
// OAuth via GitHub
$flow= new OAuth2Flow(
'https://github.com/login/oauth/authorize',
'https://github.com/login/oauth/access_token',
[$this->environment->variable('GITHUB_CLIENT'), $this->environment->variable('GITHUB_SECRET')],
$gateway->continuation(),
['user']
);
$github= new SessionBased($flow, new InFileSystem(), function($client) {
$user= $client->fetch('https://api.github.com/user')->value();
return [
'sub' => $user['id'],
'preferred_username' => $user['login'],
'name' => $user['name'],
'profile' => $user['html_url'],
'picture' => $user['avatar_url'],
];
});
$server= new McpServer(new ImplementationsIn('impl'));
// $server->setTrace(Logging::all()->toConsole());
return new Filters([$cors], [
'/.well-known/oauth-authorization-server' => $gateway->meta(),
'/oauth' => $gateway->flow($github, $sessions),
'/mcp' => $gateway->protect($server),
]);
}
}
@thekid
Copy link
Author

thekid commented Dec 31, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment