Registering an OAuth2 Client
Before a client application can request tokens from ahdapa it must be registered. ahdapa provides three registration paths:
- Static clients file — a TOML file declared via
[clients] file = ...inahdapa.toml. Clients are seeded into the CRDT at startup, gossiped to peers, and protected from API modification. Suitable for pre-provisioned infrastructure clients (e.g. SSSD machine templates, CI pipelines). See Configuration §[clients] for the file format. - Admin API (
POST /api/admin/clients) — operator-controlled registration with full access to every client field. - Dynamic Client Registration (
POST /register, RFC 7591) — automated or self-service registration for applications that manage their own credentials.
All client state lives in the database and is replicated across cluster nodes via CRDT gossip regardless of how the client was registered.
Admin API registration
The admin API gives an operator full control over every client field, including fields that dynamic registration does not expose (Kerberos authentication, grant-type restrictions, mTLS certificate binding, and pairwise subject types).
The admin API requires an authenticated admin session. See
Authentication Methods and the RBAC configuration
in Configuration for how
to grant the clients:write permission.
Create a client
curl -s -X POST https://idp.example.com/api/admin/clients \
-b session.jar \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Web App",
"redirect_uris": ["https://app.example.com/callback"],
"scopes": ["openid", "profile", "email", "offline_access"],
"token_endpoint_auth_method": "client_secret_basic",
"client_secret": "change-me-to-a-random-secret"
}'
Registering a known application (Grafana, GitLab, Zabbix, …) from scratch means
remembering which scopes, grant types, and NameID settings it needs. A profile
presets those: append ?profile=<id> and the profile’s values fill every field
the request body omits — the body always wins, and per-instance fields such as
client_name and redirect_uris still come from the request.
curl -s -X POST 'https://idp.example.com/api/admin/clients?profile=grafana' \
-b session.jar \
-H "Content-Type: application/json" \
-d '{
"client_name": "Grafana (prod)",
"redirect_uris": ["https://grafana.example.com/login/generic_oauth"],
"client_secret": "change-me-to-a-random-secret"
}'
The admin WebUI’s client and SP create forms show the same list as a “Create
from template” picker. GET /api/admin/profiles (profiles:read permission)
lists what is available; built-in profiles ship with the binary and
[profiles] dirs adds your own — see
Configuration Reference — [profiles].
OAuth2 profile signing compatibility
Every built-in OAuth2 preset explicitly sets
id_token_signed_response_alg = "RS256". In ahdapa this selects the signing
algorithm for both ID tokens and access tokens, including refreshes.
The server may continue using ML-DSA-* for other clients; these samples
must not inherit an AKP/post-quantum default that their token verifiers
cannot process. RSA is a conservative shared baseline, not a claim that
all these applications only support RSA.
Upstream configuration and verifier sources were reviewed on 2026-10-30:
| Profile | Signing choice and RP configuration |
|---|---|
grafana | RS256 is in Grafana Generic OAuth’s explicit ID-token signature allowlist; ML-DSA is not. Set use_pkce = true, auth_style = InHeader, and use_refresh_token = true. Where supported, enable validate_id_token = true and set jwk_set_url to the IdP JWKS endpoint. Configuration · Verifier |
gitlab | GitLab uses OmniAuth openid_connect; its Ruby OpenID Connect/json-jwt verifier supports RSA signing. Match client_auth_method: basic to this preset and enable pkce: true; copy the callback URL for your configured provider name. Do not confuse the ID-token algorithm with a client-assertion signing algorithm. Configuration · OmniAuth options · Verifier |
keycloak | RS256 is a compatibility default across deployed versions, not a statement that modern Keycloak lacks PQ support: Keycloak 26.8.1’s JWK parser recognizes AKP. For OIDC brokering, enable S256 PKCE, Validate Signatures, and Use JWKS URL, with client-secret basic authentication. Configuration · JWK parser · Broker PKCE |
nextcloud | The OIDC app is user_oidc, not user_oid. Its discovery service permits RSA/ECDSA/EdDSA token algorithms, not ML-DSA; RS256 maps to an RSA JWK. Copy the app’s provider callback URL and verify user-ID mapping separately. The [saml2_sp] variant is independent and is not changed by the OAuth2 signing setting. Configuration · Allowed algorithms and JWK filtering |
generic-webapp, device-daemon | These are generic templates, not applications with known verifiers. RS256 is the interoperable OIDC baseline; the device preset requests openid, so it also gets ID tokens. Confirm the actual RP/daemon’s supported algorithms before selecting another one. OIDC discovery metadata requirements |
The five authorization-code presets also allow offline_access, matching
their refresh_token grant. The RP must explicitly request that scope to
receive a refresh token: add it to Grafana’s scopes, GitLab’s OmniAuth scope
list, Keycloak’s broker Default Scopes, or Nextcloud’s provider scopes
when enabling refresh. The device-only daemon preset does not enable the
refresh-token grant.
All authorization-code RPs must use S256 PKCE with ahdapa. Settings on the RP itself are not applied by the IdP profile. Never disable signature verification or TLS verification to work around algorithm incompatibility.
An explicit create-request value overrides the preset, for example
"id_token_signed_response_alg": "ES256" for a known-capable Grafana
installation. Set this only after checking the RP and any resource
server that validates its access tokens. Missing node-local keys are
provisioned on first use for registrations created or received after
startup; no restart is needed to start issuing RSA tokens.
JWKS compatibility is separate from token-signature compatibility.
The public /jwks endpoint still contains keys for other algorithms and
clients. Some RP/library versions reject an entire mixed JWKS when they
encounter an unknown AKP key type, even if the selected token uses RSA.
Check the deployed verifier’s handling of unknown keys; these presets do
not filter the JWKS or guarantee end-to-end integration with every version.
Profiles only affect new registrations. Updating the shipped files does
not retrofit existing clients: edit their id_token_signed_response_alg
through the admin API/UI, and use the same profile files on every node.
Successful response — 201 Created:
{
"client_id": "3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b",
"client_name": "My Web App",
"redirect_uris": ["https://app.example.com/callback"],
"scopes": ["openid", "profile", "email", "offline_access"],
"token_endpoint_auth_method": "client_secret_basic",
"source": "dynamic"
}
The client_id is a UUID generated by the server. Store it alongside
the client_secret you supplied (the server does not echo secrets on
subsequent GET calls).
Request body fields
| Field | Type | Required | Description |
|---|---|---|---|
client_name | string | yes | Human-readable display name. |
redirect_uris | array of strings | yes (for interactive flows) | Allowed redirect URIs. Must be https:// except for loopback addresses (127.0.0.1, [::1]). |
scopes | array of strings | no | Scopes the client is permitted to request. Defaults to []. |
token_endpoint_auth_method | string | no | Authentication method at the token endpoint. Default: private_key_jwt. See table below. |
client_secret | string | if client_secret_basic or client_secret_post | Shared secret. Stored in the database; choose a high-entropy random value. |
jwks_uri | string | if private_key_jwt | URL of the client’s JWKS endpoint. The server fetches the public key from here to verify signed assertions. |
tls_client_certificate | string (PEM) | if tls_client_auth or self_signed_tls_client_auth | PEM-encoded client certificate. The server extracts and stores the SHA-256 thumbprint; the full PEM is not persisted. |
tls_client_auth_subject_dn | string | no | Expected Subject DN for tls_client_auth. Not enforced by the server today; stored for informational purposes. |
grant_types | array of strings | no | If present, restricts which grant types the client may use. When absent, all grant types are permitted. Valid values: authorization_code, refresh_token, client_credentials, urn:ietf:params:oauth:grant-type:device_code, urn:ietf:params:oauth:grant-type:token-exchange, urn:ietf:params:oauth:grant-type:jwt-bearer. |
subject_type | string | no | "public" (default) or "pairwise". Pairwise derives a per-client pseudonymous sub from the underlying identity. |
kerberos_principal | string | if kerberos_client_auth (single-machine) | Exact Kerberos service principal. Format: service/host@REALM. |
kerberos_principal_pattern | string | if kerberos_client_auth (template) | Glob pattern matching multiple Kerberos principals. The * wildcard matches any sequence of non-@ characters. At most three * wildcards. Format: service/*@REALM. |
kerberos_hbac_service | string | no | FreeIPA HBAC service name. When set, the server enforces HBAC rules before issuing a token to a kerberos_client_auth client. |
id_token_signed_response_alg | string | no | JWS algorithm to use for signing both the ID token and the access token for this client (OIDC Dynamic Registration §3.1). Overrides the server-wide jwt_signing_algorithm. Allowed values: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512, EdDSA, ML-DSA-44, ML-DSA-65, ML-DSA-87. When absent, the server default is used. |
Supported token endpoint authentication methods
token_endpoint_auth_method | Required fields | Notes |
|---|---|---|
private_key_jwt | jwks_uri | Default. RFC 7523 §2.2 signed JWT assertion. Requires no shared secret. |
client_secret_basic | client_secret | HTTP Basic authentication header. Form-urlencode the client id and secret before base64 (RFC 6749 §2.3.1). For compatibility, credentials sent without that encoding are still accepted when only they match, with a warning in the server log. |
client_secret_post | client_secret | client_id + client_secret in the POST body. |
client_secret_jwt | client_secret | HMAC-signed JWT assertion (HS256/HS384/HS512). |
tls_client_auth | tls_client_certificate | RFC 8705 mutual TLS with a CA-issued certificate. |
self_signed_tls_client_auth | tls_client_certificate | RFC 8705 mutual TLS with a self-signed certificate. |
kerberos_client_auth | kerberos_principal or kerberos_principal_pattern | Ahdapa extension. Requires [ipa] gssapi = true. Admin API only — not available via DCR. |
none | (none) | Public client. No credential check. PKCE is required for all flows. |
Update a client
PUT /api/admin/clients/{client_id} replaces the registration with
the supplied body. The same field constraints apply. Fields that are
omitted revert to their defaults; the exception is client_secret and
tls_client_certificate_thumbprint, which are preserved from the
existing record when the update body omits them.
curl -s -X PUT https://idp.example.com/api/admin/clients/3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b \
-b session.jar \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Web App (v2)",
"redirect_uris": [
"https://app.example.com/callback",
"https://app.example.com/callback2"
],
"scopes": ["openid", "profile", "email", "offline_access"],
"token_endpoint_auth_method": "client_secret_basic"
}'
Response: 200 OK with the full updated client object.
Delete a client
curl -s -X DELETE https://idp.example.com/api/admin/clients/3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b \
-b session.jar
Response: 204 No Content. The client is tombstoned in the CRDT and
propagated to all cluster nodes. Any existing tokens for this client
remain valid until they expire (access tokens are self-contained JWTs);
revoke outstanding refresh token families separately via
DELETE /api/admin/refresh-families/{family_id} if needed.
List and inspect clients
# List all clients
curl -s https://idp.example.com/api/admin/clients -b session.jar
# Get a specific client
curl -s https://idp.example.com/api/admin/clients/3f8a2c1e-7d4b-4e9f-a0c1-2b3d4e5f6a7b \
-b session.jar
Dynamic Client Registration (RFC 7591)
POST /register allows a client to register itself without operator
involvement. It is available via two authorization paths:
Path 1 — Pre-shared initial access token
Set server.registration_token in ahdapa.toml:
[server]
registration_token = "a-random-high-entropy-token"
Then register:
curl -s -X POST https://idp.example.com/register \
-H "Authorization: Bearer a-random-high-entropy-token" \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://app.example.com/callback"],
"client_name": "Self-registered App",
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email"
}'
Path 2 — Kerberos service-principal session
A session cookie whose sub is a service principal in the server’s
own Kerberos realm (format service/host@REALM) may register without
a pre-shared token. See Authentication Methods
for how to obtain such a session via SPNEGO at /authorize.
DCR request body
| Field | Type | Notes |
|---|---|---|
redirect_uris | array of strings | Required. |
client_name | string | Optional. Defaults to the generated client_id. |
token_endpoint_auth_method | string | client_secret_basic (default), client_secret_post, private_key_jwt, or none. kerberos_client_auth is not available via DCR. |
scope | string | Space-separated. Defaults to "openid". |
jwks_uri | string | Required when token_endpoint_auth_method = "private_key_jwt". Unsafe URIs (private/loopback/link-local/CGNAT/IPv4-mapped hosts, non-HTTPS) are rejected at registration time with 400 invalid_client_metadata (jwks_uri must be a safe HTTPS URL (no private/loopback/link-local hosts).), using the same host-safety rules as fetch time. The allow_loopback_jwks_uri opt-in is preserved. |
client_secret | string | Optional. When omitted for client_secret_* methods, the server generates a random 32-byte secret. |
subject_type | string | "public" or "pairwise". |
authorization_details_types | array of strings | RFC 9396 §10. Types this client may request; each must be registered on the server, otherwise 400 invalid_client_metadata. Echoed in the response. |
DCR response — 201 Created
{
"client_id": "9b2e4f1a-3c7d-4a0e-b8f2-1d5a6c7e8f9g",
"client_name": "Self-registered App",
"redirect_uris": ["https://app.example.com/callback"],
"token_endpoint_auth_method": "client_secret_basic",
"scope": "openid profile email",
"client_secret": "server-generated-secret-here",
"registration_client_uri": "https://idp.example.com/api/admin/clients/9b2e4f1a-3c7d-4a0e-b8f2-1d5a6c7e8f9g"
}
The registration_client_uri points to the admin API resource for
this client. Subsequent management (updates, deletion) requires admin
RBAC permissions via the admin session — RFC 7592 management tokens
are not issued.
Registration for Kerberos clients
kerberos_client_auth clients must be registered via the admin API.
Dynamic registration rejects them with invalid_client_metadata.
See Authentication Methods
for the full workflow including HBAC enforcement and SSSD deployment patterns.
curl -s -X POST https://idp.example.com/api/admin/clients \
-b session.jar \
-H "Content-Type: application/json" \
-d '{
"client_name": "SSSD template client",
"token_endpoint_auth_method": "kerberos_client_auth",
"kerberos_principal_pattern": "host/*@EXAMPLE.COM",
"kerberos_hbac_service": "sssd-idp",
"scopes": ["openid"]
}'
The * wildcard in kerberos_principal_pattern matches any hostname in
the realm. Each matched machine presents its own Kerberos AP-REQ at the
token endpoint; the server verifies it against the pattern and, if
kerberos_hbac_service is set, against the replicated HBAC rule set.