Relying party documentation

Laravel integration

Authorization Code + PKCE against the SXM Identity Broker. Each step is copy-paste ready — reference implementations: COCI and Tax portal.

  • Configure .env and config/services.php (broker URLs + scope openid profile email eid)
  • Add routes and Wso2Controller (PKCE, callback, UserInfo merge)
  • Migrate users table (oidc_sub, eid, citizen_status, rp_token)
  • Test /login → broker → eID → callback → dashboard
  • Steps 12–16: EID_API_BASE_URL, EidGovernmentPortalCatalog, <x-portal-switcher />, /login/sso
  • curl eID /api/public/government-services?excludeClientId={WSO2_CLIENT_ID} — tiles after admin approval

Generic copy-paste steps using placeholder client credentials. After onboarding, open your application in the RP portal for guides pre-filled with your client ID and redirect URIs.

Step 1

Architecture overview

Citizens sign in through the SXM Identity Broker, which federates to the national eID UI. Your Laravel app is the OAuth client — it never collects eID passwords. After callback, exchange the code for tokens, call broker UserInfo with the access_token, merge claims, and create a local session.

text
Browser → /login (PKCE + state)
       → Broker authorize (https://localhost:9443/oauth2/authorize)
       → SXM eID login + MFA
       → GET https://my-portal.gov.sx/auth/callback?code=...&state=...
       → POST https://localhost:9443/oauth2/token (code + code_verifier + client_secret)
       → GET https://localhost:9443/oauth2/userinfo (Bearer access_token)
       → User::updateOrCreate by sub (eid, citizen_status, rp_token, profile)
       → Auth::login($user)
Step 2

Configure .env

Paste into project root .env. Use your one-time credential link for the client secret. Local example redirect: http://coci.test/auth/callback

env
# My Government Portal — SXM Identity Broker
WSO2_BASE_URL=https://localhost:9443
WSO2_CLIENT_ID=YOUR_CLIENT_ID
WSO2_CLIENT_SECRET=YOUR_CLIENT_SECRET
WSO2_REDIRECT_URI=https://my-portal.gov.sx/auth/callback
WSO2_SCOPE="openid profile email eid"
WSO2_AUTHORIZE_URL=https://localhost:9443/oauth2/authorize
WSO2_TOKEN_URL=https://localhost:9443/oauth2/token
WSO2_USERINFO_URL=https://localhost:9443/oauth2/userinfo
WSO2_LOGOUT_URL=https://localhost:9443/oidc/logout
WSO2_POST_LOGOUT_REDIRECT_URI=https://my-portal.gov.sx/
# Auth prompt only: login | none — never set consent (broker owns consent)
WSO2_OAUTH_PROMPT=login

# When callback has no ?code= (user cancelled at broker)
EID_FRONTEND_URL=http://localhost:3000

# Local debug only (log or dump claims on callback): WSO2_DEBUG_CLAIMS=log
Step 3

Wire config/services.php

Add the wso2 and eid blocks so your controller reads broker settings from the environment.

php
'wso2' => [
    'base_url' => env('WSO2_BASE_URL', 'https://localhost:9443'),
    'client_id' => env('WSO2_CLIENT_ID'),
    'client_secret' => env('WSO2_CLIENT_SECRET'),
    'redirect_uri' => env('WSO2_REDIRECT_URI', 'https://my-portal.gov.sx/auth/callback'),
    'scope' => env('WSO2_SCOPE', 'openid profile email eid'),
    'authorize_url' => env('WSO2_AUTHORIZE_URL', 'https://localhost:9443/oauth2/authorize'),
    'token_url' => env('WSO2_TOKEN_URL', 'https://localhost:9443/oauth2/token'),
    'userinfo_url' => env('WSO2_USERINFO_URL', 'https://localhost:9443/oauth2/userinfo'),
    'logout_url' => env('WSO2_LOGOUT_URL', 'https://localhost:9443/oidc/logout'),
    'post_logout_redirect_uri' => env('WSO2_POST_LOGOUT_REDIRECT_URI', 'https://my-portal.gov.sx/'),
    'oauth_prompt' => env('WSO2_OAUTH_PROMPT', 'login'),
],

'eid' => [
    'frontend_url' => env('EID_FRONTEND_URL', 'http://localhost:3000'),
],
Step 4

Add OAuth routes (routes/web.php)

/login forces interactive login; /login/sso uses prompt=none for portal switching. /auth/slo supports front-channel logout iframes from eID.

php
use App\Http\Controllers\Auth\Wso2Controller;

Route::get('/login', [Wso2Controller::class, 'redirect'])->name('login');
Route::get('/login/sso', [Wso2Controller::class, 'redirectSso'])->name('login.sso');
Route::get('/auth/callback', [Wso2Controller::class, 'callback'])->name('auth.callback');
Route::get('/auth/slo', [Wso2Controller::class, 'frontChannelLogout'])->name('auth.slo');
Route::post('/logout', [Wso2Controller::class, 'logout'])->name('logout');

Route::middleware('auth')->group(function () {
    Route::view('/dashboard', 'dashboard')->name('dashboard');
});
Step 5

Database: users + eID claims

Key users by broker sub. Store standard SXM claims when scope eid is approved (see /developers/sso/claims).

php
// database/migrations/xxxx_add_oidc_fields_to_users_table.php
Schema::table('users', function (Blueprint $table) {
    $table->string('oidc_sub')->nullable()->unique()->after('id');
    $table->string('citizen_status', 32)->nullable()->after('oidc_sub');
    $table->string('rp_token', 128)->nullable()->after('citizen_status');
    $table->string('eid', 32)->nullable()->after('rp_token');
});

// app/Models/User.php — add to $fillable:
// 'oidc_sub', 'citizen_status', 'rp_token', 'eid'
Step 6

Start login (PKCE + state)

Validate state on callback. Use prompt=login on /login; use prompt=none on /login/sso. Never send prompt=consent from the RP.

php
$state = Str::random(40);
$codeVerifier = Str::random(64);
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');

session(['oauth_state' => $state, 'oauth_code_verifier' => $codeVerifier]);

$params = [
    'response_type' => 'code',
    'client_id' => config('services.wso2.client_id'),
    'redirect_uri' => config('services.wso2.redirect_uri'),
    'scope' => config('services.wso2.scope'),
    'state' => $state,
    'code_challenge' => $codeChallenge,
    'code_challenge_method' => 'S256',
    'prompt' => 'login', // omit or use 'none' on /login/sso
];

return redirect(config('services.wso2.authorize_url').'?'.http_build_query($params));
Step 7

Exchange code for tokens (callback)

Server-side only. Verify state matches session before exchanging. Clear PKCE verifier after use.

php
if ($request->get('state') !== session('oauth_state')) {
    return redirect('/')->with('error', 'Invalid OAuth state.');
}

$tokenResponse = Http::asForm()
    ->withOptions(['verify' => app()->environment('local') ? false : true])
    ->post(config('services.wso2.token_url'), [
        'grant_type' => 'authorization_code',
        'code' => $request->get('code'),
        'redirect_uri' => config('services.wso2.redirect_uri'),
        'client_id' => config('services.wso2.client_id'),
        'client_secret' => config('services.wso2.client_secret'),
        'code_verifier' => session('oauth_code_verifier'),
    ]);

if (! $tokenResponse->successful()) {
    return redirect('/')->with('error', 'Token exchange failed.');
}

$tokens = $tokenResponse->json();
$idToken = $tokens['id_token'] ?? null;
$accessToken = $tokens['access_token'] ?? null;
Step 8

Call broker UserInfo + merge claims

Use the broker access_token (opaque UUID on WSO2). Do not call eID /connect/userinfo with this token. Merge id_token + UserInfo so eid-scope fields are not dropped.

php
$userInfoResponse = Http::withToken($accessToken)
    ->withOptions(['verify' => app()->environment('local') ? false : true])
    ->get(config('services.wso2.userinfo_url'));

$userInfo = $userInfoResponse->successful() ? ($userInfoResponse->json() ?? []) : [];
$idClaims = $this->decodeJwtPayload($idToken) ?? [];
$claims = $this->normalizeBrokerClaims(array_merge($idClaims, $userInfo));

private function decodeJwtPayload(?string $jwt): ?array
{
    if (! $jwt) {
        return null;
    }
    $parts = explode('.', $jwt);
    if (count($parts) < 2) {
        return null;
    }
    $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);

    return is_array($payload) ? $payload : null;
}

