Verified Ham with PHP, no library
The whole flow in plain PHP with curl: authorize, exchange the code, read the claims.
No framework, no Composer packages: the whole sign-in in one file with curl. It reads the user's claims
from the userinfo endpoint with the access token, so there's no JWT to verify. It's also in the
repository as examples/php.
1. Register your app
- Create a Verified Ham account, if you don't have one, and sign in.
- Go to Register an app.
- Give it a name. People see it when they sign in.
- Set the redirect URI to:
http://localhost:8000/callback - Choose Confidential, and register.
- Copy the client ID and client secret. The secret is shown only once.
You can sign in to your own app straight away, to test. Other people can sign in once you've verified your own call sign.
2. The app
Save this as index.php and run VH_CLIENT_ID=… VH_CLIENT_SECRET=… php -S localhost:8000 index.php.
<?php
/**
* Sign in with Verified Ham, in plain PHP with no libraries.
*
* VH_CLIENT_ID=... VH_CLIENT_SECRET=... php -S localhost:8000 index.php
*
* Register the app at https://verifiedham.net/developers/apps/new with the
* redirect URI http://localhost:8000/callback, then open http://localhost:8000.
*
* It asks for the user's claims from the userinfo endpoint with the access
* token, which came straight from the token endpoint over TLS, so there is no
* ID token signature to check and no JWT library is needed.
*/
declare(strict_types=1);
$issuer = rtrim(getenv('VH_ISSUER') ?: 'https://verifiedham.net', '/');
$clientId = getenv('VH_CLIENT_ID') ?: exit("Set VH_CLIENT_ID and VH_CLIENT_SECRET.\n");
$clientSecret = getenv('VH_CLIENT_SECRET') ?: exit("Set VH_CLIENT_ID and VH_CLIENT_SECRET.\n");
$redirectUri = 'http://localhost:8000/callback';
session_start();
function base64url(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
/** @return array<string, mixed> */
function http(string $method, string $url, array $headers = [], ?string $body = null): array
{
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
] + ($body === null ? [] : [CURLOPT_POSTFIELDS => $body]));
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$data = json_decode((string) $response, true);
if ($status !== 200 || !is_array($data)) {
http_response_code(502);
exit(sprintf("%s %s failed (HTTP %d): %s\n", $method, $url, $status, $response));
}
return $data;
}
/** The provider's endpoints, from its discovery document. */
function discovery(string $issuer): array
{
return $_SESSION['discovery'] ??= http('GET', $issuer . '/.well-known/openid-configuration');
}
$h = static fn (mixed $s): string => htmlspecialchars((string) $s, ENT_QUOTES);
switch (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) {
case '/login':
// PKCE: a fresh secret verifier for this sign-in, sent later with the
// code; only its hash goes in the link. Plus state, against CSRF.
$_SESSION['verifier'] = base64url(random_bytes(32));
$_SESSION['state'] = base64url(random_bytes(16));
header('Location: ' . discovery($issuer)['authorization_endpoint'] . '?' . http_build_query([
'response_type' => 'code',
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'scope' => 'openid callsign',
'state' => $_SESSION['state'],
'code_challenge' => base64url(hash('sha256', $_SESSION['verifier'], true)),
'code_challenge_method' => 'S256',
]));
exit;
case '/callback':
if (isset($_GET['error'])) {
exit('Sign-in failed: ' . $h($_GET['error_description'] ?? $_GET['error']));
}
if (!isset($_GET['code'], $_GET['state'], $_SESSION['state']) || !hash_equals($_SESSION['state'], $_GET['state'])) {
http_response_code(400);
exit('That sign-in link has expired. <a href="/login">Try again</a>.');
}
$tokens = http('POST', discovery($issuer)['token_endpoint'], [
'Authorization: Basic ' . base64_encode(rawurlencode($clientId) . ':' . rawurlencode($clientSecret)),
'Content-Type: application/x-www-form-urlencoded',
], http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => $redirectUri,
'code_verifier' => $_SESSION['verifier'],
]));
$_SESSION['user'] = http('GET', discovery($issuer)['userinfo_endpoint'], [
'Authorization: Bearer ' . $tokens['access_token'],
]);
unset($_SESSION['state'], $_SESSION['verifier']);
header('Location: /');
exit;
case '/logout':
session_destroy();
header('Location: /');
exit;
default:
$user = $_SESSION['user'] ?? null;
if ($user === null) {
echo '<p><a href="/login">Sign in with Verified Ham</a></p>';
} elseif (($user['callsign_verified'] ?? false) === true) {
// Key your users by sub: it never changes, even if their call sign does.
echo '<p>Signed in as <strong>' . $h($user['callsign']) . '</strong>, verified. ';
} elseif (isset($user['callsign'])) {
echo '<p>Signed in. ' . $h($user['callsign']) . ' is still being verified, so treat it as unconfirmed. ';
} else {
echo '<p>Signed in, without a call sign yet. ';
}
if ($user !== null) {
echo '<a href="/logout">Sign out</a></p><pre>' . $h(json_encode($user, JSON_PRETTY_PRINT)) . '</pre>';
}
}
How it works
- /login makes a PKCE verifier and a state, keeps them in the session, and sends the user to
https://verifiedham.net/authorizewith the verifier's SHA-256 hash. - /callback checks the state, then exchanges the code at the token endpoint, sending the verifier and authenticating with the client secret.
- It calls the userinfo endpoint with the access token and keeps the claims:
sub,callsignandcallsign_verified.
Prefer a library in production: jumbojett/openid-connect-php
supports PKCE with setCodeChallengeMethod('S256').