Увійти через Satelit
Стандартний провайдер OpenID Connect. Емітент: https://app.satelit.md. Лише потік із кодом і PKCE S256.
Кроки
- Ви створюєте застосунок у панелі й вказуєте адреси повернення. Адреса має збігатися точно й бути HTTPS; HTTP працює лише для localhost.
- Ви надсилаєте людину на
authorization_endpointізcode_challenge,stateіnonce. - На адресі повернення ви отримуєте
codeіiss. Перевіряєтеstateтаiss. - Ви обмінюєте код на токени в
token_endpoint. Перевіряєтеiss,aud,expіnonceзid_token.
Discovery
Усі адреси й підтримувані значення тут. Читайте їх звідси, а не вписуйте в код.
curl https://app.satelit.md/.well-known/openid-configurationОбласті доступу
openidобов'язкова; дає ідентифікаторsubprofilename,localeemailemail,email_verified
Інших областей немає. Жоден токен не дає доступу до грошей, сім'ї, списків, карток чи локації, і жоден токен не відкриває інший маршрут.
sub різний для кожного сайту. Два застосунки не можуть об'єднати своїх користувачів.
Типи застосунків
- Публічний, у браузері чи на телефоні: без секрету, лише PKCE.
- Конфіденційний, на сервері: секрет показується лише раз і надсилається через
client_secret_basicабоclient_secret_post.
Токени
| Токен | Тривалість | Примітки |
|---|---|---|
id_token | 10 хвилин | ES256. Каже, хто ця людина. |
access_token | 10 хвилин | ES256. Для userinfo. |
refresh_token | 60 днів | Спрацьовує один раз і дає новий. Якщо використати вдруге, скасовує всю родину токенів. |
Повний приклад на JavaScript, у браузері
// "Sign in with Satelit" in the browser: a public client, PKCE S256, no secret, no library.
const ISSUER = "https://app.satelit.md";
const CLIENT_ID = "sat_your_client_id";
const REDIRECT_URI = location.origin + "/auth/callback";
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
const discovery = () => fetch(ISSUER + "/.well-known/openid-configuration").then((r) => r.json());
export async function signIn({ silent = false } = {}) {
const { authorization_endpoint } = await discovery();
const verifier = random();
const state = random();
const nonce = random();
sessionStorage.setItem("satelit_oidc", JSON.stringify({ verifier, state, nonce }));
const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
const url = new URL(authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: "openid profile email",
state, nonce, code_challenge: challenge, code_challenge_method: "S256",
...(silent ? { prompt: "none" } : {}),
});
location.assign(url);
}
// Runs on the page at REDIRECT_URI. Returns the person's claims, or null when they stay signed out.
export async function handleCallback() {
const q = new URLSearchParams(location.search);
const saved = JSON.parse(sessionStorage.getItem("satelit_oidc") ?? "null");
sessionStorage.removeItem("satelit_oidc");
if (!saved || q.get("state") !== saved.state) throw new Error("state does not match");
if (q.get("iss") !== ISSUER) throw new Error("wrong issuer");
// login_required or consent_required after prompt=none: the person simply stays signed out
if (q.get("error")) return null;
const { token_endpoint } = await discovery();
const res = await fetch(token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code: q.get("code"), redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: saved.verifier }),
});
const tokens = await res.json();
if (!res.ok) throw new Error(tokens.error);
const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(tokens.id_token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
if (claims.iss !== ISSUER || claims.aud !== CLIENT_ID || claims.nonce !== saved.nonce || claims.exp * 1000 < Date.now()) throw new Error("bad id token");
// Your first-party hint: this browser has signed in with Satelit before. No personal data in it.
document.cookie = "satelit_before=1; Path=/; Max-Age=31536000; SameSite=Lax; Secure";
return claims; // sub, name, locale, email, email_verified
}
// On the next visit: try silently, only for a browser that signed in before, once per browser session.
export function trySilentSignIn() {
const before = document.cookie.includes("satelit_before=1");
const tried = document.cookie.includes("satelit_tried=1");
if (!before || tried) return;
document.cookie = "satelit_tried=1; Path=/; SameSite=Lax; Secure";
signIn({ silent: true });
}
Повний приклад на PHP, на сервері
<?php
// "Sign in with Satelit" on a PHP server: a confidential client, PKCE S256, one file. Needs ext-curl and ext-json.
// /auth.php?start=1 begins the sign-in; the redirect URI points back at this file.
session_start();
const ISSUER = "https://app.satelit.md";
const CLIENT_ID = "sat_your_client_id";
define("CLIENT_SECRET", getenv("SATELIT_CLIENT_SECRET"));
const REDIRECT_URI = "https://example.com/auth.php";
function http(string $url, ?array $form = null): array {
$ch = curl_init($url);
$opts = [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10];
if ($form !== null) {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = http_build_query($form);
$opts[CURLOPT_USERPWD] = CLIENT_ID . ":" . CLIENT_SECRET; // client_secret_basic
}
curl_setopt_array($ch, $opts);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status >= 400 || !is_array($body)) {
http_response_code(502);
exit("Sign-in failed");
}
return $body;
}
function b64url(string $bytes): string {
return rtrim(strtr(base64_encode($bytes), "+/", "-_"), "=");
}
$config = http(ISSUER . "/.well-known/openid-configuration");
if (isset($_GET["start"])) {
$_SESSION["oidc"] = [
"verifier" => b64url(random_bytes(32)),
"state" => b64url(random_bytes(32)),
"nonce" => b64url(random_bytes(32)),
];
$query = http_build_query([
"response_type" => "code",
"client_id" => CLIENT_ID,
"redirect_uri" => REDIRECT_URI,
"scope" => "openid profile email",
"state" => $_SESSION["oidc"]["state"],
"nonce" => $_SESSION["oidc"]["nonce"],
"code_challenge" => b64url(hash("sha256", $_SESSION["oidc"]["verifier"], true)),
"code_challenge_method" => "S256",
]);
header("Location: " . $config["authorization_endpoint"] . "?" . $query);
exit;
}
$saved = $_SESSION["oidc"] ?? null;
unset($_SESSION["oidc"]);
if (!$saved || !hash_equals($saved["state"], $_GET["state"] ?? "")) {
http_response_code(400);
exit("State does not match");
}
if (($_GET["iss"] ?? "") !== ISSUER) {
http_response_code(400);
exit("Wrong issuer");
}
if (isset($_GET["error"])) {
header("Location: /"); // login_required, consent_required, access_denied: stay signed out
exit;
}
$tokens = http($config["token_endpoint"], [
"grant_type" => "authorization_code",
"code" => $_GET["code"] ?? "",
"redirect_uri" => REDIRECT_URI,
"code_verifier" => $saved["verifier"],
]);
// The id token came straight from the token endpoint over TLS, so its claims are checked here.
$claims = json_decode(base64_decode(strtr(explode(".", $tokens["id_token"])[1], "-_", "+/")), true);
if ($claims["iss"] !== ISSUER || $claims["aud"] !== CLIENT_ID || $claims["nonce"] !== $saved["nonce"] || $claims["exp"] < time()) {
http_response_code(400);
exit("Bad id token");
}
$_SESSION["user"] = [
"sub" => $claims["sub"],
"name" => $claims["name"] ?? null,
"email" => $claims["email"] ?? null,
"email_verified" => $claims["email_verified"] ?? false,
];
// Your first-party hint: this browser has signed in with Satelit before. No personal data in it.
setcookie("satelit_before", "1", ["expires" => time() + 31536000, "path" => "/", "secure" => true, "samesite" => "Lax"]);
header("Location: /");
Вхід без кліка: prompt=none
- Використовуйте
prompt=noneлише для браузера, який уже входив через Satelit на вашому сайті. Ви впізнаєте його за вашим cookie без персональних даних, який ставите під час першого входу. - Ніколи для кожного відвідувача: це додає зайвий перехід і два некешовані запити на першій сторінці кожного читача.
- Відповіді:
login_requiredіconsent_required. У будь-якому з цих випадків людина залишається на вашій сторінці, без входу, без сторінки помилки. - Лише одна спроба за сесію браузера. Поставте cookie сесії перед перенаправленням, щоб не потрапити в цикл.
Наявні облікові записи
- Нова людина з
email_verified: trueотримує новий обліковий запис. - Адреса, яка вже має обліковий запис у вас, не об'єднується мовчки. Людина входить, як раніше, і прив'язує Satelit зі сторінки облікового запису.
email_verified: falseнічого не прив'язує й не створює за адресою.
Вихід
Вихід на вашому сайті завершує лише вашу сесію. Немає end_session_endpoint: посилання з іншого сайту не може вивести нікого із Satelit. Людина може відключити застосунок у Налаштуваннях, у розділі «Підключені застосунки».