UniAuth

PHP Quickstart

Integrate UniAuth in a PHP app using league/oauth2-client — the standard PHP OAuth 2 / OIDC library — plus firebase/php-jwt for ID-token verification. No UniAuth-specific PHP SDK required.

Rule #1

Never hardcode endpoint URLs. Fetch them from the OIDC discovery document so UniAuth can move or rename endpoints without breaking your integration.

https://uniauth.id/.well-known/openid-configuration

1. Install

composer require league/oauth2-client firebase/php-jwt guzzlehttp/guzzle

2. Fetch & cache discovery

Fetch once, cache for 24h. Everything else comes from this document.

<?php
declare(strict_types=1);

function uniauth_discovery(): array {
    static $mem;
    if ($mem) return $mem;

    $cacheFile = sys_get_temp_dir() . '/uniauth_discovery.json';
    if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < 86400)) {
        return $mem = json_decode(file_get_contents($cacheFile), true);
    }

    $ctx = stream_context_create(['http' => ['timeout' => 5]]);
    $json = file_get_contents(
        'https://uniauth.id/.well-known/openid-configuration',
        false, $ctx
    );
    if ($json === false) {
        throw new RuntimeException('Failed to fetch UniAuth discovery document');
    }
    file_put_contents($cacheFile, $json);
    return $mem = json_decode($json, true);
}

function uniauth_jwks(): array {
    static $mem;
    if ($mem) return $mem;

    $cacheFile = sys_get_temp_dir() . '/uniauth_jwks.json';
    if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < 86400)) {
        return $mem = json_decode(file_get_contents($cacheFile), true);
    }

    $d = uniauth_discovery();
    $json = file_get_contents($d['jwks_uri']);
    file_put_contents($cacheFile, $json);
    return $mem = json_decode($json, true);
}

3. Configure the OAuth client

<?php
require 'vendor/autoload.php';
require 'discovery.php';

use League\OAuth2\Client\Provider\GenericProvider;

$d = uniauth_discovery();

$provider = new GenericProvider([
    'clientId'                => $_ENV['UNIAUTH_CLIENT_ID'],     // "uni_xxxxxxxxxxxxxxxx"
    'clientSecret'            => $_ENV['UNIAUTH_CLIENT_SECRET'], // "unis_xxxxx..."
    'redirectUri'             => 'https://yourapp.com/callback.php',
    'urlAuthorize'            => $d['authorization_endpoint'],
    'urlAccessToken'          => $d['token_endpoint'],
    'urlResourceOwnerDetails' => $d['userinfo_endpoint'],
    'scopes'                  => 'openid profile email groups',
    'scopeSeparator'          => ' ',
    'pkceMethod'              => 'S256',  // PKCE required (RFC 9700)
]);

Credentials come from the Developer Console. Client IDs begin with uni_ and secrets with unis_. Older clients use unprefixed hex — also valid. If your values don't match the Console exactly, the server returns invalid_client on a friendly error page.

4. Start the login flow

<?php
// login.php
session_start();
require 'config.php';  // sets up $provider from step 3

$url = $provider->getAuthorizationUrl();
$_SESSION['oauth2state']    = $provider->getState();
$_SESSION['oauth2pkceCode'] = $provider->getPkceCode();

header('Location: ' . $url);
exit;

5. Callback — verify the ID token locally

Exchange the code, then verify the ID token signature using the JWKS. This gives you a verified claim set without hitting /userinfo. Use /userinfo only if you need fresh profile data (e.g. the user just updated their avatar).

<?php
// callback.php
session_start();
require 'config.php';
require 'discovery.php';

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

// 1. Validate state (CSRF)
if (($_GET['state'] ?? '') !== ($_SESSION['oauth2state'] ?? null)) {
    http_response_code(400); exit('Invalid state');
}

// 2. Exchange code for tokens
$provider->setPkceCode($_SESSION['oauth2pkceCode']);
$tokenSet = $provider->getAccessToken('authorization_code', [
    'code' => $_GET['code']
]);

