Intră cu Satelit
Un furnizor OpenID Connect standard. Emitent: https://app.satelit.md. Doar flux cu cod și PKCE S256.
Pașii
- 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.
- Trimiți omul la
authorization_endpoint, cucode_challenge,stateșinonce. - Primești
codeșiisspe adresa de întoarcere. Verificistateșiiss. - Schimbi codul pe tokenuri la
token_endpoint. Verificiiss,aud,expșinoncedinid_token.
Discovery
Toate adresele și valorile acceptate sunt aici. Citește-le de aici, nu le scrie în cod.
curl https://app.satelit.md/.well-known/openid-configurationScopuri
openidobligatoriu; dă identificatorulsubprofilename,localeemailemail,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_basicsauclient_secret_post.
Tokenuri
| Token | Durată | Note |
|---|---|---|
id_token | 10 minute | ES256. Îți spune cine e omul. |
access_token | 10 minute | ES256. Pentru userinfo. |
refresh_token | 60 de zile | Merge o singură dată și dă altul nou. Folosit a doua oară, anulează toată familia de tokenuri. |
Exemplu complet în JavaScript, în browser
// "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
<?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=nonedoar 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șiconsent_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: trueprimeș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: falsenu 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”.