RU

Тема

Цвет акцента

Тема и цвет сохранятся на satelit.md и в приложении.

Панель Выбрать тариф

Войти через Satelit

Стандартный провайдер OpenID Connect. Издатель: https://app.satelit.md. Только поток с кодом и PKCE S256.

Шаги

  1. Вы создаёте приложение в панели с адресами возврата. Адрес должен совпадать точно и быть HTTPS; HTTP работает только для localhost.
  2. Отправьте человека на authorization_endpoint с code_challenge, state и nonce.
  3. На адрес возврата приходят code и iss. Проверьте state и iss.
  4. Обменяйте код на токены в token_endpoint. Проверьте iss, aud, exp и nonce из id_token.

Приложения OAuth

Discovery

Все адреса и поддерживаемые значения здесь. Читайте их отсюда, а не прописывайте в коде.

discovery
curl https://app.satelit.md/.well-known/openid-configuration

Области доступа

  • openidобязательно; даёт идентификатор sub
  • profilename, locale
  • emailemail, email_verified

Других областей доступа нет. Ни один токен не даёт доступа к деньгам, семье, спискам, картам или геолокации, и ни один токен не открывает другой маршрут.

sub разный для каждого сайта. Два приложения не могут объединить своих пользователей.

Типы приложений

  • Публичное, в браузере или на телефоне: без секрета, только PKCE.
  • Конфиденциальное, на сервере: секрет виден один раз и передаётся через client_secret_basic или client_secret_post.

Токены

ТокенСрок жизниПримечания
id_token10 минутES256. Сообщает, кто этот человек.
access_token10 минутES256. Для userinfo.
refresh_token60 днейРаботает один раз и выдаёт новый. При повторном использовании аннулирует всю цепочку токенов.

Полный пример на JavaScript, в браузере

auth.js
// "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, на сервере

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