Integration examples

Complete code, not fragments. Each example runs against https://auth.mesogeos-digital.com once you fill in a client id.

1 What you need

An administrator registers your application and gives you the first two. The rest you decide.

Client id Identifies your application. Not secret.
Client secret Confidential clients only — a server your users cannot read. Never in a browser or a mobile binary; use PKCE there instead.
Redirect URI Where the user comes back to. Registered in advance and matched exactly — protocol, host, port and path.
Scopes What you are asking for. See the table below.
Server https://auth.mesogeos-digital.com

Scopes this server knows

ScopeGrants
profile Access to basic profile information (name, picture, locale, etc.).
email Access to the user's primary email address.
phone Access to the user's phone number.
address Access to the user's physical address.
user Access to user account information.
openid Required for OpenID Connect requests — enables the ID Token.
offline_access Allow the application to perform actions on your behalf when you are not online (issues a refresh token).
system:admin Full administrative access — grants all standard scopes.
system:audit_read Read access to audit logs.
system:health Access to system health and monitoring data.
system:notifications_read Read user notifications.
system:notifications_write Send and manage user notifications.

2 Authorization code with PKCE

The flow to use unless you have a specific reason not to. PKCE costs two lines and removes the need for a client secret, so the same code works in a browser, on a phone and on a server.

/**
 * Authorization Code + PKCE, no client secret.
 *
 * Two details are load-bearing and easy to get wrong:
 *   - `state` must be compared on return. It is the CSRF protection.
 *   - `code_verifier` must survive the redirect, and must not be readable by
 *     another origin. sessionStorage is the usual answer.
 */
const AUTH_SERVER = 'https://auth.mesogeos-digital.com';
const CLIENT_ID   = 'your-client-id';
const REDIRECT_URI = window.location.origin + '/callback';

/** Cryptographically random, URL-safe. `Math.random()` is not a substitute. */
function randomUrlSafe(bytes = 32) {
    const buffer = new Uint8Array(bytes);
    crypto.getRandomValues(buffer);
    return base64Url(buffer);
}

function base64Url(bytes) {
    return btoa(String.fromCharCode(...bytes))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function challengeFor(verifier) {
    const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
    return base64Url(new Uint8Array(digest));
}

/** Step 1 — send the user to the authorization endpoint. */
async function beginLogin() {
    const verifier = randomUrlSafe();
    const state    = randomUrlSafe(16);

    sessionStorage.setItem('pkce_verifier', verifier);
    sessionStorage.setItem('oauth_state', state);

    const params = new URLSearchParams({
        response_type:         'code',
        client_id:             CLIENT_ID,
        redirect_uri:          REDIRECT_URI,
        scope:                 'openid profile email',
        state:                 state,
        code_challenge:        await challengeFor(verifier),
        code_challenge_method: 'S256',
    });

    window.location.assign(AUTH_SERVER + '/oauth/authorize?' + params);
}

/** Step 2 — run this on your redirect URI page. */
async function completeLogin() {
    const query = new URLSearchParams(window.location.search);

    if (query.has('error')) {
        throw new Error(query.get('error_description') || query.get('error'));
    }

    const expected = sessionStorage.getItem('oauth_state');
    if (!expected || query.get('state') !== expected) {
        // Not our request. Refuse rather than continue.
        throw new Error('state mismatch');
    }
    sessionStorage.removeItem('oauth_state');

    const verifier = sessionStorage.getItem('pkce_verifier');
    sessionStorage.removeItem('pkce_verifier');

    const response = await fetch(AUTH_SERVER + '/oauth/token', {
        method:  'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body:    new URLSearchParams({
            grant_type:    'authorization_code',
            client_id:     CLIENT_ID,
            code:          query.get('code'),
            redirect_uri:  REDIRECT_URI,
            code_verifier: verifier,
        }),
    });

    const token = await response.json();
    if (!response.ok) {
        throw new Error(token.error_description || token.error || 'token request failed');
    }

    return token; // { access_token, token_type, expires_in, refresh_token, id_token }
}

