Skip to content

Authenticating against your identity provider

from apiverity.server.api import create_app
from apiverity.server.auth import LocalTokenProvider
from apiverity.server.oidc import OidcConfig, OidcProvider

oidc = OidcProvider(OidcConfig(
    issuer="https://login.example.com",
    audience="apiverity",
    role_claim="groups",
    role_map={"platform-admins": "admin", "developers": "member"},
    org_id=1,
))

app = create_app(store, providers=[oidc, LocalTokenProvider(store)])

IdentityProvider has been a Protocol with one implementation — a lookup in the server's own token table — and a docstring saying "OIDC/SAML adapters implement this". None did, so a team with an identity provider had an abstraction and no way to use it.

Both providers can run at once. That is what migrating from local tokens to an issuer looks like: add the OIDC provider, move people over, remove the local one.

Needs the extra: pip install api-verity-lab[oidc].

The four checks

Skip any one and this is a base64 decoder.

The signature, against a key from the issuer's JWKS endpoint selected by the token's kid. A token with no kid, or an unknown one, is rejected rather than tried against every key — trying them all is how a rotated-out key stays valid.

iss. A correctly signed token from another issuer is a correctly signed token from somebody else.

aud. An access token minted for another service is not an access token for this one. Accepting it is the confused-deputy problem in its original form, which is why audience has no default and construction fails without it.

exp, with no leeway by default. A token that expired is a token that expired; a grace period is a decision somebody should make deliberately (leeway=60). A token with no exp is refused: that is a password with worse ergonomics.

alg: none is rejected before anything else, along with any algorithm outside the allow-list. The JWT specification permits an unsigned token, and a library that honours it will verify one. The default algorithms are asymmetric only — with HMAC the verification key is the signing key, so anybody who can read the server's configuration can mint tokens.

Roles are mapped, never defaulted

An identity provider does not know what admin means here, so role_map is required and a subject whose claim matches nothing gets no identity at all.

Not viewer. A default role means anybody the issuer will mint a token for can read every contract in the org — which is a much larger set of people than the ones you meant to invite.

Where roles live differs by issuer (roles, groups, https://example.com/roles) and none of them is wrong, so role_claim is configurable. A subject in several mapped groups gets the highest of them.

Key rotation

The key set is cached for five minutes — a fetch per request would make the issuer a dependency of every call, and caching forever would mean a rotated key keeps working until a restart.

A kid that is not in the cache triggers one forced refetch before the token is rejected. A key rotated since the cache was filled is the common case, and failing on it would make every rotation an outage.

A discovery document pointing jwks_uri at a host outside the issuer is refused. Following it silently is how a token gets verified against a key nobody here chose. Set jwks_uri explicitly if that is deliberate.

Signature checking is not implemented here

It calls PyJWT. A hand-rolled RSA verification in an authentication path is the last place to be clever, and a wrong one fails open.

The library is asked for the signature and nothing else — iss, aud and exp are checked in this module against its own configuration. Two places checking aud with two notions of what it should be is how one of them ends up not checking.

SAML

Not implemented. SAML is XML signature verification, which is a larger and much sharper problem than this module, and a half-done one would be worse than none.

IdentityProvider is still the seam, and this module is the worked example of implementing it.