private function normalizeBrokerClaims(array $claims): array
{
    $uriToName = [
        'http://wso2.org/claims/custom/citizen_status' => 'citizen_status',
        'http://wso2.org/claims/custom/rp_token' => 'rp_token',
        'http://wso2.org/claims/custom/eid' => 'eid',
    ];
    foreach ($uriToName as $uri => $name) {
        if (empty($claims[$name]) && ! empty($claims[$uri])) {
            $claims[$name] = $claims[$uri];
        }
    }

    return $claims;
}
Step 9

Create session from claims

Persist approved claims and store id_token in session for federated logout.

php
if (empty($claims['sub'])) {
    return redirect('/')->with('error', 'Unable to read identity claims.');
}

$user = User::updateOrCreate(
    ['oidc_sub' => $claims['sub']],
    [
        'name' => $claims['name'] ?? $claims['given_name'] ?? 'Citizen',
        'email' => $claims['email'] ?? $claims['sub'].'@oidc.local',
        'citizen_status' => $claims['citizen_status'] ?? null,
        'rp_token' => $claims['rp_token'] ?? null,
        'eid' => $claims['eid'] ?? null,
        'password' => bcrypt(Str::random(32)),
    ],
);

Auth::login($user);

if ($idToken) {
    session(['wso2_id_token' => $idToken]);
}