/** Step 3 — call an API, and refresh on 401 rather than on a timer. */
async function callApi(path, token) {
    const response = await fetch(AUTH_SERVER + path, {
        headers: { Authorization: 'Bearer ' + token.access_token },
    });

    if (response.status === 401 && token.refresh_token) {
        const refreshed = await refresh(token.refresh_token);
        return callApi(path, refreshed);
    }

    return response.json();
}

async function refresh(refreshToken) {
    const response = await fetch(AUTH_SERVER + '/oauth/token', {
        method:  'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body:    new URLSearchParams({
            grant_type:    'refresh_token',
            client_id:     CLIENT_ID,
            refresh_token: refreshToken,
        }),
    });
    return response.json();
}
<?php
/**
 * Authorization Code + PKCE from a PHP application.
 *
 * PKCE is used even though a server can keep a secret: it costs nothing and it
 * closes the code-interception window if a redirect ever leaks.
 */
declare(strict_types=1);

const AUTH_SERVER  = 'https://auth.mesogeos-digital.com';
const CLIENT_ID    = 'your-client-id';
const CLIENT_SECRET = 'your-client-secret';   // omit entirely for a public client
const REDIRECT_URI = 'https://your-app.example.com/callback';

function base64Url(string $raw): string
{
    return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}

/** Step 1 — redirect the user. */
function beginLogin(): never
{
    session_start();

    $verifier = base64Url(random_bytes(32));
    $state    = base64Url(random_bytes(16));

    // Both must survive the round trip, and neither may reach the browser.
    $_SESSION['pkce_verifier'] = $verifier;
    $_SESSION['oauth_state']   = $state;

    $query = http_build_query([
        'response_type'         => 'code',
        'client_id'             => CLIENT_ID,
        'redirect_uri'          => REDIRECT_URI,
        'scope'                 => 'openid profile email',
        'state'                 => $state,
        'code_challenge'        => base64Url(hash('sha256', $verifier, true)),
        'code_challenge_method' => 'S256',
    ]);

    header('Location: ' . AUTH_SERVER . '/oauth/authorize?' . $query);
    exit;
}

/** Step 2 — handle the callback. */
function completeLogin(): array
{
    session_start();

    if (isset($_GET['error'])) {
        throw new RuntimeException((string) ($_GET['error_description'] ?? $_GET['error']));
    }

    $expected = $_SESSION['oauth_state'] ?? '';
    unset($_SESSION['oauth_state']);

    // hash_equals, not ===: this is a comparison against attacker-supplied input.
    if ($expected === '' || !hash_equals($expected, (string) ($_GET['state'] ?? ''))) {
        throw new RuntimeException('state mismatch — this response is not ours');
    }

    $verifier = $_SESSION['pkce_verifier'] ?? '';
    unset($_SESSION['pkce_verifier']);

    $body = [
        'grant_type'    => 'authorization_code',
        'client_id'     => CLIENT_ID,
        'code'          => (string) ($_GET['code'] ?? ''),
        'redirect_uri'  => REDIRECT_URI,
        'code_verifier' => $verifier,
    ];
    if (defined('CLIENT_SECRET') && CLIENT_SECRET !== '') {
        $body['client_secret'] = CLIENT_SECRET;
    }

    return post('/oauth/token', $body);
}

/** POST a form-encoded body and decode the JSON answer. */
function post(string $path, array $body): array
{
    $handle = curl_init(AUTH_SERVER . $path);
    curl_setopt_array($handle, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($body),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Content-Type: application/x-www-form-urlencoded'],
        CURLOPT_TIMEOUT        => 10,
    ]);

    $raw    = curl_exec($handle);
    $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
    $error  = curl_error($handle);
    curl_close($handle);

    if ($raw === false) {
        throw new RuntimeException('could not reach the authorization server: ' . $error);
    }

    $decoded = json_decode((string) $raw, true);
    if (!is_array($decoded)) {
        // A 200 carrying HTML usually means the path did not reach the server.
        throw new RuntimeException('the authorization server did not answer with JSON');
    }
    if ($status >= 400 || isset($decoded['error'])) {
        throw new RuntimeException($decoded['error_description'] ?? $decoded['error'] ?? 'HTTP ' . $status);
    }

    return $decoded;
}

