Metadata-Version: 2.5
Name: obelisk-auth
Version: 1.1.0
Summary: Drop-in OIDC client for the Obelisk Gate identity platform — login, tokens, refresh, userinfo, local ES256 token verification, receipt checks against the signed tree head, and an MCP resource-server kit.
Project-URL: Homepage, https://obeliskgate.com
Author: VaultSpark Studios
License: Proprietary
License-File: LICENSE
License-File: NOTICE
Keywords: auth,es256,jwt,mcp,obelisk,oidc,openid-connect,pkce
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: cryptography>=42.0.0
Requires-Dist: pyjwt>=2.8.0
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == 'test'
Description-Content-Type: text/markdown

# obelisk-auth (Python)

A drop-in OIDC client for the **Obelisk Gate** identity platform
(`https://obeliskgate.com`). It is the Python counterpart of the JavaScript
`@obeliskgate/obelisk-auth` package and speaks the exact same contract: login +
tokens + refresh + userinfo + **local** ES256 token verification.

Obelisk is an OpenID Connect provider. It signs ID tokens with ES256 by default;
RS256 is signed only for a client registered for it, on a deployment where that is
enabled. It is not OpenID-certified. This SDK verifies ES256 only: leave your
client's `id_token_signed_response_alg` unset (or `ES256`).

- `GET /.well-known/openid-configuration` — discovery (RFC 8414)
- `GET /.well-known/jwks.json` — JWKS (ES256 / P-256 public keys)
- `GET /auth/authorize` — authorization-code flow, **PKCE S256 only**
- `POST /auth/token` — exchange `code`+`code_verifier` → `id_token` / `access_token` / `refresh_token`
- `GET /auth/userinfo` — Bearer-authenticated claims

Tokens are ES256 JWTs, so they can be verified **locally** against the cached
JWKS with no network round-trip per request — the property that makes Obelisk
useful as an agent-era auth plane.

## The agent-era angle

Every token is locally verifiable, and the **id_token** carries an `obelisk`
claim block that proves *how* the principal authenticated:

```json
"obelisk": {
  "v": "obelisk-claims-v1",
  "assurance": "passkey",
  "anchor": "0a1b2c3d...",
  "rating": { "...": "live issuer security posture" }
}
```

`assurance` distinguishes operator-proof factors (passkey) from weaker ones, and
`anchor` ties the issuance into Obelisk's tamper-evident receipt chain. Pull it
out of verified claims with `obelisk_block(claims)`.

## Install

```bash
pip install obelisk-auth
```

Dependencies are stdlib `urllib` for HTTP plus `PyJWT` and `cryptography` for
ES256 verification (no hand-rolled crypto).

## Login (copy-paste)

```python
from obelisk_auth import ObeliskAuth, obelisk_block

auth = ObeliskAuth(
    issuer="https://obeliskgate.com",
    client_id="rp-statvault",
    redirect_uri="https://statvault.org/auth/callback",
)

# 1) Start the login. Persist code_verifier + state + nonce in the user session,
#    then redirect the browser to the authorization URL.
login = auth.begin_login(scope="openid profile offline_access")
session["pkce"] = {
    "code_verifier": login.code_verifier,
    "state": login.state,
    "nonce": login.nonce,
}
redirect(login.authorization_url)

# 2) On the callback (e.g. /auth/callback?code=...&state=...):
#    First confirm the returned `state` matches what you stashed (CSRF defense).
assert request.args["state"] == session["pkce"]["state"]

result = auth.complete_login(
    code=request.args["code"],
    code_verifier=session["pkce"]["code_verifier"],
    nonce=session["pkce"]["nonce"],
)

print(result.claims["sub"])                 # the user's stable subject id
print(obelisk_block(result.claims))         # assurance + anchor + rating

access_token = result.tokens["access_token"]
refresh_token = result.tokens.get("refresh_token")  # present with offline_access
```

## Verify a token locally (no network per request)

```python
from obelisk_auth import ObeliskAuth

auth = ObeliskAuth(
    issuer="https://obeliskgate.com",
    client_id="rp-statvault",
    redirect_uri="https://statvault.org/auth/callback",
    # A resource server (an MCP server, an API) names the resource it protects;
    # tokens must then be minted for it. Without it the audience is client_id.
    resource="https://api.statvault.org",
)

# On a protected route, given a Bearer access token:
v = auth.verify_access_token(bearer_token)
if not v.ok:
    raise Unauthorized(v.reason)  # e.g. "audience-mismatch", "not-an-access-token", "expired"

print(v.claims["sub"], v.claims["scope"])
```

`verify_access_token` accepts a token only when all of these hold: the ES256
signature verifies against the issuer's JWKS, `iss` is the issuer, `exp` is
present and in the future, `aud` is your `resource` (or your `client_id` when no
resource is set), the JWS `typ` is absent or an OIDC type, and the token is
access-token shaped. A login's **id_token**, an access token minted for another
client or resource, and other token types signed by the same key are all
refused. These are the same checks the JavaScript SDK applies.

**Assurance comes from the id_token.** The `obelisk` block (how the user proved
themselves) is an id_token claim and is never in an access token. Read it at
sign-in from `complete_login(...).claims` with `obelisk_block`, and keep it in
your session if a later action needs it.

The first `verify_*` call fetches the JWKS once and caches it by `kid`; later
calls verify in-process. If a key rotates (unknown `kid`), the SDK refetches the
JWKS once and retries. Forced refetches are capped at one per minute, so a
stream of tokens with made-up `kid`s cannot drive a JWKS fetch per request.

## Refresh + userinfo

```python
rotated = auth.refresh(refresh_token)       # rotating refresh tokens
new_access = rotated["access_token"]

info = auth.get_userinfo(new_access)        # Bearer-authenticated userinfo
print(info["sub"])
```

> Reusing a superseded refresh token revokes the whole token family on the
> server (OAuth 2.0 BCP reuse detection); be prepared to force re-authentication.

## Verify a receipt against the signed tree head (1.1.0)

```python
from obelisk_auth import verify_receipt_inclusion

r = verify_receipt_inclusion(
    receipt["currentHash"],
    issuer="https://obeliskgate.com",
    receipt=receipt,              # optional: the full ledger row you hold
    # anchored_jkt="...",         # optional: the signing key's RFC 7638 thumbprint, obtained out of band
    # jwks=pinned_jwks,           # optional: a key set you obtained out of band
    # max_age_sec=172800,         # optional: refuse a head anchored longer ago than this
)
r.verdict     # "consistent" | "not-found" | "failed"
r.committed   # False (see below)
r.trusts      # what the verdict still takes on the Gate's word
r.checks["head"]["reason"]   # e.g. "head-verified"
```

It makes three GETs (`/api/proof/<hash>`, `/.well-known/obelisk-transparency.json`,
`/.well-known/jwks.json`) and refuses redirects. Network and HTTP failures come
back as results (`reason="unreachable:proof"` and so on), never as exceptions.
Each check (`head`, `proof`, `path`, `leaf`, `body`, `anchor`) is reported on its
own. A check that did not run is `{"checked": False, "ok": None}`, never a pass.

- `head`: the Signed Tree Head's ES256 signature against the JWKS, `typ` pinned
  to `obelisk-sth+jwt`, signed fields equal to the document's, optional
  thumbprint pin and freshness bound.
- `proof`: the Gate's index returned this hash at a position inside its chain.
- `path`: the Merkle path folds the leaf to the stated root.
- `leaf`: the leaf commits to this hash (needs `receipt`).
- `body`: the receipt you hold hashes to this hash, with the `prevHash` the
  ledger has at that position (needs `receipt`).
- `anchor`: the receipt's position against the anchored ledger head;
  `signed: True` when that anchor recomputes to the signed `headAnchorHash`.

**What it cannot prove yet.** The signed head commits to the anchor store. It
does not commit to the Merkle root the path folds to. A path that folds shows
the proof is consistent with the Gate's own index, not that the receipt is in a
tree the Gate signed. So `committed` is always `False` in this version and
`trusts` always contains `merkle-root`. The codes in `trusts`:

- `merkle-root`: the root the path reaches is not committed by the signed head.
- `receipt-position`: the index, kind and timestamp come from the unsigned proof response.
- `leaf-binding`: the leaf was not bound to this hash (no `receipt`, or no leaf).
- `receipt-body`: the body was not checked against this hash (no `receipt`).
- `anchor-link`: the anchored ledger head could not be linked to the signed head.
- `signing-key`: the key came from the Gate's own JWKS (pass `anchored_jkt` or `jwks` to remove this).

`verify_receipt_proof(hash=..., proof=..., transparency=..., jwks=...)` runs the
same checks offline, and `auth.verify_receipt_inclusion(receipt_hash)` is the
same call bound to a client's issuer.

## Protect a remote MCP server (`obelisk_auth.mcp`, 1.1.0)

Your MCP server is the resource server; Obelisk is the authorization server.
`ObeliskMcpVerifier` advertises RFC 9728 Protected Resource Metadata, answers
`401`/`403` with a `WWW-Authenticate` challenge that names it, and verifies each
bearer token locally through `verify_access_token` with `aud` bound to the
resource. One canonical `resource` string feeds all three.

```python
from mcp.server.fastmcp import FastMCP          # the MCP Python SDK (pip install mcp)
from obelisk_auth.mcp import ObeliskMcpVerifier

verifier = ObeliskMcpVerifier(
    resource="https://your-mcp.example.com/mcp",  # issuer defaults to https://obeliskgate.com
    required_scopes=["mcp:tools"],                # optional
)
server = FastMCP("your-server", token_verifier=verifier, auth=verifier.auth_settings())
app = verifier.serve_metadata(server.streamable_http_app())   # any ASGI server
```

`verify_token` implements the MCP SDK's `TokenVerifier` protocol and returns an
`AccessToken` or `None`. It returns `None` for a missing required scope as well,
so the scope requirement holds even if the server settings omit it.
`serve_metadata` serves the kit's own metadata document, because FastMCP's route
writes the issuer as `https://obeliskgate.com/`, with a trailing slash.
`auth_settings()` refuses a bare-origin resource such as
`https://your-mcp.example.com`, because the MCP SDK would advertise it with a
trailing slash the token audience would not have.

Without a framework, `verifier.authenticate(authorization_header)` returns an
`McpVerdict` with `ok`, `status` (200/401/403/503), `reason`, `www_authenticate`,
`headers()` and `body()`. A token is refused if it was minted for another resource,
if it is an id_token, if its JWS `typ` is not an access-token type, or if it
carries `cnf`: this module does not verify DPoP proofs, so it refuses DPoP-bound
tokens. If discovery or the JWKS cannot be fetched, the verdict is
`503 verifier-unavailable` and nothing is admitted. Verdicts, reasons and
challenge strings are identical to the JavaScript `@obeliskgate/obelisk-auth/mcp`.

Tested against `mcp` 1.26 (`mcp.server.fastmcp`) with a real Streamable HTTP app.
The separate `fastmcp` 2.x package is not tested.

## API

| Method | Description |
| --- | --- |
| `discover()` | Fetch + cache the discovery document. |
| `get_jwks(force=False)` | Fetch + cache the JWKS. |
| `begin_login(scope=...)` → `LoginStart` | PKCE S256 authorize URL + `code_verifier`/`state`/`nonce`. |
| `complete_login(code, code_verifier, nonce=...)` → `LoginResult` | Exchange + verify id_token. |
| `verify_id_token(id_token, nonce=...)` | Local ES256 verify of signature, `iss`, `aud`=`client_id`, `typ` and nonce (raises on failure). |
| `verify_access_token(token)` → `VerifyResult` | Local ES256 verify of signature, `iss`, `exp`, `aud`=`resource` or `client_id`, `typ` and access-token shape. Returns `ok=False` with a `reason` instead of raising. |
| `refresh(refresh_token)` | Rotate a refresh token. |
| `get_userinfo(access_token)` | Bearer userinfo. |
| `obelisk_block(claims)` | Extract the `obelisk` assurance block from verified id_token claims. |
| `is_oidc_access_token(claims)` | The access-token shape check `verify_access_token` applies. |
| `verify_receipt_inclusion(hash, issuer=...)` → `ReceiptInclusionResult` | Check a receipt against the signed tree head (also an `ObeliskAuth` method). |
| `verify_receipt_proof(hash=..., proof=..., transparency=..., jwks=...)` | The same checks, offline. |
| `obelisk_auth.mcp.ObeliskMcpVerifier(resource=...)` | MCP resource-server kit: metadata, challenge, `authenticate`, `verify_token`, `auth_settings`, `serve_metadata`. |

See [CHANGELOG.md](CHANGELOG.md): 1.1.0 adds receipt checks and the MCP kit; 1.0.1 fixes
`verify_access_token` accepting id_tokens and other clients' tokens.

## Testing

```bash
pip install -e ".[test]"
pytest
```

The bundled tests are pure unit tests (no live server): PKCE-S256 challenge
correctness, authorization-URL construction, full login flow against a mocked
token endpoint, round-trip ES256 sign/verify with a generated P-256 key, and the
replay attacks above. `tests/parity_harness.py` is not a pytest module: the
repository's `scripts/check-sdk-verify-parity.mjs` drives it to run one minted
token set through the JavaScript, Python and Go verifiers and compare verdicts.
