Skip to main content

External Reconciliation API

The External Reconciliation API enables authorized third-party systems -- such as student information systems, learning management platforms, or institutional enrollment services -- to interact with reconciled identity data managed by the Auth Bridge. Unlike the OID4VP Session and IDV APIs, which are designed for internal service-to-service communication, this API is explicitly designed for consumption by external clients.

All endpoints live under the base path /api/external/v1/reconciliation on the Auth Bridge (port 8090). Every request must include a valid OAuth2 bearer token obtained through the client_credentials grant. The API enforces per-client projection rules that restrict which claims and auxiliary data categories each client can access.

Authentication

External clients authenticate using OAuth2 bearer tokens. The typical flow is:

  1. The client obtains an access token from the configured OAuth2 authorization server (e.g., Keycloak or the STS) using the client_credentials grant with the scope reconciliation:read.
  2. The client includes the token in the Authorization header of every API request.
  3. The Auth Bridge validates the JWT against the configured JWKS endpoint, checks the issuer, audience, expiration, and scopes.

Obtaining a Token

curl -X POST https://keycloak.example.com/realms/portal/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=enrollment-service" \
-d "client_secret=<secret>" \
-d "scope=reconciliation:read"

Response:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "reconciliation:read"
}

Using the Token

curl -X GET https://auth-bridge.example.com/api/external/v1/reconciliation/{id}/claims \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

If the token is missing, expired, or lacks the required scope, the API responds with 401 Unauthorized or 403 Forbidden.

Per-Client Projection Configuration

A key security feature of the External API is per-client projection. Each registered client is configured with explicit lists of which identity claims and auxiliary data categories it is allowed to access. The Auth Bridge enforces these restrictions server-side; even if a client requests data outside its projection, the response will only contain the authorized subset.

The configuration lives in the Auth Bridge's application.yml:

external-api:
enabled: true
jwt:
issuer: "http://keycloak:8080/realms/portal"
jwks-uri: "http://keycloak:8080/realms/portal/protocol/openid-connect/certs"
clients:
enrollment-service:
scopes: ["reconciliation:read"]
projected-claims: ["eduid", "eduperson_principal_name", "email"]
auxiliary-categories: ["enrollment", "role"]
analytics-platform:
scopes: ["reconciliation:read"]
projected-claims: ["eduid"]
auxiliary-categories: ["enrollment"]

In this example, enrollment-service can access three identity claims and two auxiliary data categories, while analytics-platform can only see the eduid claim and enrollment auxiliary data. The client identity is extracted from the azp (authorized party) or client_id claim in the JWT. If a client is not listed in the configuration, all requests are rejected with 403 Forbidden.

This projection model follows the principle of least privilege: each client receives only the data it needs to perform its function, reducing the blast radius of a compromised credential.

POST /lookup

Looks up an identity by a hashed identifier. This is the primary entry point for external systems that know a user's wallet key or credential identifier but need to resolve it to the internal identity record.

Request

{
"identifierHash": "dGhpcyBpcyBhIGJhc2U2NHVybC1lbmNvZGVkLWhtYWMtaGFzaA",
"identifierType": "KEY"
}
FieldTypeRequiredDescription
identifierHashstringYesA Base64url-encoded HMAC-SHA256 hash of the identifier. The caller computes this hash using a shared HMAC key that has been exchanged out-of-band. This design ensures that raw identifiers are never transmitted over the wire.
identifierTypestringYesThe type of identifier being looked up. Supported values: KEY (wallet holder key/DID), EDUID (eduID URN), EPPN (eduPersonPrincipalName).

Response (200 OK)

{
"internalIdentityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"claims": {
"eduid": "urn:mace:surf.nl:eduid:12345",
"email": "student@institution.nl"
},
"auxiliaryCategories": ["enrollment"],
"assurance": {
"acr": "urn:sphereon:oid4vp:vp",
"amr": ["vp"]
}
}
FieldTypeDescription
internalIdentityIdstring (UUID)The internal identity ID. Use this to call the other endpoints for this identity.
claimsobjectThe identity claims, filtered to only those allowed by the client's projection configuration.
auxiliaryCategoriesarray of stringsThe auxiliary data categories that have data stored for this identity, filtered to those the client is allowed to access.
assuranceobjectAuthentication assurance metadata describing how the identity was verified.

Response (404 Not Found)

{
"error": "identity_not_found",
"error_description": "No identity record matches the provided identifier hash"
}

HMAC Hash Computation

The caller is responsible for computing the HMAC-SHA256 hash of the identifier before sending it to the API. The shared HMAC key is provisioned during client onboarding and must be stored securely. Here is an example in Python:

import hmac
import hashlib
import base64

shared_key = b"your-shared-hmac-key"
identifier = b"did:key:z6Mkf5rGMoatrSj1f..."

hash_bytes = hmac.new(shared_key, identifier, hashlib.sha256).digest()
identifier_hash = base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii")

# Use identifier_hash in the lookup request

GET /{internalIdentityId}

Retrieves the full identity record for a given internal identity ID. The response includes claims (subject to projection), auxiliary data category availability, assurance metadata, and binding information.

Path Parameters

ParameterTypeDescription
internalIdentityIdstring (UUID)The internal identity identifier, obtained from the lookup endpoint or from a previous API call.

Response (200 OK)

{
"internalIdentityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"claims": {
"eduid": "urn:mace:surf.nl:eduid:12345",
"eduperson_principal_name": "student@institution.nl",
"email": "student@institution.nl"
},
"auxiliaryCategories": ["enrollment", "role"],
"assurance": {
"acr": "urn:sphereon:oid4vp:vp",
"amr": ["vp"]
},
"bindings": {
"walletBound": true,
"federationBound": true,
"lastAuthenticatedAt": "2026-03-27T10:30:00Z"
}
}

