Identity
celine.sdk.auth. Twelve repositories import it, and it is where every CELINE service
decides whether a token is genuine. A permissive change here is permissive everywhere.
Verifying a token
REQ-0020 — a token is verified, never merely decoded
JwtUser.from_token checks the signature against the issuer's published keys and enforces
exp, nbf and iss. There is no unverified decode path in the public surface: a caller
holding a token and settings either gets a verified JwtUser or an exception.
Which signature algorithms are accepted is REQ-0043.
REQ-0021 — signing keys come from the configured JWKS URI, over a client cached per URI
OidcSettings.jwks_uri is the source. The PyJWKClient is memoised per URI so a service
verifying thousands of requests fetches the key set once, and the key set is itself cached
for an hour.
This memoisation is the single seam for testing. A test replaces the JWKS fetch and leaves every check above live; substituting a mocked validator instead proves only that the mock was called.
REQ-0022 — an expired token is rejected, with thirty seconds of leeway
The leeway exists for clock skew between a service and the identity provider. A token expired by less than it is still accepted; one expired by more is not.
REQ-0023 — a token signed by a key the issuer does not publish is rejected
Signature verification is the point of the exercise: a well-formed token carrying correct claims and a foreign signature must not parse.
REQ-0024 — a token from another issuer is rejected
iss must equal OidcSettings.base_url. A token that is genuine elsewhere is not genuine
here.
REQ-0025 — an Authorization header value is accepted as well as a bare token
Bearer <token> is stripped, in any case. Services pass the header through unchanged often
enough that requiring them to split it produced the bug this absorbs.
REQ-0026 — the audience is validated only when one is configured
With OidcSettings.audience set, aud must contain it. With it unset, audience
validation is skipped entirely — a permissive default, and the reason a service that never
declares an audience is not checking one.
An empty or blank audience is unset. CELINE_OIDC_AUDIENCE= (as a compose file's
${CELINE_OIDC_AUDIENCE:-} passes it) skips validation exactly as an absent variable does;
it never means "aud must contain the empty string", which no token satisfies.
get_expected_audiences() composes audience with client_id (when
include_client_id_as_audience is on) and returns None when there is nothing to check.
It is a helper for callers that build their own validation; from_token does not use
it, so allowed_audiences and include_client_id_as_audience have no effect on
from_token. Anything relying on them must apply them itself.
REQ-0027 — a token without a subject is refused
sub identifies the principal; a verified token that names nobody is useless and is a
ValueError rather than a JwtUser with an empty id.
REQ-0028 — a missing or empty token is refused before any network call
None, "" or whitespace raises ValueError without touching the JWKS endpoint. An
unauthenticated request must not be able to cause an outbound fetch.
REQ-0043 — the signature algorithm comes from an asymmetric allow-list and the published key, and exp is required
ALLOWED_JWT_ALGORITHMS is RS256 and ES256 — what a Keycloak key set publishes for its
RSA and EC P-256 signing keys. A caller's algorithms may narrow that set, never widen it:
names outside it are ignored, and a set that leaves nothing raises ValueError. HS* and
none are never accepted, since a key set has no shared secret to verify them against.
The algorithm is the signing key's, as the key set assigns it (alg, or derived from
kty/crv), never the one the token's header names. A key whose algorithm is outside the
allowed set verifies nothing.
A token without exp is refused: a token that never expires is not one this platform issues.
Reading a verified token
REQ-0029 — organization memberships are parsed from the organization claim
Each key becomes an Organization with that key as its alias — which is used directly as
the Digital Twin network id. type is a single-element list in Keycloak and is flattened to
a string; attributes are normalised so every value is a list, whatever the claim shape.
organization_aliases, get_organization(alias) and is_member_of(alias) read them. An
unparseable or absent claim yields no memberships rather than an error.
REQ-0031 — a service account is distinguished from a user
is_service_account treats a preferred_username beginning service-account- as
authoritative (Keycloak's client-credentials convention) and gty=client-credentials as an
equivalent signal from other providers. An email, any group, or any other
preferred_username marks a human. Failing all of those, a token carrying a client id and
no email is a service, and so is a token whose Keycloak grant marker (the jti prefix Keycloak
26 writes, e.g. trrtcc:) names client credentials. A realm that does not assign Keycloak's
built-in service_account scope issues such tokens with neither preferred_username nor
client_id.
"Any group" means a group at either level, realm or organization. It is a signal that a person is behind the token, not a grant: a realm group present in a token classifies it as a user's and authorises nothing (REQ-0042). Classifying by group errs towards "user", which is the safe direction — a user is never authorised by scope.
The platform authorises services by scope and users by their platform role and organization groups (REQ-0042); this is the function that decides which of the two a caller is.
REQ-0032 — claims are reachable by name, role and scope
get_claim, has_role (a realm role, read from realm_access.roles as REQ-0042 states;
until 2.0.0 it read a top-level roles claim, which no Keycloak mapper on the platform
emits), has_scope (a space-separated string or a list), display_name (name, then username, then email, then user-<sub>), get_username
(username, else user-<sub>) and to_dict. Each tolerates the claim being absent or of the
wrong shape, because the claim set is the identity provider's to change.
REQ-0033 — a parsed token can report its own expiry
is_expired(leeway) and is_valid(leeway) read exp. A token with no exp is treated as
not expired — the verification step has already refused an expired one, so these serve
callers holding a token they intend to reuse.
REQ-0040 — an organization membership carries its Keycloak id and the groups held inside it
Organization exposes id (the KC organization UUID) and groups (the groups the caller
holds inside that organization, leading slashes stripped) alongside alias, type and
attributes. Both are absent from the claim on a realm whose mapper does not emit them, and
are then an empty value rather than an error.
type is read from the flattened type key first and from attributes.type second.
Keycloak's own organization mapper emits the flattened shape; the nested one is what a
differently configured mapper produces, and both must parse.
REQ-0042 — a platform grant comes only from the realm role platform-admin; an organization's groups count only inside that organization
There are exactly two levels of authority in a token, and nothing in this package merges them:
- Platform.
realm_roles(claims)readsrealm_access.rolesand nothing else: not a top-levelrolesclaim, notresource_access.<client>.roles, notgroups. The one platform-wide grant is the realm rolePLATFORM_ADMIN_ROLE("platform-admin"), andis_platform_admin(claims)is true exactly when that role is among them. A missing or malformedrealm_accessyields no roles and is not an error. - Organization.
organization_groups(claims, alias)readsorganization.<alias>.groupsfor that one alias, leading slashes stripped. A group held in one organization is never visible through another alias, and is never a platform grant, whatever it is called — an organization'sadminsis not a platform administrator.
A realm group grants nothing. The top-level groups claim (/admins, admins, …) is not
read by any authorization helper; a token that still carries one is treated exactly as if it
did not.
Grants.from_claims(claims) (also JwtUser.grants) is the structured reader for callers that
need both levels: .platform holds the realm roles, .in_org(alias) one organization's
groups, .is_platform_admin the platform check. It offers no flat list of both levels.
JwtUser.realm_roles, JwtUser.is_platform_admin and JwtUser.has_role(role) read the same
realm roles.
In a policy decision the two levels stay in two fields of celine.sdk.policies.Subject:
roles (the realm roles, filled from realm_roles) reaches Rego as input.subject.roles,
and groups (one organization's groups) as input.subject.groups. A policy checks the
platform grant as "platform-admin" in input.subject.roles. The engine never copies a role
into groups or a group into roles, and a subject built without roles has an empty list,
never a missing key (REQ-0056).
organization_aliases(claims) returns every alias the caller is a member of, sorted. Every
reader tolerates a claim of the wrong shape and answers empty.
Removed in the same change (breaking): extract_groups, which merged the realm groups
claim with every organization's groups (formerly REQ-0030, deprecated 2026-10-03 after it made
a community's own admins dataset-api's platform administrator, NIS2 R1), and
realm_groups, which read the realm groups claim as a platform grant (formerly REQ-0041).
Realm groups are no longer a platform mechanism at all; the platform level is the realm role.
Obtaining a token
REQ-0034 — an access token carries its expiry and answers whether it is still usable
AccessToken(access_token, expires_at, refresh_token=None, token_type="Bearer").
There is one such class, whatever path it is imported by. celine.sdk.auth.AccessToken,
celine.sdk.auth.models.AccessToken and celine.sdk.auth.jwt.AccessToken are the same
object; celine.sdk.auth.jwt carried a second, byte-identical definition until 2026-08-15,
which made an isinstance check across the two paths fail for reasons that read as
impossible.
expires_at is an epoch float. is_valid(leeway=30) is false once the token is within the
leeway of expiring — early, deliberately, so a token is replaced before it is refused.
to_header() renders "<token_type> <access_token>".
REQ-0035 — a forwarded token is used as-is
StaticTokenProvider wraps a token a caller already holds — the case for every service that
forwards its user's JWT downstream. A Bearer prefix is stripped; nothing is refreshed and
nothing is verified, because whoever accepted the request already verified it.
It is exported by celine.sdk.auth, beside the provider it is an alternative to. (It was
reachable only as celine.sdk.auth.static.StaticTokenProvider until 2026-08-15; that path
still works.)
REQ-0036 — a provider notifies its listeners when a new token is issued
add_token_renewed_listener registers an async callback, fired after each issuance. This is
what lets a long-lived MQTT connection rebuild itself on fresh credentials (REQ-0080).
A failing listener does not fail the issuance, and does not stop the remaining listeners: the exception is logged and the loop continues. A token was still obtained, and dropping it because a subscriber misbehaved would be worse.
REQ-0037 — the client-credentials provider reuses a token until it is close to expiring
OidcClientCredentialsProvider.get_token() returns the cached token while is_valid()
holds, so the common path makes no network call.
REQ-0038 — a refresh is attempted before re-authenticating, and its failure is not fatal
Holding a refresh token, the provider tries the refresh grant; if that fails for any reason it falls back to a full client-credentials authentication. Either way the renewal listeners are fired with the new token.
REQ-0039 — endpoints are discovered from the issuer, once
OidcDiscoveryClient reads <issuer>/.well-known/openid-configuration and caches the
issuer, token_endpoint and jwks_uri it finds for the life of the client. A trailing
slash on the configured issuer is not a second URL.
expires_in from the token response fixes expires_at; a response omitting it is treated
as five minutes.