Authorization Server — token validation across instances
A deployment normally runs more than one instance of the MibAuthorizationServer against one
database: an internal one that other services call, and an external one exposed to clients
(mib-authorization and mib-authorization-ext). This page covers the one rule those instances
must obey — they must all present the same rootUrl — and what the password grant returns.
For the components referenced here see AuthorizationServerOverview.md; for the flows themselves see AuthorizationServerAuthenticationFlows.md.
Why rootUrl decides whether tokens cross instances
Access tokens are reference (opaque) tokens: the client receives a 43-character handle
(base64url of 256 random bits, no dots — it carries neither identity nor expiry), and the real
payload is a signed at+jwt stored in AUTH_GRANTS. The row is located by ReferenceId, which
is base64(sha256(handle)) — OpenIddict hashes the handle before storing it, so the value in the
table never equals the value the client holds.
rootUrl is also the OpenIddict issuer (ConfigureMibAuth.cs:325, server.SetIssuer), so it is
stamped as the iss claim of that stored payload. And OpenIddict validates iss against the
local issuer, in both of its pipelines:
| pipeline | guards | fills ValidIssuers from |
|---|---|---|
| validation | /api/v1/*, /connect/userinfo |
the local instance's issuer |
| server | /oauth/token, /connect/revocation, /connect/introspect, logout |
Options.Issuer ?? BaseUri |
Two instances with different rootUrl therefore reject each other's tokens even though they
share the database and the signing keys.
sequenceDiagram
autonumber
participant C as Client
participant EXT as auth-ext (rootUrl B)
participant INT as auth (rootUrl A)
participant DB as AUTH_GRANTS
C->>EXT: POST /oauth/token (grant_type=password)
EXT->>DB: store signed at+jwt, iss = rootUrl B
EXT-->>C: access_token (opaque handle)
C->>INT: GET /api/v1/me (Bearer handle)
INT->>DB: lookup by base64(sha256(handle)) → payload
Note over INT: payload carries iss = rootUrl B<br/>local ValidIssuers = rootUrl A
INT-->>C: 401 Invalid Token
Note that mib-api never validates locally — it always delegates to GET /api/v1/me of the
instance named in MIBAUTHORIZATIONCLIENTCONFIG_DEFAULT_SERVERURL, whichever instance issued the
token. That is why a client talking only to mib-api still hits this.
What it looks like when it happens
Every symptom below is the same misconfiguration. It is deterministic, not intermittent — a roughly 50% failure rate is a different problem (replica routing or a key ring), not this one.
| operation, performed against another instance | result |
|---|---|
GET /api/v1/me, and anything behind mib-api |
401 authorize_invalidtoken / Invalid Token, milliseconds after the token was issued |
POST /oauth/token with grant_type=refresh_token |
400 invalid_grant — "The issuer associated to the specified token is not valid" |
POST /connect/revocation |
200 OK and nothing revoked — the token keeps working. OpenIddict normalises the rejection into an empty success (the RFC 7009 shape), so the status code hides it |
POST /connect/introspect |
active: false for a live token |
The revocation row is the dangerous one: an operator revoking a leaked token against the "wrong"
hostname gets a success response while the token stays valid. Never take 200 as proof — use the
token afterwards.
Two details worth ruling out before looking elsewhere:
- Data Protection key rings are not involved. Two pods with entirely independent, ephemeral key
rings validate each other's tokens without trouble, as long as
rootUrlmatches. - Signing keys are shared by design, from
AUTH_SIGNING_CREDENTIALS/AUTH_VALIDATION_KEYS, so they are never the differing factor between instances of one deployment.
Diagnosing it
# every instance of one logical authorization server must print the SAME issuer
curl -s https://<instance>/.well-known/openid-configuration | jq -r .issuer
If they differ, tokens do not cross, and the fix is configuration — align rootUrl. It is not a
client change, and it cannot be worked around from the consumer side.
Setting rootUrl per instance
rootUrl is read from MibAuthorizationServerConfig.mibconfig inside the mounted global config
folder (<MIBCLIENT_BOOTSTRAP_GLOBALCONFIGFOLDER>/<MIBCLIENT_BOOTSTRAP_APPNAME>/). Two traps when a
deployment mounts that folder from shared storage:
- The environment variable does not override the file. Setting
MIBAUTHORIZATIONSERVERCONFIG_DEFAULT_ROOTURLhas no effect while the XML definesrootUrl: withMIBCLIENT2_CONFIG_MODEunset,MibConfigLoader's default source list is["XML"]and the environment-variable source is not registered at all. - Do not reach for
MIBCLIENT2_CONFIG_MODE=ENVVAR,XMLto force it. That replaces the source list the bootstrap had configured, the mounted config folder drops out, and the process starts but never listens on its port.
To give one instance a different rootUrl, overlay that single file (for example a ConfigMap
mounted with subPath over exactly that path) and leave the rest of the folder untouched.
Changing rootUrl invalidates nothing already stored, but every token minted before the change
carries the old iss and will be rejected afterwards.
What the password grant returns
A password-grant request that sends no scope parameter is granted every scope the client is
permitted to use — openid, profile, mibapi, plus offline_access when
API_CLIENTS.ALLOWS_REFRESH_TOKEN = 1:
{
"access_token": "…43 opaque chars…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile mibapi offline_access",
"refresh_token": "…"
}
offline_access is what makes OpenIddict issue the refresh token, so that widening is load-bearing:
without it a client that does not ask for scopes receives an access token and nothing else. Under
OpenIddict ALLOWS_REFRESH_TOKEN maps to a client permission (scp:offline_access,
MibApplicationStore.BuildPermissions), which is a ceiling on what may be requested rather than a
grant — the widening (PasswordGrantTokenHandler.cs:83) is what turns the permission into an actual
grant, matching what IdentityServer4 did before the engine migration.
There is no id_token on this response. Only the implicit flow and the authorization-code
exchange produce one — the same asymmetry IdentityServer4 had. The refresh grant does return an
id_token when the session's scopes resolve to identity resources, which a password-grant session's
do.
A request that does send scope is honoured as sent. Asking for a scope the client is not
permitted to use is rejected with 400 invalid_request (ID2051) rather than silently dropped,
so granted always equals requested — and because they are equal, RFC 6749 §5.1 makes the scope
field optional and OpenIddict omits it. Its absence on an explicit-scope request is expected, not a
bug.
Two related gotchas:
accessTokenMinutesis not honoured.expires_inis3600(60 minutes) whatever the setting says. A client that computes expiry from configuration instead of fromexpires_inwill hit a401believing it still has hours of margin.client_credentialsdoes not get this widening. A no-scopeclient_credentialsrequest is granted no scopes at all, where IdentityServer4 granted the API scope.
History
The behaviour on this page dates from the migration of the OAuth/OIDC engine from IdentityServer4 to
OpenIddict (MEDIAIBOX-11999, MediaiBox core 6.0.102). IdentityServer4 resolved reference tokens
by a store lookup and had no issuer concept for them, so a multi-instance deployment with divergent
rootUrl worked before that version and stopped working after it. The cross-instance validation and
the scope widening described above were restored in MEDIAIBOX-12298.