">Log in * Callback page : $user = AstrinaID::handleCallback(); // ['sub','provider','email','name','avatar'] or null * * Key each local account on ($user['provider'], $user['sub']) — sub is a stable, * opaque per-identity id. Do NOT trust email as a primary key. */ declare(strict_types=1); /* Direct HTTP hit → serve own source as a download; when require()d, define the class. */ if (PHP_SAPI !== 'cli' && isset($_SERVER['SCRIPT_FILENAME']) && @realpath($_SERVER['SCRIPT_FILENAME']) === __FILE__) { header('Content-Type: text/plain; charset=utf-8'); header('Content-Disposition: attachment; filename="astrina-id-sdk.php"'); header('X-Content-Type-Options: nosniff'); readfile(__FILE__); exit; } final class AstrinaID { public static function base(): string { return defined('ASTRINA_BASE') ? (string)ASTRINA_BASE : 'https://astrina.io'; } /** * Build the URL to send the user to. Stashes a CSRF state in the session. * Pass a provider key (e.g. 'google') to jump STRAIGHT into that provider — * the user never sees an Astrina chooser page, just a transparent redirect. * Leave it empty to show Astrina's provider chooser. */ public static function loginUrl(string $provider = ''): string { if (session_status() !== PHP_SESSION_ACTIVE) session_start(); $state = bin2hex(random_bytes(16)); $_SESSION['astrina_state'] = $state; return self::base() . '/auth/authorize.php?' . http_build_query(array_filter([ 'client_id' => ASTRINA_CLIENT_ID, 'redirect_uri' => ASTRINA_REDIRECT_URI, 'response_type' => 'code', 'state' => $state, 'provider' => $provider, ])); } /** Providers currently enabled on Astrina (cached in-session 5 min). */ public static function providers(): array { return self::directory()['providers']; } /** * Fetch (and cache in-session for 5 min) the enabled-provider directory from * Astrina: the provider list (each with an inline brand icon + deep-link) and * the owner's chosen button design. One network hit feeds both providers() and * buttonsHtml(). */ private static function directory(): array { if (session_status() !== PHP_SESSION_ACTIVE) session_start(); $c = $_SESSION['astrina_providers'] ?? null; if (is_array($c) && ($c['t'] ?? 0) > time() - 300 && isset($c['v']['providers'])) return $c['v']; $url = self::base() . '/auth/providers.php?' . http_build_query([ 'client_id' => ASTRINA_CLIENT_ID, 'redirect_uri' => ASTRINA_REDIRECT_URI, ]); $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 6]); $body = curl_exec($ch); curl_close($ch); $j = json_decode((string)$body, true); $dir = [ 'providers' => (is_array($j) && !empty($j['providers'])) ? $j['providers'] : [], 'design' => (is_array($j) && !empty($j['design'])) ? $j['design'] : [], ]; $_SESSION['astrina_providers'] = ['t' => time(), 'v' => $dir]; return $dir; } /** Blend a #hex toward a base colour (ratio 0..1). Solid result, so no CSS * color-mix is needed and the button renders on any browser. */ private static function tint(string $hex, float $r, string $base = 'ffffff'): string { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) return '#' . $base; $out = ''; for ($i = 0; $i < 3; $i++) { $c = hexdec(substr($hex, $i * 2, 2)); $b = hexdec(substr($base, $i * 2, 2)); $out .= str_pad(dechex((int)round($c * $r + $b * (1 - $r))), 2, '0', STR_PAD_LEFT); } return '#' . $out; } /** * Render a ready-to-drop-in set of "Continue with …" buttons for the enabled * providers — real brand icons, each deep-linking straight to its provider — * styled to match the design the owner chose in Astrina, plus the required * "Secured by Astrina ID" note so users know who handles sign-in. Fully inline: * no stylesheet, no icon assets, no fetch to any provider. $note localises the note. */ public static function buttonsHtml(string $note = 'Secured by Astrina ID'): string { $dir = self::directory(); $list = $dir['providers']; if (!$list) return ''; $d = $dir['design'] + ['style' => 'outline', 'shape' => 'rounded', 'size' => 'md', 'layout' => 'stack']; $radius = $d['shape'] === 'pill' ? '999px' : ($d['shape'] === 'square' ? '6px' : '11px'); $pad = $d['size'] === 'lg' ? '14px 16px' : '11px 14px'; $fs = $d['size'] === 'lg' ? '15.5px' : '14.5px'; $grid = $d['layout'] === 'grid'; $isz = $grid ? 27 : 22; $wrap = $grid ? 'display:grid;grid-template-columns:repeat(auto-fit,minmax(64px,1fr));gap:10px;max-width:360px' : 'display:flex;flex-direction:column;gap:10px;max-width:360px'; $h = '
'; foreach ($list as $p) { $u = htmlspecialchars((string)($p['login_url'] ?? '#'), ENT_QUOTES); $lb = htmlspecialchars((string)($p['label'] ?? ''), ENT_QUOTES); $pc = (string)($p['color'] ?? '#5b5bd6'); $pcE = htmlspecialchars($pc, ENT_QUOTES); $icon = (string)($p['icon'] ?? ''); if ($icon === '') $icon = ''; if ($d['style'] === 'brand') { $bg = 'background:' . self::tint($pc, 0.09) . ';border:1px solid ' . self::tint($pc, 0.34, 'e3e6ef'); } elseif ($d['style'] === 'soft') { $bg = 'background:#f6f7fb;border:1px solid #e6e8f0'; } else { $bg = 'background:#fff;border:1px solid #dcdfec'; } $btn = $bg . ';border-radius:' . $radius . ';padding:' . $pad . ';color:#1a1a2e;font-weight:600;' . 'font-size:' . $fs . ';text-decoration:none;box-sizing:border-box;' . ($grid ? 'display:flex;flex-direction:column;align-items:center;justify-content:center;aspect-ratio:1/1' : 'display:flex;align-items:center;gap:11px'); $ico = '' . $icon . ''; $label = $grid ? '' : 'Continue with ' . $lb . ''; $aria = $grid ? ' aria-label="Continue with ' . $lb . '" title="Continue with ' . $lb . '"' : ''; $h .= '' . $ico . $label . ''; } $h .= '
🔒 ' . $note . '
'; $h .= '
'; return $h; } /** * Run on your redirect page. Verifies state, exchanges the code server-side, * and returns the identity or null on any failure. * @return array{sub:string,provider:string,email:string,name:string,avatar:string}|null */ public static function handleCallback(): ?array { if (session_status() !== PHP_SESSION_ACTIVE) session_start(); if (!empty($_GET['error'])) return null; $code = (string)($_GET['code'] ?? ''); $state = (string)($_GET['state'] ?? ''); $want = (string)($_SESSION['astrina_state'] ?? ''); unset($_SESSION['astrina_state']); if ($code === '' || $state === '' || $want === '' || !hash_equals($want, $state)) return null; $ch = curl_init(self::base() . '/auth/token.php'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_TIMEOUT => 12, CURLOPT_POSTFIELDS => http_build_query([ 'client_id' => ASTRINA_CLIENT_ID, 'client_secret' => ASTRINA_CLIENT_SECRET, 'code' => $code, 'redirect_uri' => ASTRINA_REDIRECT_URI, ]), ]); $body = curl_exec($ch); $hc = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $j = json_decode((string)$body, true); if ($hc !== 200 || !is_array($j) || empty($j['ok'])) return null; return [ 'sub' => (string)($j['sub'] ?? ''), 'provider' => (string)($j['provider'] ?? ''), 'email' => (string)($j['email'] ?? ''), 'name' => (string)($j['name'] ?? ''), 'avatar' => (string)($j['avatar'] ?? ''), ]; } }