Skip to content
rw3iss Auth

Client properties

The complete property list for an oauth_clients row. Ground truth is migration 025

For the m2m registry, see m2m_clients at the bottom.

oauth_clients

Identity

PropertyTypeWhat it doesDefault
client_idTEXT NOT NULL UNIQUEThe public name of the application. Sent on every authorize and token request, and lands in the id_token’s aud and azp.none — required
nameTEXT NOT NULLShown to the user on the consent screen and in the browser’s FedCM chooser.none — required
descriptionTEXTFree text for the admin UI. Not shown to the user.NULL
logo_urlTEXTRendered on the consent screen.NULL

Enforced: client_id uniqueness by the database; a duplicate on create returns 400 “That client_id is already registered”.

Gotcha — who picks the id. An admin chooses client_id. Self-service cannot: MintClientID derives it from the name plus 6 random bytes (my-app-3f9c1d2b04ae). A caller-chosen id is a squatting and impersonation surface — civicgate-official, or simply grabbing an id an internal client was going to want — and the id is not a secret, so nothing is gained by letting a registrant pick it.

Gotcha — logo_url must be https://. Self-service registration refuses anything else (validateLogoURL), which keeps an attacker-chosen image off a plain-http origin and keeps a javascript: or data: URL out of an <img src> on our origin. The admin path does not apply this check.

Credentials

PropertyTypeWhat it doesDefault
client_secret_hashTEXTbcrypt hash of the secret. NULL/empty means the client is public.NULL
client_secret_prefixTEXTFirst 12 characters of the secret, so a UI can say which secret is live.NULL

Enforced: Client.IsPublic() is !hash.Valid || hash == "". A confidential client must present its secret at the token endpoint (client_secret_post or client_secret_basic; Basic wins when both are sent). A public client presents none, and PKCE takes the secret’s place.

Format: cgs_ + 32 random bytes, base64url. The 12-character prefix leaves roughly 208 bits unguessed, so displaying it identifies the secret without meaningfully helping anyone reproduce it.

Redirects

PropertyTypeWhat it doesDefault
redirect_urisTEXT[] NOT NULLThe exact-match allow-list of addresses an authorization code may be delivered to. Also the source of the FedCM origin allow-list.'{}'
post_logout_urisTEXT[] NOT NULLAcceptable post_logout_redirect_uri values at /oauth/logout. Also counts toward allowed origins.'{}'

Enforced: Client.AllowsRedirect at /oauth/authorize (before any redirect happens) and again at /oauth/token against the URI the code was issued for. EndSession matches post_logout_uris the same way. Registration-time validation is oidc.ValidateRedirectURIs, shared by the admin and self-service paths.

Gotcha — at least one is required. Both create paths refuse an empty list rather than storing a client that cannot complete a single login. post_logout_uris may be empty; post-logout redirection is optional.

Gotcha — max 10 each (oidc.MaxRedirectURIs), 512 characters per entry.

Full rules: Redirect URIs.

Capabilities

PropertyTypeWhat it doesDefault
allowed_scopesTEXT[] NOT NULLThe ceiling on what this client may ever obtain.'{openid,profile,email}'
grant_typesTEXT[] NOT NULLWhich OAuth grants this client may use.'{authorization_code,refresh_token}'

allowed_scopes is enforced by Client.FilterScopes, which intersects the request with this list at /oauth/authorize, so a compromised client cannot escalate its own access by simply asking for more. The scope in the token response is the granted set, which may be narrower than what was requested — read it rather than assuming.

Gotcha: admin create force-prepends openid if you leave it out, and substitutes ["openid","profile","email"] for an empty list. Self-service rejects an out-of-range scope outright rather than silently dropping it — a silent drop leaves a developer with a client that authorises fine and then returns a token missing the claim they asked for, with nothing anywhere explaining why.

grant_types is enforced — this is new. See below.

grant_types, in detail

Checked by Client.AllowsGrant(grant) at three call sites:

WhereEffect when the grant is not registered
/oauth/authorize302 …?error=unauthorized_client to the (already validated) redirect URI
/oauth/token, authorization_code400 unauthorized_client
/oauth/token, refresh_token400 unauthorized_client
/oauth/token, client_credentials400 unauthorized_client

Authorize is checked as well as token deliberately: checking only at the token endpoint would walk a person through a full consent screen before refusing a client that was knowably ineligible before anyone was asked to approve anything.

Accepted values are exactly oidc.SupportedGrants: authorization_code, refresh_token, client_credentials. Both admin create and admin PATCH validate against that list and refuse anything else with 400 Unsupported grant type: <value>. Discovery’s grant_types_supported is derived from the same constant, so the document cannot advertise something the endpoint does not serve.

Who may set what:

