">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 = '