/** Who does this token belong to? */
function userinfo(string $accessToken): array
{
    $handle = curl_init(AUTH_SERVER . '/oauth/userinfo');
    curl_setopt_array($handle, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $accessToken],
        CURLOPT_TIMEOUT        => 10,
    ]);
    $raw = curl_exec($handle);
    curl_close($handle);

    return json_decode((string) $raw, true) ?: [];
}
"""
Authorization Code + PKCE, with `requests`.

The two things worth copying carefully are the `state` comparison and keeping the
verifier server-side. Everything else is form encoding.
"""
import base64
import hashlib
import secrets
from urllib.parse import urlencode

import requests

AUTH_SERVER   = "https://auth.mesogeos-digital.com"
CLIENT_ID     = "your-client-id"
CLIENT_SECRET = None                 # None for a public client
REDIRECT_URI  = "https://your-app.example.com/callback"


def _b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()


def begin_login() -> tuple[str, str, str]:
    \"\"\"Return (authorization_url, verifier, state). Store the last two.\"\"\"
    verifier = _b64url(secrets.token_bytes(32))
    state = _b64url(secrets.token_bytes(16))
    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())

    query = urlencode({
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "openid profile email",
        "state": state,
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })
    return f"{AUTH_SERVER}/oauth/authorize?{query}", verifier, state


def complete_login(code: str, returned_state: str, verifier: str, expected_state: str) -> dict:
    \"\"\"Exchange the code. Refuses a response whose state does not match.\"\"\"
    # compare_digest, not ==: this input comes from the network.
    if not secrets.compare_digest(returned_state, expected_state):
        raise ValueError("state mismatch — this response is not ours")

    body = {
        "grant_type": "authorization_code",
        "client_id": CLIENT_ID,
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": verifier,
    }
    if CLIENT_SECRET:
        body["client_secret"] = CLIENT_SECRET

    response = requests.post(f"{AUTH_SERVER}/oauth/token", data=body, timeout=10)
    payload = response.json()

    if response.status_code >= 400 or "error" in payload:
        raise RuntimeError(payload.get("error_description") or payload.get("error"))

    return payload


def userinfo(access_token: str) -> dict:
    response = requests.get(
        f"{AUTH_SERVER}/oauth/userinfo",
        headers={"Authorization": f"Bearer {access_token}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()


def refresh(refresh_token: str) -> dict:
    body = {
        "grant_type": "refresh_token",
        "client_id": CLIENT_ID,
        "refresh_token": refresh_token,
    }
    if CLIENT_SECRET:
        body["client_secret"] = CLIENT_SECRET

    response = requests.post(f"{AUTH_SERVER}/oauth/token", data=body, timeout=10)
    response.raise_for_status()
    return response.json()
# Authorization Code + PKCE from a shell — useful for checking a client
# registration before writing any application code.

AUTH_SERVER="https://auth.mesogeos-digital.com"
CLIENT_ID="your-client-id"
REDIRECT_URI="https://your-app.example.com/callback"

# 1 — generate the verifier and its challenge
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CHALLENGE=$(printf '%s' "$VERIFIER" \
    | openssl dgst -binary -sha256 \
    | openssl base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)

# 2 — open this in a browser and sign in
echo "$AUTH_SERVER/oauth/authorize?response_type=code\
&client_id=$CLIENT_ID\
&redirect_uri=$REDIRECT_URI\
&scope=openid%20profile%20email\
&state=$STATE\
&code_challenge=$CHALLENGE\
&code_challenge_method=S256"

# 3 — copy the `code` from the URL you land on, then exchange it
CODE="paste-it-here"

curl -sS -X POST "$AUTH_SERVER/oauth/token" \
    -d grant_type=authorization_code \
    -d client_id="$CLIENT_ID" \
    -d code="$CODE" \
    -d redirect_uri="$REDIRECT_URI" \
    -d code_verifier="$VERIFIER" | jq .

# 4 — use it
ACCESS_TOKEN="from-the-response"

curl -sS "$AUTH_SERVER/oauth/userinfo" \
    -H "Authorization: Bearer $ACCESS_TOKEN" | jq .

3 Device flow (RFC 8628)

For anything that can display a short code but cannot host a browser — a TV, a set-top box, a headless tool. The device polls; the user authorizes from a phone.

/**
 * Device flow from Node, or from anything with fetch.
 *
 * The loop is the whole thing: ask for a code, show it, then poll patiently
 * until the user finishes, refuses, or the code ages out.
 */
const AUTH_SERVER = 'https://auth.mesogeos-digital.com';
const CLIENT_ID   = 'your-device-client-id';

const sleep = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000));

