Skip to content

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.

RS256, HS256 and ES256 are accepted unless the caller names a narrower set.

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.

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.


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-0030 — groups are read from both the realm level and the organization level

extract_groups merges the top-level groups claim with every organization.<alias>.groups into one deduplicated list, leading slashes stripped, first-seen order preserved. Non-list claims and non-string entries are skipped rather than raising.

Merging is correct only for a single-tenant service. For a service acting for several communities, flattening lets a badge held in one community satisfy a check about another; such a service must read the two levels apart and not call this function.

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.

The platform authorises services by scope and users by group membership; 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 list or a bare string), 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.


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.