GET /{internalIdentityId}/claims

Retrieves only the projected identity claims for this identity, without binding or auxiliary metadata. This is a lightweight endpoint for systems that only need to resolve claim values.

Response (200 OK)

{
"eduid": "urn:mace:surf.nl:eduid:12345",
"eduperson_principal_name": "student@institution.nl",
"email": "student@institution.nl"
}

The response body is a flat JSON object containing only the claims that the calling client is authorized to see. If the client's projection allows ["eduid", "email"] but not eduperson_principal_name, the response would omit that field entirely.

GET /{internalIdentityId}/auxiliary/{category}

Retrieves auxiliary data stored under a specific category for this identity. Auxiliary data is arbitrary JSON that external systems store alongside the reconciled identity to enrich it with domain-specific information.

Path Parameters

ParameterTypeDescription
internalIdentityIdstring (UUID)The internal identity identifier.
categorystringThe auxiliary data category (e.g., enrollment, role, preferences). Must be in the client's allowed auxiliary-categories list.

Response (200 OK)

{
"category": "enrollment",
"data": {
"enrollment_status": "active",
"programme": "Computer Science",
"institution": "University of Amsterdam",
"start_date": "2025-09-01"
},
"storedBy": "enrollment-service",
"storedAt": "2026-03-15T14:22:00Z",
"expiresAt": "2027-01-01T00:00:00Z"
}

Response (404 Not Found)

Returned if no auxiliary data exists under the specified category for this identity, or if the client is not authorized to access the category.

PUT /{internalIdentityId}/auxiliary/{category}

Stores or updates auxiliary data for a specific category. If data already exists under this category for this identity, it is replaced entirely (not merged). The storedBy field is automatically set to the calling client's identifier.

Request Body

{
"data": {
"enrollment_status": "active",
"programme": "Computer Science",
"institution": "University of Amsterdam",
"start_date": "2025-09-01"
},
"expiresAt": "2027-01-01T00:00:00Z"
}
FieldTypeRequiredDescription
dataobjectYesArbitrary JSON data to store. The structure is entirely defined by the calling system; the Auth Bridge treats it as an opaque blob. Maximum size is 64 KB.
expiresAtstring (ISO 8601)NoOptional expiration timestamp. After this time, the data is eligible for automatic cleanup. If omitted, the data does not expire.

Response (200 OK)

{
"category": "enrollment",
"storedBy": "enrollment-service",
"storedAt": "2026-03-27T11:00:00Z",
"expiresAt": "2027-01-01T00:00:00Z"
}

Response (201 Created)

Returned when the auxiliary category did not previously exist for this identity. The response body is identical to the 200 case.

DELETE /{internalIdentityId}/auxiliary/{category}

Deletes all auxiliary data stored under a specific category for this identity. This operation is idempotent; calling it when no data exists returns 204 No Content without error.

Response (204 No Content)

No response body. The auxiliary data has been deleted (or did not exist).

DELETE /{internalIdentityId}

GDPR erasure endpoint. Permanently and irreversibly deletes all data associated with this identity, including:

  • All identity_match records (the reconciled identity claims)
  • All identity_link_binding records (the wallet-to-identity and federation-to-identity links)
  • All auxiliary_data records across every category
  • All session artifacts and cached tokens related to this identity

This operation is designed to fulfill GDPR Article 17 (Right to Erasure) requests. It is irreversible -- once executed, the identity cannot be recovered. The user would need to go through the full wallet authentication and IDV reconciliation flow again to re-establish their identity in the system.

Response (204 No Content)

No response body. All data has been deleted.

Audit Trail

Even though the identity data itself is deleted, the Auth Bridge logs an audit event recording that an erasure was performed, who requested it (the client ID from the JWT), the internal identity ID that was erased, and the timestamp. This audit log is retained separately for compliance purposes and does not contain any personal data.

[AUDIT] GDPR_ERASURE client=enrollment-service identity=f47ac10b-58cc-4372-a567-0e02b2c3d479 timestamp=2026-03-27T12:00:00Z

Safety Considerations

Because this operation is destructive and irreversible, consider the following:

  • Ensure the calling client has been explicitly authorized for erasure operations. The reconciliation:read scope alone is not sufficient; a separate reconciliation:delete scope (or equivalent policy) should be required.
  • Implement a confirmation step in the calling system's workflow before invoking this endpoint.
  • Test erasure behavior in a staging environment before enabling it in production.

Common Error Responses

All endpoints share a consistent error response format:

{
"error": "error_code",
"error_description": "Human-readable description of the problem"
}
HTTP StatusError CodeDescription
400 Bad Requestinvalid_requestThe request body is malformed or missing required fields.
401 Unauthorizedinvalid_tokenThe bearer token is missing, expired, or has an invalid signature.
403 Forbiddeninsufficient_scopeThe token does not include the required scope, or the client is not configured for this API.
403 Forbiddencategory_not_allowedThe client attempted to access an auxiliary category outside its projection.
404 Not Foundidentity_not_foundNo identity exists with the given internal ID.
404 Not Foundauxiliary_not_foundNo auxiliary data exists under the requested category.
413 Payload Too Largedata_too_largeThe auxiliary data payload exceeds the 64 KB limit.
429 Too Many Requestsrate_limitedThe client has exceeded the configured rate limit. Retry after the duration indicated in the Retry-After header.
500 Internal Server Errorserver_errorAn unexpected error occurred. Logged server-side for investigation.