async function form(path, body) {
    const response = await fetch(AUTH_SERVER + path, {
        method:  'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body:    new URLSearchParams(body),
    });
    return { status: response.status, payload: await response.json() };
}

async function authorizeDevice() {
    // 1 — ask for a code
    const { payload: device } = await form('/oauth/deviceauthorization', {
        client_id: CLIENT_ID,
        scope:     'openid profile',
    });

    if (device.error) {
        throw new Error(device.error_description || device.error);
    }

    // 2 — show it. The complete URI has the code embedded, which saves typing
    //     when the device can render a QR code.
    console.log('Go to:  ' + device.verification_uri);
    console.log('Code:   ' + device.user_code);
    console.log('Or:     ' + device.verification_uri_complete);

    // 3 — poll, no faster than the server asked for
    let interval = device.interval || 5;
    const deadline = Date.now() + (device.expires_in || 1800) * 1000;

    while (Date.now() < deadline) {
        await sleep(interval);

        const { payload } = await form('/oauth/token', {
            grant_type:  'urn:ietf:params:oauth:grant-type:device_code',
            client_id:   CLIENT_ID,
            device_code: device.device_code,
        });

        if (payload.access_token) {
            return payload;
        }

        switch (payload.error) {
            case 'authorization_pending':
                break;                       // expected; keep waiting
            case 'slow_down':
                interval += 5;               // back off, do not retry immediately
                break;
            case 'access_denied':
                throw new Error('the user declined');
            case 'expired_token':
                throw new Error('the code expired — start again');
            default:
                throw new Error(payload.error_description || payload.error);
        }
    }

    throw new Error('timed out waiting for authorization');
}
<?php
/**
 * Device flow from PHP — a CLI tool or a kiosk process.
 */
declare(strict_types=1);

const AUTH_SERVER = 'https://auth.mesogeos-digital.com';
const CLIENT_ID   = 'your-device-client-id';

function form(string $path, array $body): array
{
    $handle = curl_init(AUTH_SERVER . $path);
    curl_setopt_array($handle, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($body),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 15,
    ]);
    $raw = curl_exec($handle);
    curl_close($handle);

    return json_decode((string) $raw, true) ?: ['error' => 'unreachable'];
}

function authorizeDevice(): array
{
    $device = form('/oauth/deviceauthorization', [
        'client_id' => CLIENT_ID,
        'scope'     => 'openid profile',
    ]);

    if (isset($device['error'])) {
        throw new RuntimeException($device['error_description'] ?? $device['error']);
    }

    echo 'Go to:  ' . $device['verification_uri'] . PHP_EOL;
    echo 'Code:   ' . $device['user_code'] . PHP_EOL;

    $interval = (int) ($device['interval'] ?? 5);
    $deadline = time() + (int) ($device['expires_in'] ?? 1800);

    while (time() < $deadline) {
        sleep($interval);

        $token = form('/oauth/token', [
            'grant_type'  => 'urn:ietf:params:oauth:grant-type:device_code',
            'client_id'   => CLIENT_ID,
            'device_code' => $device['device_code'],
        ]);

        if (isset($token['access_token'])) {
            return $token;
        }

        switch ($token['error'] ?? '') {
            case 'authorization_pending':
                break;                  // keep waiting
            case 'slow_down':
                $interval += 5;         // the server asked us to ease off
                break;
            case 'access_denied':
                throw new RuntimeException('the user declined');
            case 'expired_token':
                throw new RuntimeException('the code expired — start again');
            default:
                throw new RuntimeException($token['error_description'] ?? $token['error'] ?? 'unknown error');
        }
    }

    throw new RuntimeException('timed out waiting for authorization');
}
\"\"\"Device flow with `requests`.\"\"\"
import time

import requests

AUTH_SERVER = "https://auth.mesogeos-digital.com"
CLIENT_ID = "your-device-client-id"