return redirect('/dashboard');
Step 10

Federated logout

Redirect to broker /oidc/logout with id_token_hint so the citizen session ends at the broker and eID.

php
public function logout(Request $request): RedirectResponse
{
    Auth::logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    $idToken = session('wso2_id_token');
    session()->forget('wso2_id_token');

    $logoutUrl = config('services.wso2.logout_url');
    $postLogout = config('services.wso2.post_logout_redirect_uri');

    if (! filled($idToken) || ! filled($logoutUrl) || ! filled($postLogout)) {
        return redirect('/');
    }

    $query = http_build_query([
        'id_token_hint' => $idToken,
        'post_logout_redirect_uri' => $postLogout,
        'client_id' => config('services.wso2.client_id'),
    ]);

    return redirect($logoutUrl.'?'.$query);
}
Step 11

Test end-to-end

Redirect URI for RP-EXAMPLE-0001 must exactly match WSO2_REDIRECT_URI. See Claims doc if eid / rp_token are missing after login.

shell
# 1. Refresh config
php artisan config:clear

# 2. Run app (Herd: https://my-government-portal.test — or php artisan serve)

# 3. Browser: open /login
#    Flow: Laravel → broker → eID → https://my-portal.gov.sx/auth/callback → /dashboard

# 4. Optional: call UserInfo with access_token from token response
curl -sS -H "Authorization: Bearer {broker_access_token}" \
  https://localhost:9443/oauth2/userinfo

# 5. Registered redirect URI (must match exactly):
https://my-portal.gov.sx/auth/callback
Step 12

Portal switcher — .env (eID catalog)

Add alongside your WSO2 variables. EID_API_BASE_URL must reach GET /api/public/government-services from your Laravel server.

env
# Government service launcher (dynamic catalog from eID)
EID_API_BASE_URL=http://localhost:5026
EID_FRONTEND_URL=http://localhost:3000
EID_CITIZEN_PORTAL_PATH=/citizen/profile
GOVERNMENT_PORTAL_ID=my-rp
Step 13

Portal switcher — config (services + fallback)

Extend config/services.php and add config/government_portals.php for local identity + offline fallback tiles.

php
// config/services.php — inside return [
'eid' => [
    'api_base_url' => env('EID_API_BASE_URL', 'http://localhost:5026'),
    'frontend_url' => env('EID_FRONTEND_URL', 'http://localhost:3000'),
    'citizen_portal_path' => env('EID_CITIZEN_PORTAL_PATH', '/citizen/profile'),
],
'government_portals' => [
    'self' => env('GOVERNMENT_PORTAL_ID', 'my-rp'),
    'self_meta' => config('government_portals.self'),
],

// config/government_portals.php
return [
    'self' => ['label' => 'My Government Portal', 'short' => 'RP'],
    'fallback_items' => [
        [
            'key' => 'tax',
            'label' => 'Tax Administration',
            'short' => 'TAX',
            'description' => 'Used only if eID catalog API is down',
            'accent' => '#b45309',
            'sso_login_url' => rtrim(env('TAX_PORTAL_URL', 'http://tax-portal.test'), '/').'/login/sso',
        ],
    ],
];
Step 14

Portal switcher — EidGovernmentPortalCatalog service

Create app/Services/EidGovernmentPortalCatalog.php. Reference: COCI repo (copy-paste ready). Excludes your WSO2 client_id from the tile list.