// 3. Verify the ID token's signature via JWKS (no /userinfo call)
$idToken = $tokenSet->getValues()['id_token'];
$d = uniauth_discovery();
$jwks = uniauth_jwks();
$claims = JWT::decode($idToken, JWK::parseKeySet($jwks));
// JWT::decode verifies the RS256 signature + exp automatically.

// 4. Verify iss + aud (JWT library does NOT do this)
if ($claims->iss !== $d['issuer']) {
    http_response_code(400); exit('iss mismatch');
}
$aud = is_array($claims->aud) ? $claims->aud : [$claims->aud];
if (!in_array($_ENV['UNIAUTH_CLIENT_ID'], $aud)) {
    http_response_code(400); exit('aud mismatch');
}

// 5. Authorization via claims
if (!($claims->email_verified ?? false)) {
    http_response_code(403);
    exit('Email not verified — refusing to proceed.');
}
$isAdmin = in_array('admin', $claims->groups ?? []);
// ^ Use the groups claim for RBAC. Don't use env-var email allowlists.

// 6. Local session
$_SESSION['user'] = [
    'sub'      => $claims->sub,         // pairwise — app-specific
    'email'    => $claims->email,
    'name'     => $claims->name ?? null,
    'groups'   => $claims->groups ?? [],
    'is_admin' => $isAdmin,
    'id_token' => $idToken,             // keep for logout
];
header('Location: /');
exit;

6. Logout — propagate to UniAuth

Clearing the local session isn't real logout — the user can re-authenticate silently. Redirect to end_session_endpoint so UniAuth revokes the session there.

<?php
// logout.php
session_start();
require 'discovery.php';

$d = uniauth_discovery();
$idToken = $_SESSION['user']['id_token'] ?? '';
session_destroy();

$params = http_build_query([
    'id_token_hint'            => $idToken,
    'post_logout_redirect_uri' => 'https://yourapp.com/',
]);
header('Location: ' . $d['end_session_endpoint'] . '?' . $params);
exit;

7. Back-channel logout webhook

Register a webhook URL in the Developer Console as backchannel_logout_uri. UniAuth POSTs a signed JWT logout token to that URL when the user signs out of UniAuth from anywhere — so your app can invalidate server-side sessions.

<?php
// logout-webhook.php
require 'vendor/autoload.php';
require 'discovery.php';

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

$d = uniauth_discovery();
$jwks = uniauth_jwks();

$token = $_POST['logout_token'] ?? '';
if (!$token) { http_response_code(400); exit; }

try {
    $claims = JWT::decode($token, JWK::parseKeySet($jwks));
} catch (Exception $e) {
    http_response_code(400); exit;
}

// Required checks per OIDC Back-Channel Logout 1.0:
$aud = is_array($claims->aud) ? $claims->aud : [$claims->aud];
if ($claims->iss !== $d['issuer'] ||
    !in_array($_ENV['UNIAUTH_CLIENT_ID'], $aud)) {
    http_response_code(400); exit;
}
// events claim MUST contain the back-channel-logout URI
if (!isset($claims->events->{'http://schemas.openid.net/event/backchannel-logout'})) {
    http_response_code(400); exit;
}
// MUST NOT contain nonce (different from ID tokens)
if (isset($claims->nonce)) { http_response_code(400); exit; }

// Invalidate your DB sessions for this user
invalidate_sessions_for_sub($claims->sub, $claims->sid ?? null);
http_response_code(200);

Integration checklist

Before shipping, verify everything on the Integration Checklist. TL;DR: discovery ✓ PKCE ✓ JWKS verify ✓ iss/aud/nonce check ✓ email_verified ✓ groups for RBAC ✓ end_session logout ✓ back-channel webhook ✓.

Framework integrations

  • Laravel: use Laravel Socialite with a generic OIDC provider. Point it at our issuer URL — same discovery-first pattern applies.
  • Symfony: use HWIOAuthBundle with a generic OIDC config.
  • WordPress: the OpenID Connect Generic plugin works with UniAuth's discovery URL out of the box.

Packagist status

A dedicated uniauth/oauth2-uniauth Composer package (extending league/oauth2-client) is in development. Until it's published, the code above is the supported integration path — league/oauth2-client with our discovery URL works fine.

Next