Sign in with Satelit
A standard OpenID Connect provider. Issuer: https://app.satelit.md. Code flow with PKCE S256 only.
The steps
- You create an app in the dashboard, with the redirect URIs. A URI must match exactly and be HTTPS; HTTP works only for localhost.
- You send the person to the
authorization_endpoint, withcode_challenge,stateandnonce. - You receive
codeandissat the redirect URI. You checkstateandiss. - You exchange the code for tokens at the
token_endpoint. You checkiss,aud,expandnoncein theid_token.
Discovery
All the addresses and supported values are here. Read them from here, do not hard-code them.
curl https://app.satelit.md/.well-known/openid-configurationScopes
openidrequired; gives thesubidentifierprofilename,localeemailemail,email_verified
There are no other scopes. No token gives access to money, family, lists, cards or location, and no token opens another route.
sub is different for each site. Two apps cannot join their users.
App types
- Public, in the browser or on a phone: no secret, PKCE only.
- Confidential, on a server: the secret appears only once and is sent with
client_secret_basicorclient_secret_post.
Tokens
| Token | Lifetime | Notes |
|---|---|---|
id_token | 10 minutes | ES256. Tells you who the person is. |
access_token | 10 minutes | ES256. For userinfo. |
refresh_token | 60 days | Works once and returns a new one. Used a second time, it cancels the whole token family. |
Full example in JavaScript, in the 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 });
}
Full example in PHP, on the 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: /");
Sign-in without a click: prompt=none
- Use
prompt=noneonly for a browser that has already signed in with Satelit on your site. You recognize it by a cookie of your own, with no personal data, which you set at the first sign-in. - Never for every visitor: it would add an extra trip and two uncached requests to every reader's first page.
- The responses are
login_requiredandconsent_required. On either one, the person stays on your page, signed out, with no error page. - One attempt per browser session. Set a session cookie before the redirect, so you do not enter a loop.
Existing accounts
- A new person with
email_verified: truegets a new account. - An address that already has an account with you is not merged silently. The person signs in the way they did before and links Satelit from the account page.
email_verified: falsedoes not link and does not create anything by address.
Signing out
Signing out of your site ends only your session. There is no end_session_endpoint: a link on another site cannot sign anyone out of Satelit. The person can disconnect the app in Settings, under “Connected apps”.