php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class EidGovernmentPortalCatalog
{
    public function launchablePortals(?string $excludeClientId = null): array
    {
        $baseUrl = rtrim((string) config('services.eid.api_base_url', ''), '/');
        if ($baseUrl === '') {
            return $this->fallbackPortals();
        }

        $exclude = $excludeClientId ?? config('services.wso2.client_id');
        $cacheKey = 'eid.government_portals.'.md5($baseUrl.'|'.($exclude ?? ''));

        return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($baseUrl, $exclude): array {
            $query = $exclude ? '?excludeClientId='.rawurlencode($exclude) : '';
            $response = Http::timeout(8)->acceptJson()->get($baseUrl.'/api/public/government-services'.$query);

            if (! $response->successful()) {
                Log::warning('eID government services catalog failed', ['status' => $response->status()]);
                return $this->fallbackPortals();
            }

            $portals = $response->json('portals') ?? [];
            $mapped = [];
            foreach ($portals as $portal) {
                if (! is_array($portal)) {
                    continue;
                }
                $sso = $portal['ssoLoginUrl'] ?? $portal['sso_login_url'] ?? null;
                if (! is_string($sso) || $sso === '') {
                    continue;
                }
                $label = (string) ($portal['label'] ?? 'Government service');
                $mapped[] = [
                    'key' => (string) ($portal['clientId'] ?? md5($sso)),
                    'label' => $label,
                    'short' => (string) ($portal['shortLabel'] ?? strtoupper(substr($label, 0, 4))),
                    'description' => (string) ($portal['description'] ?? $label),
                    'accent' => (string) ($portal['accent'] ?? '#334155'),
                    'sso_login_url' => $sso,
                ];
            }

            return $mapped !== [] ? $mapped : $this->fallbackPortals();
        });
    }

    private function fallbackPortals(): array
    {
        $items = config('government_portals.fallback_items', []);
        return is_array($items) ? array_values($items) : [];
    }
}
Step 15

Portal switcher — View composer + Blade

Register in AppServiceProvider. Use <x-portal-switcher /> in your authenticated layout (full UI: copy COCI resources/views/components/portal-switcher.blade.php).

php
// app/Providers/AppServiceProvider.php — boot()
use App\Services\EidGovernmentPortalCatalog;
use Illuminate\Support\Facades\View;

View::composer('components.portal-switcher', function ($view): void {
    $catalog = app(EidGovernmentPortalCatalog::class);
    $eidBase = rtrim((string) config('services.eid.frontend_url', ''), '/');
    $path = (string) config('services.eid.citizen_portal_path', '/citizen/profile');

    $view->with('launchablePortals', $catalog->launchablePortals());
    $view->with('eidCitizenPortalUrl', $eidBase !== '' ? $eidBase.$path : null);
    $view->with('currentPortalKey', config('services.government_portals.self'));
    $view->with('currentPortal', config('services.government_portals.self_meta'));
});

// resources/views/layouts/app.blade.php (when @auth)
<x-portal-switcher />

// Minimal tile loop (if you skip the component):
@foreach ($launchablePortals ?? [] as $portal)
    <a href="{{ $portal['sso_login_url'] }}">{{ $portal['label'] }}</a>
@endforeach
Step 16

Portal switcher — /login/sso (silent SSO)

Tiles link to peer {PortalBaseUrl}/login/sso. Your route must start OAuth with prompt=none and fall back to /login on login_required.

php
// routes/web.php
Route::get('/login/sso', [Wso2Controller::class, 'redirectSso'])->name('login.sso');

// Wso2Controller (excerpt — see COCI for full PKCE + callback)
public function redirectSso(Request $request): RedirectResponse
{
    return $this->startOAuth(forceLogin: false);
}

private function startOAuth(bool $forceLogin, bool $ssoRetry = false): RedirectResponse
{
    // ... PKCE state + code_verifier ...
    if ($forceLogin) {
        $params['prompt'] = 'login';
    } elseif (! $ssoRetry) {
        $params['prompt'] = 'none';
    }
    return redirect(config('services.wso2.authorize_url').'?'.http_build_query($params));
}

// In callback: if error is login_required and this was SSO, redirect to /login

// Smoke test catalog:
curl -s "http://localhost:5026/api/public/government-services?excludeClientId=YOUR_WSO2_CLIENT_ID" | jq '.portals[].ssoLoginUrl'