UK

Тема

Колір акценту

Тема й колір збережуться на 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. Людина може відключити застосунок у Налаштуваннях, у розділі «Підключені застосунки».