Pathgrant_types
POST /admin/oauth/clientsChosen; validated; client_credentials refused when public: true
PATCH /admin/oauth/clients/{id}Chosen; validated (see the gotcha below)
POST /oidc/clientsFixed to authorization_code + refresh_token. Not caller-settable.
PATCH /oidc/clients/{id}Not readable from the body and not in the SET clause — belt and braces

Fixed. PATCH now loads the client and refuses client_credentials on a public one, exactly as create does. It previously could not tell, because it never loaded the row. Admin create refuses client_credentials on a public: true client. Admin PATCH validates only that each value is a supported grant — it does not load the client, so it cannot tell whether the row is public, and it will store the combination.

This is not exploitable: tokenByClientCredentials refuses a public client at request time with invalid_client — client_credentials requires a confidential client with a secret, before it even consults AllowsGrant. But the stored row will claim a capability the client does not have, so do not rely on the column alone to tell you what a public client can do. :::

Trust and binding

PropertyTypeWhat it doesDefault
trustedBOOLEAN NOT NULLSkips the consent screen entirely.FALSE
require_pkceBOOLEAN NOT NULLForces PKCE even for a confidential client.TRUE
statusTEXT NOT NULLactive | anything else.'active'
app_codeTEXTNames an apps row; scopes the issued access token.NULL
owner_user_idUUID REFERENCES users(id) ON DELETE CASCADESelf-service owner. NULL = administrator-created.NULL
created_at / updated_atTIMESTAMPTZBookkeeping.now()

trusted is enforced at /oauth/authorize: if !client.Trusted gates the whole consent block. Only for first-party clients you operate yourself. It is never settable through self-service.

require_pkce is enforced at both authorize and token. The effective rule is client.RequirePKCE || client.IsPublic() — a public client always needs PKCE whatever the column says. When required, code_challenge is mandatory and code_challenge_method must be S256; plain is refused outright at authorize even though the RFC permits it, because it offers no protection against an attacker who saw the challenge.

status is enforced by the runtime lookup only:

SELECT … FROM oauth_clients WHERE client_id = $1 AND status = 'active'

Any value other than active makes the client invisible to /oauth/authorize, /oauth/token, /oauth/logout and FedCM — the error is Unknown or inactive client, deliberately not distinguishing the two. GetClientByOwner omits that filter on purpose: a disabled client must still appear on the screen that could re-enable it. Self-service PATCH restricts the value to active or disabled; admin PATCH accepts any string.

owner_user_id is enforced inside the WHERE clause of every self-service statement, never by a fetch-then-compare. ON DELETE CASCADE is deliberate: the account goes, its applications go with it, because an orphaned client is a live credential nobody can see in a UI to revoke.

app_code is the property most often misread — it does not decide which users may sign in. See Users, apps and tenancy.

Field-name mapping

The JSON body, the DTO and the column all use the same names, with two exceptions worth knowing:

Request fieldColumnNote
public(none)A create-time flag, not a column. true → no secret is generated, so client_secret_hash stays NULL, which is what IsPublic() reads. You cannot PATCH a client between public and confidential; rotate-secret always writes a hash.
(none)client_secret_hashNever returned. The DTO exposes has_secret and is_public booleans instead.

m2m_clients

A different table with a different shape, for machine callers. Created by POST /admin/m2m-clients (system_admin only), listed and revoked through the same prefix. There is no self-service path and no UI.

PropertyTypeWhat it doesDefault
client_idTEXT NOT NULL UNIQUEOperator-chosen, e.g. rm-prod. Easier to read in logs than a UUID.none — required
client_secret_hashTEXT NOT NULLbcrypt. Plaintext returned once on create. Never optional — there is no public m2m client.none
name / descriptionTEXTBookkeeping. name rides in the token’s service_name.
scopesTEXT[] NOT NULLPermission strings this client may assert. The issued token’s scopes are the intersection with any requested scope. Empty = the client’s full grant.'{}'
allowed_audiencesTEXT[] NOT NULLOptional restriction on target audiences. Empty = the server’s default audience.'{}'
statusTEXT NOT NULL CHECK (active|disabled)Disabled clients fail the grant without leaking whether the secret was right.'active'
last_used_atTIMESTAMPTZStamped on every successful grant — the “what is still consuming this?” check before revoking.NULL
revoked_atTIMESTAMPTZSoft revoke. Filtered out of the hot-path index.NULL
created_byUUID REFERENCES users(id) ON DELETE SET NULLWhich admin minted it.NULL

Revocation is not immediate for outstanding tokens. Service tokens are stateless — no row is persisted — so revoking the client fails all future grants while tokens already issued age out at their natural expiry (≤ AccessTokenExpiry, ~15 minutes).