EN

Theme

Accent colour

Your theme and colour carry over to satelit.md and the app.

Dashboard Choose a plan

Sign in with Satelit

A standard OpenID Connect provider. Issuer: https://app.satelit.md. Code flow with PKCE S256 only.

The steps

  1. You create an app in the dashboard, with the redirect URIs. A URI must match exactly and be HTTPS; HTTP works only for localhost.
  2. You send the person to the authorization_endpoint, with code_challenge, state and nonce.
  3. You receive code and iss at the redirect URI. You check state and iss.
  4. You exchange the code for tokens at the token_endpoint. You check iss, aud, exp and nonce in the id_token.

OAuth apps

Discovery

All the addresses and supported values are here. Read them from here, do not hard-code them.

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

Scopes

  • openidrequired; gives the sub identifier
  • profilename, locale
  • emailemail, 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_basic or client_secret_post.

Tokens

TokenLifetimeNotes
id_token10 minutesES256. Tells you who the person is.
access_token10 minutesES256. For userinfo.
refresh_token60 daysWorks once and returns a new one. Used a second time, it cancels the whole token family.

Full example in JavaScript, in the 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 });
}

Full example in PHP, on the 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: /");

Sign-in without a click: prompt=none

  • Use prompt=none only 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_required and consent_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: true gets 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: false does 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”.