Integration guide
Authenticate your users against the national directories with one endpoint. No LDAP libraries, no bind credentials, no per-system configuration.
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.
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_credentials | 401 | Wrong password, unknown user, disabled account — deliberately indistinguishable. Show a generic message. |
| invalid_client | 401 | Your access token is missing, expired or revoked. Fetch a new one and retry once. |
| unauthorized_client | 403 | Your system is paused, calling from a disallowed IP, or outside its domain scope. |
| invalid_request | 400 | Malformed body. Check JSON and both fields. |
| rate_limited | 429 | Back off for retry_after seconds. Also returned for a locked account. |
| directory_unavailable | 503 | The user's directory is down. Tell the user to try again shortly; do not fall back to a local password. |
| service_maintenance | 503 | Planned 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.