def authorize_device() -> dict:
    device = requests.post(
        f"{AUTH_SERVER}/oauth/deviceauthorization",
        data={"client_id": CLIENT_ID, "scope": "openid profile"},
        timeout=15,
    ).json()

    if "error" in device:
        raise RuntimeError(device.get("error_description") or device["error"])

    print("Go to: ", device["verification_uri"])
    print("Code:  ", device["user_code"])

    interval = device.get("interval", 5)
    deadline = time.time() + device.get("expires_in", 1800)

    while time.time() < deadline:
        time.sleep(interval)

        token = requests.post(
            f"{AUTH_SERVER}/oauth/token",
            data={
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "client_id": CLIENT_ID,
                "device_code": device["device_code"],
            },
            timeout=15,
        ).json()

        if "access_token" in token:
            return token

        error = token.get("error")
        if error == "authorization_pending":
            continue                      # expected
        if error == "slow_down":
            interval += 5                 # back off rather than retry
            continue
        if error == "access_denied":
            raise RuntimeError("the user declined")
        if error == "expired_token":
            raise RuntimeError("the code expired — start again")
        raise RuntimeError(token.get("error_description") or error)

    raise TimeoutError("timed out waiting for authorization")
AUTH_SERVER="https://auth.mesogeos-digital.com"
CLIENT_ID="your-device-client-id"

# 1 — ask for a code
curl -sS -X POST "$AUTH_SERVER/oauth/deviceauthorization" \
    -d client_id="$CLIENT_ID" \
    -d scope="openid profile" | jq .

# {
#   "device_code": "GmRhmhcx…",
#   "user_code": "WDJB-MJHT",
#   "verification_uri": "$AUTH_SERVER/Device",
#   "verification_uri_complete": "$AUTH_SERVER/Device?user_code=WDJB-MJHT",
#   "expires_in": 1800,
#   "interval": 5
# }

# 2 — open verification_uri in a browser and enter the user_code

# 3 — poll. Wait `interval` seconds between attempts; on slow_down, add five.
DEVICE_CODE="from-the-response"

while true; do
    RESPONSE=$(curl -sS -X POST "$AUTH_SERVER/oauth/token" \
        -d grant_type="urn:ietf:params:oauth:grant-type:device_code" \
        -d client_id="$CLIENT_ID" \
        -d device_code="$DEVICE_CODE")

    echo "$RESPONSE" | jq -r '.error // "authorized"'
    echo "$RESPONSE" | jq -e '.access_token' >/dev/null 2>&1 && break

    case "$(echo "$RESPONSE" | jq -r '.error')" in
        authorization_pending) sleep 5 ;;
        slow_down)             sleep 10 ;;
        *)                     echo "$RESPONSE" | jq .; break ;;
    esac
done

4 Token operations

The four calls you make after a flow completes. All shown with curl; the shape is the same in any language.

AUTH_SERVER="https://auth.mesogeos-digital.com"
CLIENT_ID="your-client-id"
CLIENT_SECRET="your-client-secret"
ACCESS_TOKEN="…"

# Who is this token for?
curl -sS "$AUTH_SERVER/oauth/userinfo" \
    -H "Authorization: Bearer $ACCESS_TOKEN" | jq .

# Is it still good?  (RFC 7662 — for a resource server, not for the client that
# holds the token; a client already knows.)
curl -sS -X POST "$AUTH_SERVER/oauth/introspect" \
    -u "$CLIENT_ID:$CLIENT_SECRET" \
    -d token="$ACCESS_TOKEN" | jq .
# {"active":true,"scope":"openid profile","client_id":"…","exp":1640995200}

# Renew it
curl -sS -X POST "$AUTH_SERVER/oauth/token" \
    -d grant_type=refresh_token \
    -d client_id="$CLIENT_ID" \
    -d refresh_token="…" | jq .

# Throw it away. Do this on logout — an abandoned token stays valid.
curl -sS -X POST "$AUTH_SERVER/oauth/revoke" \
    -d client_id="$CLIENT_ID" \
    -d token="$ACCESS_TOKEN"
# 200 with an empty body, whether or not the token existed: RFC 7009 says a
# revocation endpoint must not become an oracle for which tokens are real.