Skip to content

Instantly share code, notes, and snippets.

@gomasy
Created August 11, 2026 13:46
Show Gist options
  • Select an option

  • Save gomasy/35ddffee70e513881f225d53d66be77c to your computer and use it in GitHub Desktop.

Select an option

Save gomasy/35ddffee70e513881f225d53d66be77c to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1);
/**
* WebFinger (RFC 7033) — OpenID Connect Issuer Discovery endpoint.
*
* GET https://example.com/.well-known/webfinger?resource=acct:alice@example.com
* -> 200 application/jrd+json
* {"subject":"acct:alice@example.com","links":[{"rel":"...issuer","href":"..."}]}
*
* No database: the whitelist in WEBFINGER_CONFIG is the only source of truth.
* Requires PHP 8.0+.
*/
// ============================================================
// Configuration — this is the only part you normally edit.
// ============================================================
const WEBFINGER_CONFIG = [
// Exact-match whitelist, keyed by email address. Checked first.
'accounts' => [
'alice@example.com' => 'https://issuer.example.com',
'bob@example.com' => 'https://another-issuer.example.net',
],
// Fallback whitelist, keyed by domain. Leave empty to disable.
'domains' => [
'example.org' => 'https://issuer.example.com',
],
// Treat the local part case-insensitively. Strictly speaking acct: URIs are
// case-sensitive there, but every real mail system folds case, so keep this
// on unless you know you need the distinction.
'fold_local_part' => true,
// Cache lifetime of a successful response, in seconds. 0 disables caching.
'cache_max_age' => 3600,
// Pretty-print the JSON. Handy while debugging, wasteful in production.
'pretty_print' => false,
];
/** Link relation type for OIDC Issuer Discovery. */
const ISSUER_REL = 'http://openid.net/specs/connect/1.0/issuer';
// ============================================================
// Domain model
// ============================================================
/**
* An error that maps directly onto an HTTP status code.
*
* Throwing instead of exiting keeps every response funnelled through a single
* writer, which is what makes the request handler testable.
*/
final class WebFingerError extends RuntimeException
{
public function __construct(
private int $status,
private string $errorCode,
string $message
) {
// Note: the property cannot be called $code — Exception already
// declares that one as protected.
parent::__construct($message);
}
public function status(): int
{
return $this->status;
}
public function errorCode(): string
{
return $this->errorCode;
}
}
/**
* A normalized "local@domain" account.
*
* Both incoming resources and configured whitelist keys go through parse(),
* so the two sides are guaranteed to be normalized identically.
*/
final class Account
{
/**
* Deliberately conservative: alphanumerics plus the punctuation that
* actually shows up in addresses. Restricting to ASCII also means we never
* need mbstring to fold case.
*/
private const LOCAL_PART = '/^[A-Za-z0-9._%+\-]+$/D';
/**
* Dot-separated LDH labels, 253 characters at most. Punycode passes as-is.
*
* The /D modifier on both patterns is load-bearing: without it "$" also
* matches just before a trailing newline, so "alice\n" would slip through.
*/
private const DOMAIN = '/^[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)*$/D';
private function __construct(
public string $local,
public string $domain
) {
}
/** Returns null when the value is not a usable acct: resource. */
public static function parse(string $resource, bool $foldLocalPart): ?self
{
$value = trim($resource);
if (stripos($value, 'acct:') === 0) {
$value = substr($value, 5);
} elseif (preg_match('/^[a-z][a-z0-9+.\-]*:/i', $value)) {
// Any other scheme (https:, mailto:, ...) is out of scope here.
return null;
}
$at = strrpos($value, '@');
if ($at === false || $at === 0 || $at === strlen($value) - 1) {
return null;
}
$local = substr($value, 0, $at);
$domain = strtolower(substr($value, $at + 1));
if (strlen($domain) > 253
|| !preg_match(self::LOCAL_PART, $local)
|| !preg_match(self::DOMAIN, $domain)) {
return null;
}
return new self($foldLocalPart ? strtolower($local) : $local, $domain);
}
public function __toString(): string
{
return $this->local . '@' . $this->domain;
}
public function uri(): string
{
return 'acct:' . $this;
}
}
/** The whitelist, with its keys normalized once at construction time. */
final class IssuerRegistry
{
/** @var array<string, string> */
private array $accounts = [];
/** @var array<string, string> */
private array $domains = [];
/**
* @param array<string, string> $accounts
* @param array<string, string> $domains
*/
public function __construct(array $accounts, array $domains, bool $foldLocalPart)
{
foreach ($accounts as $key => $issuer) {
$account = Account::parse((string) $key, $foldLocalPart);
if ($account === null) {
// A typo in the config should be loud but must not take the
// endpoint down for everybody else.
error_log(sprintf('webfinger: ignoring malformed account "%s"', $key));
continue;
}
$this->accounts[(string) $account] = $issuer;
}
foreach ($domains as $key => $issuer) {
$this->domains[strtolower(trim((string) $key))] = $issuer;
}
}
public function lookup(Account $account): ?string
{
return $this->accounts[(string) $account]
?? $this->domains[$account->domain]
?? null;
}
}
/** Turns query parameters into a JRD document, or throws WebFingerError. */
final class WebFingerService
{
public function __construct(
private IssuerRegistry $registry,
private bool $foldLocalPart
) {
}
/**
* @param string[] $resources every "resource" parameter that was supplied
* @param string[] $rels every "rel" parameter that was supplied
* @return array{subject: string, links: list<array{rel: string, href: string}>}
*/
public function resolve(array $resources, array $rels): array
{
if (count($resources) !== 1 || trim($resources[0]) === '') {
throw new WebFingerError(400, 'invalid_request', 'Exactly one "resource" parameter is required.');
}
$account = Account::parse($resources[0], $this->foldLocalPart);
if ($account === null) {
throw new WebFingerError(400, 'invalid_resource', 'The "resource" parameter must be an acct: URI.');
}
$issuer = $this->registry->lookup($account);
if ($issuer === null) {
// Everything outside the whitelist gets the same bare 404 so we do
// not leak which accounts exist.
throw new WebFingerError(404, 'not_found', 'No resource found.');
}
// When "rel" is present, return only the matching links. A request that
// asks for some other relation still gets 200 with an empty list.
$links = ($rels === [] || in_array(ISSUER_REL, $rels, true))
? [['rel' => ISSUER_REL, 'href' => $issuer]]
: [];
return ['subject' => $account->uri(), 'links' => $links];
}
}
// ============================================================
// HTTP layer
// ============================================================
/**
* Parse a query string into a map of name => list of values.
*
* $_GET is unusable here: "rel" may legitimately appear more than once and
* $_GET silently keeps only the last occurrence.
*
* @return array<string, list<string>>
*/
function webfinger_parse_query(string $queryString): array
{
$parsed = [];
foreach (explode('&', $queryString) as $pair) {
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
$parsed[urldecode($parts[0])][] = isset($parts[1]) ? urldecode($parts[1]) : '';
}
return $parsed;
}
function webfinger_send(int $status, string $contentType, array $body, int $maxAge, bool $pretty): void
{
http_response_code($status);
header('Content-Type: ' . $contentType . '; charset=UTF-8');
header($maxAge > 0 ? 'Cache-Control: public, max-age=' . $maxAge : 'Cache-Control: no-store');
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | ($pretty ? JSON_PRETTY_PRINT : 0);
echo json_encode($body, $flags), "\n";
}
function webfinger_main(array $config): void
{
header('Access-Control-Allow-Origin: *'); // CORS is mandatory for WebFinger
header('Access-Control-Allow-Methods: GET, OPTIONS');
header('X-Content-Type-Options: nosniff');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'OPTIONS') {
http_response_code(204);
return;
}
try {
if ($method !== 'GET' && $method !== 'HEAD') {
header('Allow: GET, HEAD, OPTIONS');
throw new WebFingerError(405, 'method_not_allowed', 'Only GET is supported.');
}
$service = new WebFingerService(
new IssuerRegistry($config['accounts'], $config['domains'], $config['fold_local_part']),
$config['fold_local_part']
);
$query = webfinger_parse_query($_SERVER['QUERY_STRING'] ?? '');
$jrd = $service->resolve($query['resource'] ?? [], $query['rel'] ?? []);
webfinger_send(200, 'application/jrd+json', $jrd, (int) $config['cache_max_age'], (bool) $config['pretty_print']);
} catch (WebFingerError $e) {
webfinger_send(
$e->status(),
'application/json',
['error' => $e->errorCode(), 'error_description' => $e->getMessage()],
0,
(bool) $config['pretty_print']
);
}
}
// Define WEBFINGER_SKIP_BOOTSTRAP before requiring this file to load the
// classes without serving a request (useful in tests).
if (!defined('WEBFINGER_SKIP_BOOTSTRAP')) {
webfinger_main(WEBFINGER_CONFIG);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment