Metadata-Version: 2.4
Name: tai42-identity-oidc
Version: 0.1.1
Summary: Validate-only OIDC identity provider for the TAI ecosystem: an installable plugin that registers itself as the "identity-oidc" identity provider and resolves an issuer-minted JWT to an authenticated identity (no key minting, no stored state).
Author-email: tai42 <oss@tai42.ai>
License-Expression: Apache-2.0
Project-URL: Homepage, https://tai42.ai
Project-URL: Repository, https://github.com/tai42ai/tai-identity-oidc
Project-URL: Issues, https://github.com/tai42ai/tai-identity-oidc/issues
Project-URL: Changelog, https://github.com/tai42ai/tai-identity-oidc/blob/main/CHANGELOG.md
Keywords: tai,identity,authentication,oidc,jwt,access-control
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: tai42-contract<0.2,>=0.1
Requires-Dist: tai42-kit[jwt]<0.2,>=0.1
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: pyright>=1.1.389; extra == "dev"
Dynamic: license-file

# tai42-identity-oidc

[![CI](https://github.com/tai42ai/tai-identity-oidc/actions/workflows/ci.yml/badge.svg)](https://github.com/tai42ai/tai-identity-oidc/actions/workflows/ci.yml)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)

The validate-only **OIDC identity provider** for the TAI ecosystem — an
installable plugin that registers itself as the `"identity-oidc"` identity
provider and resolves an **issuer-minted JWT** presented on an API call to an
authenticated identity.

Importing the package registers the provider in `tai42-contract`'s module-level
identity-provider registry (`register_identity_provider("identity-oidc", ...)`),
with no `tai42_app` handle involved — so it registers in any process that imports
it, including ones that never `start()`. A deployment selects it by including
`identity-oidc` in the access-control `auth_providers` list.

Its only tai-* dependencies are `tai42-contract` (the identity ABCs and the
registry it registers through) and `tai42-kit[jwt]` (the OIDC discovery / JWKS /
JWT-verify helper, `tai42_kit.net.jwt`). It **never** imports the skeleton — the
plugin is contract-facing, and the import is banned by ruff.

## The TAI ecosystem

TAI is an open-source runtime for MCP tools, agents, and workflows. An identity
provider is how the runtime answers "who is this caller?": it resolves an
inbound credential to an authenticated identity, and access control decides what
that identity may do. This package is one such provider (issuer-minted OIDC
JWTs, validate-only); any package can back the same contract, so this repo is
this provider's own full doc home, and the documentation site covers the
platform-level story:

- Access-control concept: https://tai42.ai/concepts/access-control
- Build an identity provider (author guide): https://tai42.ai/guides/authors/identity-provider
- Ecosystem catalog: https://tai42.ai/reference/catalog

## Validate-only — no key minting, no stored state

This provider implements the base
`tai42_contract.access_control.identity.IdentityProvider` ABC — deliberately **not**
`ApiKeyIdentityProvider`. It mints no keys and holds no state: keys are managed at
the external issuer, so the skeleton's key-minting surface refuses this provider
loudly (Studio renders "keys are managed at the external issuer" rather than a raw
error). It is the ecosystem's proof of the mint-capability gate.

## How a token resolves

On each request the presented token runs a cheap structural gate first
(`looks_like_jwt` — three dot-separated base64url segments), so `sk-…` keys and
`tai-sess-…` session tokens fall through the provider chain with a clean `None`
and no network fetch. A JWT-shaped token is verified against the issuer's JWKS
(discovered once, cached with TTL and bounded unknown-`kid` refetch):

- `alg` must be in the configured allowlist **before** any key lookup, so
  `alg=none` and symmetric downgrades are rejected by construction.
- `iss` must match exactly, `aud` must contain the configured audience, and
  `exp` is required (with leeway).

A JWT-shaped token that fails verification **raises** — it is an attack or a
misconfiguration, never someone else's credential — and the request denies
(fail-closed). The verified claims become `AuthIdentity.claims`; the user id is
the **namespaced** subject `idp:{issuer}:{claim}` (`claim` defaults to `sub`).

The `idp:{issuer}:` prefix fences issuer subjects into their own slice of the
single flat policy `user_id` namespace they share with `usr-*` accounts, `sk-*`
key ids, and `oidc:*` login subjects — so a trusted issuer emitting a `sub` equal
to a privileged principal's id cannot inherit that principal's policy.

The reserved `owner_user_id` claim is stripped from the returned claims as
defense-in-depth: it is authoritative only from the key-mint path, and the
skeleton verifier's central strip already removes it for every non-mintable
provider.

## Deny-by-default

An issuer-authenticated subject with **no** operator-provisioned policy resolves
to the empty `AccessPolicy`, which holds no scopes, so every protected route
denies. There is no auto-provisioning and no default-policy path. Operators grant
access by creating a policy for each expected subject id under its namespaced
form — `idp:{issuer}:{sub}` — through the existing policy routes.

## Chaining note (v1)

Chaining two JWT providers against different issuers is out of scope for v1: a
single `identity-oidc` member verifies against one configured issuer.

## Configuration

All config is the plugin's own `TAI_IDENTITY_OIDC_*` env namespace (the plugin
never reads skeleton config):

| Env var | Required | Default | Meaning |
|---|---|---|---|
| `TAI_IDENTITY_OIDC_ISSUER` | yes | — | The OIDC issuer URL (discovery is fetched from `{issuer}/.well-known/openid-configuration`). |
| `TAI_IDENTITY_OIDC_AUDIENCE` | yes | — | The audience the token's `aud` must contain. |
| `TAI_IDENTITY_OIDC_ALLOWED_ALGS` | no | `["RS256"]` | JSON array — the signing-algorithm allowlist. Never "any". |
| `TAI_IDENTITY_OIDC_CLAIM` | no | `sub` | The claim mapped into the namespaced user id. |
| `TAI_IDENTITY_OIDC_JWKS_TTL_SECONDS` | no | `3600` | JWKS cache TTL. |

A missing required setting raises a loud settings error at provider
construction (boot).

## Surface

The provider implements `tai42_contract.access_control.identity.IdentityProvider`:

| Method | Does |
|---|---|
| `validate_token(token)` | Gates non-JWT tokens to `None`; verifies a JWT against the issuer's JWKS and returns the `AuthIdentity` with the namespaced user id. A verification failure **raises** (fail-closed). |
| `healthcheck()` | Fetches the issuer's discovery + JWKS at boot; raises loudly if the issuer is unreachable or the documents are broken. |

`readiness_targets()` inherits the base empty tuple — the issuer is not a
kit-pooled client, so the boot `healthcheck()` is the reachability check.

## Requirements

Requires **Python 3.13+** and a reachable OIDC issuer publishing standard
discovery + JWKS. An unreachable or broken issuer is caught loudly by
`healthcheck()` at startup rather than failing per-request.

## Install

Nothing is on PyPI yet, so install from source — clone this repo and add it as an
editable dependency of the environment that runs the server:

```bash
git clone https://github.com/tai42ai/tai-identity-oidc
cd tai-skeleton   # or your own app checkout
uv add --editable ../tai-identity-oidc    # once published: uv add tai42-identity-oidc
```

## Development

```bash
uv sync --extra dev
uv run ruff check .
uv run ruff format --check .
uv run pyright
uv run pytest
```

`[tool.uv.sources]` resolves `tai42-contract` and `tai42-kit` from sibling checkouts
for local development; the published wheel floors them from the index.

## License

Apache-2.0. See `LICENSE` and `NOTICE`.
