RO

Tema

Culoarea de accent

Tema și culoarea te urmează pe satelit.md și în aplicație.

Panou Alege un plan

Intră cu Satelit

Un furnizor OpenID Connect standard. Emitent: https://app.satelit.md. Doar flux cu cod și PKCE S256.

Pașii

  1. Creezi o aplicație în panou, cu adresele de întoarcere. O adresă trebuie să coincidă exact și să fie HTTPS; HTTP merge doar pentru localhost.
  2. Trimiți omul la authorization_endpoint, cu code_challenge, state și nonce.
  3. Primești code și iss pe adresa de întoarcere. Verifici state și iss.
  4. Schimbi codul pe tokenuri la token_endpoint. Verifici iss, aud, exp și nonce din id_token.

Aplicații OAuth

Discovery

Toate adresele și valorile acceptate sunt aici. Citește-le de aici, nu le scrie în cod.

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

Scopuri

  • openidobligatoriu; dă identificatorul sub
  • profilename, locale
  • emailemail, email_verified

Alte scopuri nu există. Niciun token nu dă acces la bani, familie, liste, carduri sau locație, și niciun token nu deschide altă rută.

sub este diferit pentru fiecare site. Două aplicații nu își pot uni utilizatorii.

Tipuri de aplicații

  • Publică, în browser sau pe telefon: fără secret, doar PKCE.
  • Confidențială, pe server: secretul apare o singură dată și se trimite cu client_secret_basic sau client_secret_post.

Tokenuri

TokenDuratăNote
id_token10 minuteES256. Îți spune cine e omul.
access_token10 minuteES256. Pentru userinfo.
refresh_token60 de zileMerge o singură dată și dă altul nou. Folosit a doua oară, anulează toată familia de tokenuri.

Exemplu complet în JavaScript, în browser

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 });
}

Exemplu complet în PHP, pe server

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: /");

Intrare fără clic: prompt=none

  • Folosește prompt=none doar pentru un browser care a mai intrat cu Satelit pe site-ul tău. Îl recunoști după un cookie al tău, fără date personale, pe care îl pui la prima intrare.
  • Niciodată pentru fiecare vizitator: ar adăuga un drum în plus și două cereri necacheate la prima pagină a fiecărui cititor.
  • Răspunsurile sunt login_required și consent_required. La oricare dintre ele, omul rămâne pe pagina ta, neautentificat, fără nicio pagină de eroare.
  • O singură încercare pe sesiune de browser. Pune un cookie de sesiune înainte de redirecționare, ca să nu intri într-o buclă.

Conturile existente

  • Un om nou cu email_verified: true primește un cont nou.
  • O adresă care are deja cont la tine nu se unește pe tăcute. Omul intră cum intra și leagă Satelit din pagina contului.
  • email_verified: false nu leagă și nu creează nimic după adresă.

Ieșirea

Ieșirea de pe site-ul tău încheie doar sesiunea ta. Nu există un end_session_endpoint: un link de pe alt site nu poate scoate pe nimeni din Satelit. Omul poate deconecta aplicația din Setări, la „Aplicații conectate”.