Applications
A person who authenticates here has a directory entry and a row on
/admin/users. The application on the other side of that authentication
has one too: an entry under ou=applications in the embedded directory. It
answers the question what applications has this service seen?, whatever
protocol brought each one.
The entries are the registry, not a copy of one. Every protocol endpoint
that checks a client’s configuration reads it from these entries, so an
ldapmodify of one is a configuration change.
dc=example,dc=com
├── ou=users people
├── ou=groups groups, which grant nothing by being groups
│ (a token can carry them, see groups.claim)
└── ou=applications OAuth clients, OIDC relying parties, SAML 2.0 and 1.1
service providers, WS-Federation applications, WS-Trust
relying parties, the OpenID4VP verifier, Kerberos services
The directory is a tree per trust realm, so each realm has a
registry of its own. GET /admin/ldap/applications lists the entries and
publishes the schema; ?format=json is the machine-readable form.
Features
One entry per identifier
An entry appears the first time an identifier is accepted:
- a
client_idat the authorization or token endpoint; - a
wtrealmon awsignin1.0response; - an
AppliesToon an issued WS-Trust token; - a Kerberos service principal name on a TGS-REP, and again when a ticket for it is accepted;
- the OpenID4VP Verifier’s own
client_id.
The key is the identifier verbatim, not lower-cased and not namespaced by protocol. An application that appears under one name in two protocols is one entry with two kinds, not two entries.
The Kerberos service recorded at both ends is still one entry: both halves
write SPN@REALM. The acceptor’s half is the only one that fires for a ticket
another KDC issued — a real Active Directory, for example — so the service
a client presented that ticket to is recorded too.
One sighting can name more than one kind at once, because some
applications really are two things in one request. A wtrealm is a
WS-Federation application and the audience of the SAML 1.1 or 2.0 assertion
it was handed. An AppliesTo handed a SAML 2.0 assertion is a WS-Trust
relying party and that assertion’s service provider. This is the same rule
that makes alice, alice@REALM and her urn:uuid:<entryUUID> subject one
person on /admin/users, and it is what federation needs: a
relying party that federates over both OIDC and SAML is one relationship.
Each protocol records its application where it accepts it, not at a shared
funnel. The user side has one funnel; this side cannot, because in the
authorization code flow the person is authenticated by the sign-in service,
which knows nothing about OAuth and never reads a client_id. So each protocol
records its own application at the point it decides that application is real.
The directory is the source of truth
Nothing shadows these entries in memory. Every query is a directory read, and nothing is cached. Three things follow.
An ldapmodify is a configuration change. Add a value to
oauthRedirectUri on a client’s entry and the next authorization request is
matched against it — no restart, no reload.
RFC 7591 registrations live here too. POST /oauth2/register writes an
entry, and RFC 7592’s read, update and delete operate on it. The whole
registration document is kept verbatim in appRegistrationJson, because RFC
7591 permits arbitrary metadata and no fixed set of attributes can hold it. When
the record is rebuilt, that document is the starting point, and every member
that has an attribute of its own is then overwritten from the attribute. An
operator who edits oauthRedirectUri is therefore never ignored by the check
that reads it.
Deleting a registration keeps the entry. appRegistered becomes FALSE,
the client_secret and the registration access token are removed, and the
history stays. The registry records what this service has seen; losing the
fact that an application was ever here because its registration was withdrawn
would lose history, not configuration.
What the checks read
The checks read the attributes, never the registration document, through
one function (clientConfigOf()). The exact-match redirect-URI check, whether
a client is public or confidential, the client-secret check, the scopes a
client may be issued (oauthAllowedScope), consent, delegated permissions and
the CORS allowlist all resolve to attributes on an entry. It does not matter
whether a registration, a console form, the management API or ldapmodify put
them there. appRegistered records how an application got here, not whether
what it holds counts.
That makes one client_id able to exercise both halves of RFC 6749 section
2.1.1 without a restart: make oauthTokenEndpointAuthMethod hold none alone
(it may hold several methods, but never none beside another) and the
client becomes public — PKCE is required of it and its secret is no longer
checked — and set it back and it is confidential again.
Which of these checks are enforced depends on the mode: product mode implies RFC 9700 mode and enforces all of them; see Development and product mode below.
Software statements (RFC 7591 section 2.3)
A registration may carry a software_statement: a signed JWT of client
metadata, vouched for by whoever publishes the software rather than by the
installation registering it. A trusted statement’s claims take precedence
over the registration’s own JSON (section 3.1.1), and the statement comes
back unmodified in the response (section 3.2.1).
POST /oauth2/register
{ "software_statement": "eyJhbGciOiJSUzI1NiIsInR5cCI6InNvZnR3YXJl…",
"client_name": "Acme Mobile" }
201 { "redirect_uris": ["com.acme.mobile:/oauth2/cb"], <- from the statement
"client_name": "Acme Mobile", <- from the JSON
"software_statement": "eyJhbGciOiJSUzI1NiIsInR5cCI6InNvZnR3YXJl…",
"client_id": "sts-client-…", … }
Who is trusted is declared, the same way an RFC 7523 assertion issuer is:
- This realm itself, for a statement issued from an application’s own page
(Software statements → Issue a statement) or with
POST /admin-api/applications/issue-software-statement. It is typedsoftware-statement+jwt, names as issuer the address the request arrived on, names the application insub, and is signed with the realm’s key — so in development mode, where the key is regenerated at each start, it stops verifying at the next restart. - An application that declares the issuer in
oauthSoftwareStatementIssuer— the software publisher. Its statements verify against itsjwks, its RFC 7523 key pair from/admin/pki, or anx5cthis realm issued to it.
Everything else is refused in every mode. A document that is not a JWS, is
unsigned or HMAC-signed, names no iss, does not verify, has expired, is
addressed (aud) to another server, or is one of this realm’s other JWTs is
invalid_software_statement. An issuer nothing declares is
unapproved_software_statement. oauth2.softwareStatementRequireTrustedIssuer
turns the second refusal off; a statement accepted that way is recorded as
unverified, and its claims lose to the JSON.
In product mode a trusted statement is a second way through a closed
endpoint. With oauth2.openRegistration off, a registration carrying one is
accepted while oauth2.softwareStatementOpensRegistration is on (the default),
and registration_endpoint stays in the discovery documents. A client admitted
that way must present a trusted statement from the same issuer with every RFC
7592 update, so it cannot PUT away the metadata the statement fixed.
oauth2.softwareStatementRequired demands a statement on every registration.
A statement is not spent. Section 2.3 expects every copy of the software to
present the same one, so no jti history is kept. The entry records how a
statement let a client in, as appSoftwareStatementIssuer,
appSoftwareStatementTrusted and appSoftwareStatementPublisher.
The rest of dynamic registration — the OpenID Connect Registration rules it applies, RFC 7592 updates — is on OAuth 2.0 and OpenID Connect.
The applications that are this service
Every other entry arrives because somebody presented an identifier. This
service’s own surfaces never are presented from outside, so they are seeded at
startup, under ou=applications with everything else:
| Entry | What it is | Realms |
|---|---|---|
sts-admin-console |
The admin console at /admin: a confidential OpenID Connect relying party on the authorization code grant, redirect URI /admin/callback |
every realm |
sts-user-portal |
The user portal at /portal: the same shape, redirect URI /portal/callback |
every realm |
sts-management-api |
The management API: a confidential OAuth client on client_credentials, with client_secret_basic, scope admin:read admin:write, and no redirect URI |
every realm |
sts-debugger-api |
The embedded protocol debugger’s api: a resource server that defines one delegated permission, urn:sts:debugger-api:debugger |
default realm, only while the debugger is embedded |
sts-debugger-ui |
The embedded debugger’s browser client: a relying party granted that permission | default realm, only while the debugger is embedded |
They are full RFC 7591 registrations rather than labels, and they are
load-bearing. /admin, /portal and the debugger are relying parties of this
service’s own authorization server: an unauthenticated request is sent to
/oauth2/authorize with the entry’s client_id, comes back to its redirect
URI with a code, and the code is redeemed at /oauth2/token. The three hosted
surfaces authenticate there with private_key_jwt, using a key this service
issued them; the management API has a client_secret. Each entry also carries
a registration access token, so GET /oauth2/register/sts-admin-console
returns the registration to whoever holds it. RFC 9700 mode checks these
clients by the same rules as anybody else’s.
The console, portal and debugger entries carry oauthGlobalConsent for
openid, profile, email and offline_access (and the debugger’s
permission), so a person is not asked whether they consent to this service
reading their own profile. It is an attribute rather than an exemption, so an
operator who wants the consent screen removes the values.
The sts-admin-console entry is the client the console signs in as; it does
not make anybody an administrator. The console roles
decide that.
They are seeded only where the identifier is free. An operator who
deleted one meant it, and re-creating it would make the delete appear not to
work. Deleting one of these entries takes the surface it names offline. In
the default memory persistence mode nothing is written down, so the next start
seeds them again, with new secrets; with a store on, the directory that was
written down replaces the seeded one, so a deletion survives the restart.
applications.seedInternal turns seeding off, and takes effect at the next
start. The management API’s default-realm secret is pinned by
adminApi.clientSecret where that is set.
The schema, and what “schema” means here
node-ldapjs has no schema subsystem: it is protocol machinery — messages,
filters, DN parsing, a client and a server. So the applications schema is
defined by this service, published on /admin/ldap/applications, and
enforced by nothing in LDAP — a vocabulary, not a constraint, like the rest
of this schemaless directory. See LDAP and
LDAP schema.
Where a standard name exists it is used. applicationProcess (RFC 4519) is the
one registered object class that fits an application, and it brings cn and
description. No registered LDAP schema has a client_id, a set of redirect
URIs, an entityID or a service principal name, because products that store
OAuth clients keep them in their own databases. So stsApplication is this
service’s own class, and its attributes are this service’s own names, as
x509subject, didSubject and authnMethod are on the user entries.
The published table is the definition. The entry is built by walking it,
and an attribute that is not in it is refused when this service writes it.
multi accumulates a repeat and single is assigned, which stops a counter
growing a value per sign-in. Beside the identity and the counters
(appAuthentications, appSessions, appUsers) sit the protocol-specific
attributes: oauthClientId, oauthRedirectUri, oauthGrantType,
oauthTokenEndpointAuthMethod, oauthConfidential, samlEntityId,
samlAssertionConsumerService, wsfedRealm, wsfedReplyUrl,
wstrustAppliesTo, krb5ServicePrincipalName, oid4vpClientId,
federationPartnerId, ldapBindDn, scimClientId, spiffeWorkloadId, and
more for the features that read them (oauthAllowedScope, appCorsOrigin,
oauthPermission, oauthGlobalConsent and the rest — the published table is
the full list).
Every protocol family has an identifier attribute, and all but one
accumulate. Fourteen families share eleven attributes: OAuth 2.0, OpenID
Connect and OpenID4VCI share oauthClientId (a relying party is an OAuth
client, and a wallet authenticates as one), and both SAML profiles share
samlEntityId, because those specifications share the identifier and two
attributes for one fact would be two spellings that disagree. They are multi
because one application legitimately answers to two client_ids or two SPNs,
one per environment. The exception is mutual TLS’s
oauthTlsClientAuthSubjectDn, which stays single-valued because RFC 8705
section 2.1 matches a certificate against “the single expected subject”, and a
client registers at most one of the five subject parameters.
Four of them are declaration only. Nothing in this service writes
federationPartnerId, ldapBindDn, scimClientId or spiffeWorkloadId: LDAP
and SCIM authenticate the caller rather than an application identifier,
SPIFFE files identities under ou=spiffe, and a federation relationship lives
under ou=federations. They exist because what is this application called
when it talks to us that way is a fact an operator has, and a value in one
grants nothing.
The client secret is stored on the entry. oauthClientSecret and
appRegistrationAccessToken hold credentials this service minted. That is the
same decision /krb5/principals makes about the Kerberos fixture passwords: a
test service whose accounts are unusable without reading the source is worse
than one that says what they are. Be precise about the cost: wherever the
secret is checked, anyone who can read it can authenticate as that client. In
product mode an LDAP search never returns it — credential attributes are
withheld from every reader of the socket — and the console shows it to an
administrator only, marked as a credential. It is never written to the audit
log.
appSessions and appUsers are counts, not lists. The ids themselves are
deliberately not on the entry — an application used by two thousand people
would otherwise carry two thousand values — so the count increments when the id
differs from the last one recorded. That is right for the ordinary case and
undercounts somebody alternating between two applications.
Declared and derived
An entry holds two kinds of fact, and the line between them decides what may be changed from the console and the API.
- Declared is what this application is allowed to do: its redirect URIs, grant types, scopes, secret, whether it is confidential, the protocol families it is for. It is configuration, it is what the checks read, and it is editable.
- Derived is what happened: the counters, the first and last sighting, the kinds and protocols it has been seen in, the redirect URIs it actually used.
A form that could rewrite the derived facts would make the page lie about the
service’s own behaviour, in a way indistinguishable from the recording being
broken, so the console and the API refuse them with a list of what is not
editable. ldapmodify still reaches every attribute: an operator with an LDAP
client is doing something deliberate, and refusing it there is not the same
as not offering it here.
The console page and the API
/admin/applications (Directory → Applications) is the other side of
/admin/users: that page lists every identity that has authenticated here, and
this one lists what they authenticated to. Filter by identifier or name and
by kind, page with ?page= and ?per=, and ?application=<id> drills into
one: every attribute of its directory entry with what the published schema says
each attribute is, paged under ?attributesPage=. ?format=json returns the
same data, and GET /admin-api/applications is the same view with the same
parameters.
“Every attribute” is meant literally. The drill-down shows the whole entry,
including the operational attributes createTimestamp and modifyTimestamp
and anything an ldapmodify wrote by hand.
The DN is published as entryDN — RFC 5020’s name, and the name an
ldapsearch filter matches it by here — and it is computed on every read
rather than stored. A stored copy would be a second definition of the same
fact, and the one that goes stale when somebody renames an entry. It appears at
the top of the drill-down, on every row of the list, on
/admin/ldap/applications, and as dn on every application in the API’s
reply, because the DN is the address an ldapsearch or ldapmodify is aimed
at. Attribute names come back canonically spelled — oauthClientId, not
oauthclientid (LDAP).
Both the page and the API write: create, set, add, remove,
revoke-registration and forget, as forms on the page and as
POST /admin-api/applications/{action}. Every action calls the same function
in applications.js that a protocol endpoint and an ldapmodify reach, against
the same entries, so a form post and an ldapmodify are one act arriving by
two routes, each visible to the other immediately.
createconfigures a relying party before it connects. Without it, the only ways to give an unregistered client its own redirect URIs would be/oauth2/registeror the globaloauth2.redirectUrissetting. The entry records that it was created by hand.revoke-registrationkeeps the entry and its history, and takes away only the registration, the secret and the registration access token.forgetis the one operation that loses a fact, which is why it is separate fromrevoke-registration.
The page marks oauthClientSecret and appRegistrationAccessToken as
credentials where it prints them. Two counting caveats are on the page:
Sessions and Users count changes rather than distinct sets, and ?kind=
does not partition the list, because a record commonly carries two kinds.
/admin/applications/new
create also has a page of its own. Name a client_id, wtrealm,
AppliesTo, entityID or service principal name that has never connected, tick
the protocol families it is declared for, and fill in what each protocol
will call it and where its responses go back to.
The families are a closed list of fourteen: OAuth 2.0, OpenID Connect, SAML
2.0, SAML 1.1, WS-Federation, WS-Trust, Kerberos v5, OpenID4VCI, OpenID4VP,
Federation, LDAP, SCIM 2.0, SPIFFE and TLS / mutual TLS. They land on
appAllowedProtocol; the identifiers and addresses land on the schema’s own
attributes. GET /admin-api/applications/new returns the same vocabulary as
JSON, so a caller can read what a create will accept from the service.
POST /admin-api/applications/create takes the families as protocols and the
attributes as fields, keyed by attribute name.
Eleven identifier fields cover the fourteen families, and three fields take
redirect URIs, because only three families send a response back through a
browser: oauthRedirectUri, samlAssertionConsumerService and
wsfedReplyUrl. Multi-valued fields take one value per line — newline
separated, not comma separated, since a redirect URI may legally contain a
comma and may not contain a newline.
The entry lands in the trust realm the console is showing, at that
realm’s ou=applications. An ldapsearch under that realm’s base DN sees it;
another realm’s registry has never heard of it.
The page posts action=create to /admin/applications, the same action the
short row at the foot of the list posts. That inline row still takes an
identifier and a name, for somebody already looking at the list.
Importing a protected resource’s metadata (RFC 9728)
Tick Use a protected resource metadata document and paste the JSON, upload
the file, or name the URL it is published at (usually
https://<host>/.well-known/oauth-protected-resource). The page shows the
document in three tabs — the raw JSON, a table of its values, and the fields
read from it, editable — and fills in the create form:
- the document’s
resourceas the default name, asoauthPermissionBaseUriand asoauthAudience; - one permission per
scopes_supportedvalue, with the resource prefix taken off (https://api.example.com/readunderhttps://api.example.comisread); - a client_id generated at random;
- OAuth 2.0 ticked.
Nothing is created until Create is pressed. The document is kept on the
entry as oauthResourceMetadata, and oauthResourceMetadataUrl where it was
fetched.
- Its
authorization_serversare compared with this realm’s. Each one that is an issuer this realm publishes is shown green; one that is not is a warning, and the application can still be created. - A fetch follows the federation outbound policy —
federation.outbound, https with the certificate verified (#171), no redirects,federation.maxResponseBytes,federation.outboundTimeoutMs— and needs Admin Write, because the URL is one an administrator names. - In product mode the URL may not resolve to a loopback, private,
link-local or reserved address (the name is resolved once and the connection
pinned to the checked address), and a document whose
resourceis not the identifier its well-known URL was built from (section 3.3), or is not https, is refused. Development mode reports both and imports anyway. A malformed document is refused in both modes. signed_metadatais decoded and shown, and not verified or applied — this service holds no key for the resource.
POST /admin-api/applications/load-resource-metadata does the same for a JSON
caller (document or url) and returns the proposed application in plan;
the create is then POST /admin-api/applications/create with those values.
Declared families and recorded ones
appAllowedProtocol and appProtocol must not be read as one thing. The
first is declared and editable; the second is what happened, accumulated by the
endpoints, and refused to every form. The drill-down shows them side by side
under Protocol families, with a Declared column and a Recorded one.
The match between them is made on the entry’s kinds rather than on protocol
labels, because a federation partner’s sighting is recorded under whichever
protocol its relationship speaks and would be indistinguishable by label from
an ordinary OAuth client’s.
Recorded is not the same as has authenticated:
POST /admin-api/applications/create and the Register buttons on
/admin/saml2 and /admin/saml11 take a kind, so a hand-made entry can be
recorded in a family it has never connected in. The Authentications count
answers that question. Five families — LDAP, SCIM, SPIFFE, mutual TLS and
OpenID4VCI — have no kind at all, because this service records no application
identifier in them; those rows say never recorded here rather than no.
Declaring a family grants nothing, and in product mode it refuses the
rest. In product mode the issuance policy refuses an issuance through a
family the application is not declared for: an application declared for SAML
2.0 alone is refused an access token at /oauth2/token, and one declared for
OAuth 2.0 alone is refused an ID Token (an access token is OAuth 2.0’s, OpenID
Connect’s, OpenID4VCI’s and mutual TLS’s; an ID Token is OpenID Connect’s
alone). In development nothing is refused, and an application declared for
nothing is refused nothing in either mode. The issuance policy’s
decideProtocols answer turns the rule off. Separately, SAML 2.0 in product
mode: the per-service-provider
paths /saml2/metadata/{sp}, /saml2/sso/{sp} and /saml2/slo/{sp} answer
only for a registered service provider — an entry of that kind, or one declared
for SAML 2.0 (#112) — see SAML 2.0 Web Browser SSO. What
otherwise takes effect is the configuration underneath: redirect URIs, grant
types, scopes and the secret.
A DID describing an application
Tick Decentralized Identifier (DID) among an application’s protocol families and this service advertises a W3C DID for it:
- The DID is a
did:webunder the realm’s address:did:web:<host>[:realm:<id>]:applications:<identifier>. Characters adid:webcomponent cannot hold (such as:and/in a URN identifier) are percent-encoded. - The document is served at
<base>/applications/<identifier>/did.json(application/did+json,no-store). It is answered only once the application has a key; until then it is a 404. - Its contents are three attributes, on the application’s
Decentralized Identifier (DID) configuration tab:
didPublicKeyJwk: the public keys, each a JsonWebKey2020 method named<did>#<kid>underauthenticationandassertionMethod. A value with a private member is refused.didService:<type>|<https URL>, each aserviceentry.didAlsoKnownAs: absolute URIs.
- Generate a key pair on that tab (ES256, ES384 or EdDSA), or call
POST /admin-api/applications/generate-did-key. The public key is added to the document. The private key is shown once, and kept by this service sealed (like an application’s RFC 7523 key pair) so it can sign the application’s Domain Linkage Credentials. Tick replace to make the new key the only one. - Domain linkage. For each
LinkedDomainsservice, Download did-configuration.json (orPOST /admin-api/applications/sign-domain-linkagewith theorigin) signs a DIF Domain Linkage Credential with a kept key. Host the file athttps://<origin>/.well-known/did-configuration.json: it proves the DID and the origin are one party. It is good foroid4vci.domainLinkageLifetimeS(a year by default). A key pasted in by hand has no private half here and cannot sign one.
The DID follows the address the service is reached on; pin
global.publicBaseUrl so it does not change with the host name.
Certificate enrollment (ACME, EST, SCEP)
ACME, EST and SCEP are three protocol families an application may be declared for. In product mode an application is issued a certificate only over a family it is declared for. An application declared for nothing is not refused, in either mode.
The application’s Certificate enrollment configuration tab overrides the realm’s settings for that application alone. A value set there replaces the realm’s, in either direction; an empty field leaves the realm’s in force:
| Attribute | Overrides | What it does |
|---|---|---|
acmeAllowedProfiles, estAllowedProfiles, scepAllowedProfiles |
<family>.allowedProfiles |
The profiles it may be issued, in place of the realm’s list. The CA, OCSP and KDC profiles are never issued. |
acmeDefaultProfile, estDefaultProfile, scepDefaultProfile |
<family>.defaultProfile |
The profile used when a request names none. |
acmeCertificateLifetimeDays, estCertificateLifetimeDays, scepCertificateLifetimeDays |
<family>.certificateLifetimeDays |
Its certificates’ lifetime, longer or shorter than the realm’s. No certificate outlives its Issuing CA. |
enrollMaxCertificates |
pki.enrollmentMaxCertificatesPerEntry |
How many certificates it may hold, higher or lower than the realm’s. |
estBasicAuthentication, estCertificateAuthentication |
est.basicAuthentication, est.certificateAuthentication |
TRUE accepts and FALSE refuses that EST authentication method when the application authenticates itself, whatever the realm says. |
estServerKeyGeneration |
est.serverKeyGeneration |
TRUE allows and FALSE refuses /serverkeygen for its certificates, whatever the realm says. |
A refusal is the protocol’s own error: an ACME problem document, an EST
HTTP status, a SCEP failInfo.
Its certificates, on its own page. For an application declared for ACME, EST or SCEP, the Credentials tab and the Certificate enrollment configuration tab both show:
- the rules in force for it: profiles, default profile, lifetime, certificate cap and EST’s switches, each marked as the application’s own or the realm’s;
- every certificate it was issued over the three protocols, with its profile, serial, names, dates and status, a PEM download and a Revoke button;
- for ACME, its External Account Binding keys, with Create an EAB key and Delete;
- for EST, Issue a certificate with a server-generated key;
- for SCEP, its challenge passwords, with Create a challenge and Delete;
- its registered host names, with Add and Remove.
Each control posts to the protocol’s own console action, so each has the
same /admin-api operation as on the protocol’s page. A one-time secret (an
HMAC key, a challenge, a private key) is shown once on the page the action
answers with, which links back to the application. GET
/admin-api/applications?application=<id> returns the same facts as
certificateEnrollment, with no secret.
Custom claims, SAML attributes and token lifetimes
The realm’s Token lifetimes, Custom claims, UserInfo claims and Custom SAML attributes pages (under Protocols) apply to every application. An application can override them on its own configuration tabs (Directory → Applications → the application → Configuration):
| Tab | Section | What it overrides |
|---|---|---|
| OAuth 2.0 / OpenID Connect | Token lifetimes | Shows the access token, ID Token, refresh token and refresh-idle lifetimes in force for this client, whether each is its own or the realm’s, and the realm page’s warnings. The overrides are the oauthAccessTokenTtlS, oauthIdTokenTtlS, oauthRefreshTokenTtlS and oauthRefreshIdleSeconds fields on the same tab. The clock skew stays realm-wide. |
| OAuth 2.0 / OpenID Connect | Custom claims | The access token, ID Token and UserInfo claims for this client. |
| SAML | Custom SAML attributes | The SAML 2.0 attributes (with an optional NameFormat) and SAML 1.1 attributes (with a namespace) for this audience. |
An application’s claims are added to the realm’s, and win by name. The realm’s claims still go out. Where the application and the realm name the same claim, the application’s value is issued. An application with no claims of its own gets exactly the realm’s set. Each section lists the claims in force, marked as the application’s own or the realm’s, with Remove on the application’s rows and a form to set one.
A row is either a typed value, with the same ${…} placeholders as the
realm’s page, or a directory attribute of the person (every value or the
first, and a JSON type for the token sets). Rows follow the realm’s rules: a
name the protocol sets itself (sub, iss, exp…), an attribute that may
not be released, or an unknown type is refused (STS-REG-0206). So is a set
whose protocol the application is not declared for.
Which application a claim applies to: for tokens and UserInfo, the client
(client_id); for SAML, the audience (the service provider’s entityID, or a
WS-Federation or WS-Trust relying party). These are the same keys its other
per-application settings use.
The management API: POST /admin-api/applications/set-custom-claim (with
set = access_token, id_token, userinfo, saml2 or saml11, and
name, plus value, or attribute with multi and type) and
POST /admin-api/applications/remove-custom-claim. GET
/admin-api/applications?application=<id> returns customClaims and
tokenLifetimes.
CORS: which pages may read an answer
CORS is an allowlist on every path, in both modes. An application’s
appCorsOrigin — a list of exact origins such as
https://spa.example.com, no path, no wildcard — is where a third-party origin
goes. An origin is echoed back when:
| The request | The origin must be |
|---|---|
comes from this service’s own origin — the address it was made to, global.publicBaseUrl, the embedded debugger, or global.corsOrigins |
nothing more: always allowed |
names a client — a client_id in the query or body, a client_assertion’s sub, /oauth2/register/{client_id}, a Basic user name, a JWT access token’s client_id or azp, a GNAP instance reference |
listed in that application’s appCorsOrigin. A name no application answers to gets no CORS header at all, so a page sees a CORS error rather than invalid_client |
names no client — discovery, a JWKS, a DID document, credential issuer metadata, and every preflight (which carries no body and no Authorization) |
listed by any application in the realm |
An empty list allows no third-party origin.
- Nothing sends
Access-Control-Allow-Credentials. - The answer exposes
WWW-Authenticate,DPoP-Nonce,Location,Link,Replay-NonceandRetry-After. - Every response carries
Vary: Origin. - A navigation (a SAML or WS-Federation form post) is left alone.
- In RFC 9700 mode
/oauth2/authorizegets no CORS header for anyone.
Values are stored normalised (HTTPS://App.Example.com:443/ is held as
https://app.example.com), and a path, *, null or a user name is refused
(STS-REG-0150). Set it from the application’s page,
POST /admin-api/applications/add with attribute: appCorsOrigin, or
ldapmodify; RFC 7591 registration has no member for it. A withheld header is
logged as STS-HTTP-0019 (a preflight), STS-HTTP-0020 (no client named),
STS-HTTP-0021 (unknown client) or STS-HTTP-0022 (the client does not list
the origin).
Development and product mode
| Development | Product | |
|---|---|---|
| A client’s redirect URIs | matched exactly in RFC 9700 mode; otherwise an address is observed and recorded | matched exactly (product implies RFC 9700 mode) |
| SAML ACS and WS-Federation reply addresses | recorded, and an observed address is marked | an address nobody registered is refused until confirmed |
| The client secret and declared authentication method | checked in RFC 9700, OAuth 2.1 and FAPI mode | checked |
An unknown client_id |
accepted, and an entry is recorded | refused |
| Software statements from an undeclared issuer | refused (both modes) while oauth2.softwareStatementRequireTrustedIssuer is on |
the same; a trusted statement also opens a closed registration endpoint |
| RFC 9728 import from a URL | internal addresses and a mismatched resource are reported, then imported |
refused |
| Credential attributes over LDAP | readable by a search | never returned by a search, filter or compare |
| SAML 2.0 per-SP paths for an unregistered SP | answered | refused |
| CORS | the allowlist | the allowlist |
See What is not checked for the full picture.
Configuration
The live source for every setting is its console page and
GET /admin-api/config; this table is a copy of rows in common/config.js.
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
applications.max |
STS_APPLICATIONS_MAX |
500 |
yes | How many entries may live under ou=applications. Past it a new application is refused and warned about rather than an old one evicted; separate from ldap.maxEntries. |
applications.seedInternal |
STS_APPLICATIONS_SEED_INTERNAL |
true |
no | Seed this service’s own surfaces (console, portal, management API, and the debugger’s two clients) as applications at startup. |
portal.applicationScanLimit |
STS_PORTAL_APPLICATION_SCAN_LIMIT |
1000 |
yes | How many registry entries /portal/applications asks the issuance policy about for one person before it stops. |
global.corsOrigins |
STS_CORS_ORIGINS |
empty | yes | Origins CORS treats as this deployment’s own, beside its listen addresses, global.publicBaseUrl and the embedded debugger. A value that is not an origin is ignored and logged. |
oauth2.softwareStatementRequireTrustedIssuer |
STS_OAUTH2_SOFTWARE_STATEMENT_REQUIRE_TRUSTED_ISSUER |
true |
yes | Refuse a software statement whose issuer nothing in the realm trusts (unapproved_software_statement). Off, it is accepted unverified and its claims lose to the JSON. |
oauth2.softwareStatementOpensRegistration |
STS_OAUTH2_SOFTWARE_STATEMENT_OPENS_REGISTRATION |
true |
yes | A trusted statement is accepted where registration is otherwise closed (product mode with oauth2.openRegistration off). |
oauth2.softwareStatementRequired |
STS_OAUTH2_SOFTWARE_STATEMENT_REQUIRED |
false |
yes | Refuse a registration or update that carries no statement. |
adminApi.clientSecret pins the management API client’s secret; it is on
Management API.
Design decisions
- The entries are the registry. A cache in front of the directory would be
a second store, and an operator’s
ldapmodifywould be ignored by the one check that matters. - The key is the identifier, not the protocol. An application is one entry that accumulates kinds rather than being filed twice, which is what a federation relationship over several protocols needs.
- There is no Kind select on the create page. A family is declared
and a kind is derived, written when a protocol recognises the identifier,
so a select would let a form assert a sighting that had not happened. The
kinds fill themselves in as the application is used.
POST /admin-api/applications/createstill acceptskind, because the SAML Register buttons pass one, and that is a protocol module’s statement rather than a person’s guess. - Nothing refuses a protocol because a family was not declared (bar the SAML 2.0 product-mode case above). Refusing a protocol in development would remove a test case rather than add one.
- One value per line, not per comma, because a redirect URI may contain a comma and may not contain a newline.
entryDNis computed, not stored, so it cannot go stale when an entry is renamed.- Seeded entries are left alone once they exist, and a deleted one stays deleted for as long as the directory remembers it.
In the running service
| Where | What |
|---|---|
/admin/applications |
The registry: list, filter, drill-down, and the edit actions. GET /admin-api/applications, POST /admin-api/applications/{action} |
/admin/applications/new |
Create an application, with its families, identifiers, redirect URIs and an RFC 9728 import. GET /admin-api/applications/new |
/admin/ldap/applications |
The entries as the directory holds them, and the published schema |
/admin/delegation-settings |
Delegated permissions defined and granted on application entries, and on each application’s Permissions tab (Admin console) |
/admin/consent |
Global consent on application entries, and per-person consent |
POST /oauth2/register |
RFC 7591 registration, which writes an entry |
/portal/applications |
The applications a signed-in person may use |
Related
- Admin console
- Management API
- OAuth 2.0 and OpenID Connect: registration, scopes, consent
- Security profiles: RFC 9700 mode
- LDAP and LDAP schema
- Federation
- Trust realms
- What is not checked