developer guide
Kiungo - Kilwa Kivinje

Integration guide

Authenticate your users against the national directories with one endpoint. No LDAP libraries, no bind credentials, no per-system configuration.

How it works

Two calls. That's the whole integration.

Your backend exchanges its client credentials for a short-lived access token, then posts a user's username and password to the gateway. The gateway works out which directory owns that e-mail domain, authenticates against it, and returns a signed assertion. You verify the assertion's signature and create your own session. You never see an LDAP host, a bind DN, or a bind password.

1 · POST /api/v1/oauth/token
client_id + client_secret → Bearer token (cache it; ~1h)
2 · POST /api/v1/auth/authenticate
username + password → assertion (JWT, 90 s, single-use)
3 · verify with /.well-known/jwks.json
check signature, aud = your client_id, exp; then mint your own session

Code samples

# 1. Get a token (cache it until expires_in)
curl -s https://sso-test.gov.go.tz/api/v1/oauth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"client_credentials","client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET"}'

# 2. Authenticate a user
curl -s https://sso-test.gov.go.tz/api/v1/auth/authenticate \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'X-Correlation-Id: login-9f2c' \
  -d '{"username":"first.last@mof.go.tz","password":"…"}'

# 3. Public keys for verifying the assertion
curl -s https://sso-test.gov.go.tz/.well-known/jwks.json
# pip install requests PyJWT[crypto]
import time, requests, jwt
from jwt import PyJWKClient

GATEWAY = "https://sso-test.gov.go.tz"
CLIENT_ID = "YOUR_CLIENT_ID"
CLIENT_SECRET = settings.AUTH_GATEWAY_SECRET   # from your secret store, never source

_token = {"value": None, "exp": 0}
_jwks = PyJWKClient(f"{GATEWAY}/.well-known/jwks.json", cache_keys=True)

def _access_token():
    if _token["exp"] - 30 > time.time():
        return _token["value"]
    r = requests.post(f"{GATEWAY}/api/v1/oauth/token", json={
        "grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
    }, timeout=5)
    r.raise_for_status()
    body = r.json()
    _token.update(value=body["access_token"], exp=time.time() + body["expires_in"])
    return _token["value"]

def authenticate(username: str, password: str) -> dict | None:
    """Returns verified claims on success, None on any failure."""
    r = requests.post(f"{GATEWAY}/api/v1/auth/authenticate",
        json={"username": username, "password": password},
        headers={"Authorization": f"Bearer {_access_token()}"}, timeout=10)
    if r.status_code != 200:
        error = r.json().get("error")           # switch on this, never on the message
        if error == "invalid_client":
            _token["exp"] = 0                   # token expired early; next call refreshes
        return None
    assertion = r.json()["assertion"]
    key = _jwks.get_signing_key_from_jwt(assertion).key
    return jwt.decode(assertion, key, algorithms=["RS256"], audience=CLIENT_ID, issuer=GATEWAY)

# --- as a Django authentication backend ---
class GatewayBackend:
    def authenticate(self, request, username=None, password=None):
        claims = authenticate(username, password) if username and password else None
        if not claims:
            return None
        user, _ = User.objects.update_or_create(
            email=claims["sub"],
            defaults={"first_name": claims.get("given_name", ""), "last_name": claims.get("family_name", "")},
        )
        return user
    def get_user(self, user_id):
        return User.objects.filter(pk=user_id).first()
// Spring Boot 3 · spring-boot-starter-oauth2-resource-server brings in nimbus-jose-jwt
@Service
public class GatewayAuthService {
    private static final String GATEWAY = "https://sso-test.gov.go.tz";
    private static final String CLIENT_ID = "YOUR_CLIENT_ID";
    @Value("${auth.gateway.secret}") private String clientSecret;   // from Vault / env, never source

    private final RestClient http = RestClient.create();
    private final JwtDecoder decoder = NimbusJwtDecoder
        .withJwkSetUri(GATEWAY + "/.well-known/jwks.json").build();   // caches keys, honours kid
    private volatile String token; private volatile Instant tokenExp = Instant.EPOCH;

    private synchronized String accessToken() {
        if (tokenExp.minusSeconds(30).isAfter(Instant.now())) return token;
        Map<String,Object> body = http.post().uri(GATEWAY + "/api/v1/oauth/token")
            .contentType(MediaType.APPLICATION_JSON)
            .body(Map.of("grant_type","client_credentials","client_id",CLIENT_ID,"client_secret",clientSecret))
            .retrieve().body(Map.class);
        token = (String) body.get("access_token");
        tokenExp = Instant.now().plusSeconds(((Number) body.get("expires_in")).longValue());
        return token;
    }

