Created
August 11, 2026 13:46
-
-
Save gomasy/35ddffee70e513881f225d53d66be77c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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