Войти через 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. Человек может отключить приложение в Настройках, в разделе «Подключённые приложения».