    /** Returns verified claims, or empty on any failure. */
    public Optional<Jwt> authenticate(String username, String password) {
        try {
            Map<String,Object> res = http.post().uri(GATEWAY + "/api/v1/auth/authenticate")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken())
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("username", username, "password", password))
                .retrieve().body(Map.class);
            Jwt jwt = decoder.decode((String) res.get("assertion"));
            if (!jwt.getAudience().contains(CLIENT_ID)) return Optional.empty();
            return Optional.of(jwt);                        // jwt.getSubject() == username
        } catch (HttpClientErrorException e) {
            if (e.getStatusCode() == HttpStatus.UNAUTHORIZED && e.getResponseBodyAsString().contains("invalid_client")) tokenExp = Instant.EPOCH;
            return Optional.empty();                       // invalid_credentials, rate_limited, …
        } catch (HttpServerErrorException e) {
            return Optional.empty();                       // 503 directory_unavailable — show "try again"
        }
    }
}
<?php
// composer require firebase/php-jwt guzzlehttp/guzzle
use Firebase\JWT\JWT; use Firebase\JWT\JWK; use Firebase\JWT\CachedKeySet;
use Illuminate\Support\Facades\{Cache, Http};

class GatewayAuth
{
    const GATEWAY = 'https://sso-test.gov.go.tz';
    const CLIENT_ID = 'YOUR_CLIENT_ID';

    private function accessToken(): string
    {
        return Cache::remember('auth_gateway_token', now()->addMinutes(55), function () {
            $r = Http::timeout(5)->post(self::GATEWAY.'/api/v1/oauth/token', [
                'grant_type' => 'client_credentials',
                'client_id' => self::CLIENT_ID,
                'client_secret' => config('services.auth_gateway.secret'),   // .env, never source
            ])->throw()->json();
            return $r['access_token'];
        });
    }

    /** Returns verified claims as an array, or null on any failure. */
    public function authenticate(string $username, string $password): ?array
    {
        $r = Http::timeout(10)->withToken($this->accessToken())
            ->post(self::GATEWAY.'/api/v1/auth/authenticate', compact('username', 'password'));
        if (! $r->ok()) {
            if ($r->json('error') === 'invalid_client') Cache::forget('auth_gateway_token');
            return null;                                      // switch on $r->json('error') if you need detail
        }
        $keys = new CachedKeySet(self::GATEWAY.'/.well-known/jwks.json', new \GuzzleHttp\Client(),
            new \GuzzleHttp\Psr7\HttpFactory(), Cache::store()->getStore() instanceof \Psr\Cache\CacheItemPoolInterface ? Cache::store()->getStore() : new \Symfony\Component\Cache\Adapter\ArrayAdapter(), 3600, true);
        $claims = (array) JWT::decode($r->json('assertion'), $keys);
        if (($claims['aud'] ?? null) !== self::CLIENT_ID) return null;
        return $claims;                                       // $claims['sub'] === $username
    }
}

// In a Laravel auth guard or login controller:
//   $claims = app(GatewayAuth::class)->authenticate($request->email, $request->password);
//   if ($claims) { $user = User::firstOrCreate(['email' => $claims['sub']], [...]); Auth::login($user); }

The assertion

An RS256 JWT. Verify the signature with the key whose kid matches the header, then check aud equals your client_id and exp is in the future. It lives 90 seconds and is accepted once — it proves "this user just authenticated", it is not a session token.

{
  "iss": "https://sso-test.gov.go.tz",
  "sub": "first.last@mof.go.tz",
  "aud": "YOUR_CLIENT_ID",
  "exp": 1724234590, "iat": 1724234500, "jti": "01J…",
  "amr": ["pwd", "ldap"],
  "domain": "mof.go.tz", "cluster": "CLUSTER-A",
  "email": "first.last@mof.go.tz",
  "display_name": "First Last", "given_name": "First", "family_name": "Last",
  "department": "ICT", "title": "Systems Analyst",
  "groups": ["Staff", "ICT-Unit"]
}

Error codes

Every failure has this shape — branch on error; the description may change, the code will not.

{"status":"error","request_id":"…","error":"…","error_description":"…","retry_after":null}
invalid_credentials401Wrong password, unknown user, disabled account — deliberately indistinguishable. Show a generic message.
invalid_client401Your access token is missing, expired or revoked. Fetch a new one and retry once.
unauthorized_client403Your system is paused, calling from a disallowed IP, or outside its domain scope.
invalid_request400Malformed body. Check JSON and both fields.
rate_limited429Back off for retry_after seconds. Also returned for a locked account.
directory_unavailable503The user's directory is down. Tell the user to try again shortly; do not fall back to a local password.
service_maintenance503Planned maintenance. Honour retry_after.

Good practice

  • Cache the access token for its lifetime. Requesting one per login wastes a round trip and counts against your quota.
  • Send X-Correlation-Id with your own request ID. It is echoed back and stored, so support can trace one login across both systems.
  • Quote request_id when reporting a problem — it pinpoints the exact audit record.
  • Verify locally against JWKS rather than calling /auth/verify. It is faster and keeps your login working if the gateway is briefly unreachable.
  • Never log the password you forward, and never store it. The gateway doesn't either.
  • Sandbox first. Ask for a sandbox-mode client: it routes to a simulated directory with seeded accounts (first.last@sandbox.go.tz / SandboxP@ss123, plus disabled/locked/expired variants) so you can test every branch without touching a real directory.