Table of Contents

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 rootUrl matches.
  • 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_ROOTURL has no effect while the XML defines rootUrl: with MIBCLIENT2_CONFIG_MODE unset, 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,XML to 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:

  • accessTokenMinutes is not honoured. expires_in is 3600 (60 minutes) whatever the setting says. A client that computes expiry from configuration instead of from expires_in will hit a 401 believing it still has hours of margin.
  • client_credentials does not get this widening. A no-scope client_credentials request 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.