Python SDK for the Knuckles identity service
Project description
knuckles-client
Python SDK for the Knuckles identity service. Knuckles handles user accounts, sign-in ceremonies (magic-link, Google, Apple, WebAuthn passkey), and JWT issuance for a fleet of consuming applications. This package is what those applications import.
Why an SDK? The three things consuming apps get wrong by default are: forgetting
audienceverification on JWTs, forgetting to swap in the rotated refresh token after a refresh, and treatingREFRESH_TOKEN_REUSEDas a generic 401 instead of a "revoke everything" signal. The SDK encodes all three correctly so you don't have to.
Table of contents
- Install
- Quick start
- Concepts in 30 seconds
- The full API
- Token verification, in depth
- Refresh-token rotation, in depth
- Exception handling
- Configuration reference
- Recipes
- Versioning policy
- Compatibility matrix
- Development
Install
pip install knuckles-client
Supports Python 3.11+. Pure Python — no compiled extensions, no platform-specific wheels.
Quick start
from knuckles_client import KnucklesClient
knuckles = KnucklesClient(
base_url="https://auth.example.com", # your Knuckles deployment
client_id="my-app", # the client_id you registered
client_secret="...", # NEVER ship this in a browser bundle
)
# 1. Verify an access token locally — JWKS-cached, no network after warmup.
claims = knuckles.verify_access_token(access_token)
user_id = claims["sub"]
# 2. Drive a sign-in ceremony.
auth = knuckles.google.start(redirect_url="https://my-app/auth/google/callback")
# ... your frontend redirects the browser to auth.authorize_url ...
# ... Google redirects back to your callback with ?code=...&state=... ...
pair = knuckles.google.complete(code=code, state=auth.state)
# 3. Hand the user their session — store however you store sessions.
print(pair.access_token) # short-lived RS256 JWT
print(pair.refresh_token) # opaque, rotates on every use
# 4. When the access token nears expiry, rotate.
new_pair = knuckles.refresh(pair.refresh_token)
# IMPORTANT: store new_pair.refresh_token. The old one is now consumed.
Concepts in 30 seconds
- One client per process. The
KnucklesClientholds an HTTP session (connection pool) and a JWKS cache. Construct it once at startup; reuse it everywhere. - App-client credentials live on your backend. The
client_idis public-ish, theclient_secretis treated like any other server secret. Browsers never see the secret. - The user's tokens are what you store. After a successful
ceremony you get a
TokenPair. Where you put it (HTTP-only cookie, database row, native keychain) is your application's choice. - Access tokens are validated locally.
verify_access_tokencaches Knuckles' public keys (JWKS) and verifies signatures in-process. No per-request network hop to Knuckles. - Refresh tokens rotate on every use. Always store the new refresh token from a refresh response. Re-presenting a consumed refresh token is treated as a security incident — see below.
The full API
| Method | Returns | Notes |
|---|---|---|
client.verify_access_token(token) |
dict[str, Any] |
Local. Raises KnucklesTokenError on any failure. |
client.refresh(refresh_token) |
TokenPair |
Always store the new refresh token from the response. |
client.logout(refresh_token) |
None |
Idempotent; unknown tokens succeed silently. |
client.logout_all(access_token=...) |
int |
Revokes every refresh token for the user. Returns count. |
client.me(access_token=...) |
UserProfile |
Current user's profile from /v1/me. |
client.fetch_jwks() |
dict[str, Any] |
Raw JWKS body, mostly for debugging. |
client.fetch_openid_configuration() |
dict[str, Any] |
Partial OIDC discovery doc. |
client.magic_link.start(email=, redirect_url=) |
None |
Sends the email. May raise KnucklesRateLimitError. |
client.magic_link.verify(token) |
TokenPair |
Redeems the token from the email. |
client.google.start(redirect_url=) |
CeremonyStart |
Returns authorize_url + state. |
client.google.complete(code=, state=) |
TokenPair |
Finishes Google ceremony. |
client.apple.start(redirect_url=) |
CeremonyStart |
|
client.apple.complete(code=, state=, user=None) |
TokenPair |
Pass user only on first sign-in for that Apple ID. |
client.passkey.sign_in_begin() |
PasskeyChallenge |
Discoverable-credential flow; no bearer needed. |
client.passkey.sign_in_complete(credential=, state=) |
TokenPair |
|
client.passkey.register_begin(access_token=) |
PasskeyChallenge |
User must be signed in. |
client.passkey.register_complete(access_token=, credential=, state=, name=None) |
str (credential id) |
|
client.passkey.list(access_token=) |
list[PasskeyDescriptor] |
|
client.passkey.delete(access_token=, credential_id=) |
None |
Ownership-checked. |
Token verification, in depth
claims = knuckles.verify_access_token(token)
What the SDK does, in order:
- Fetches
{base_url}/.well-known/jwks.jsononce per process and caches the public keys in-memory (viajwt.PyJWKClient). - Parses the JWT header to find its
kid, looks up the matching public key from the cache. - Verifies the RS256 signature.
- Verifies the
issclaim equals yourbase_url. - Verifies the
audclaim equals yourclient_id. - Verifies
iat,exp,subare present andexpis in the future. - Returns the decoded claims dict.
Any failure raises KnucklesTokenError. The SDK does not
automatically refresh the token — that's a higher-level decision
your app makes (you may want to refresh, or you may want to require
re-authentication).
Refresh-token rotation, in depth
Knuckles uses one-shot rotating refresh tokens. The contract:
- Every successful refresh returns a new refresh token. Store it immediately, replacing the old one.
- The old refresh token is now consumed. Presenting it again is the
signal of a leak — Knuckles revokes every refresh token for the
user and returns
REFRESH_TOKEN_REUSED.
Correct usage:
from knuckles_client import KnucklesAuthError
def get_valid_access_token(session) -> str:
"""Return a usable access token, refreshing if needed."""
try:
knuckles.verify_access_token(session.access_token)
return session.access_token
except KnucklesTokenError:
pass # expired or invalid — try a refresh
try:
pair = knuckles.refresh(session.refresh_token)
except KnucklesAuthError as exc:
if exc.code == "REFRESH_TOKEN_REUSED":
# SECURITY EVENT — every session for this user has been
# revoked server-side. Sign them out everywhere.
session.delete()
raise SessionRevokedError() from exc
# Otherwise: refresh expired or invalid — sign out, redirect to login.
session.delete()
raise SignInRequiredError() from exc
session.access_token = pair.access_token
session.refresh_token = pair.refresh_token # <-- the rotation
session.save()
return pair.access_token
Exception handling
KnucklesError # base for everything the SDK raises
├── KnucklesNetworkError # transport failure / non-JSON response
├── KnucklesTokenError # local JWKS verification failed
└── KnucklesAPIError # Knuckles returned a typed error
├── KnucklesAuthError # 401 / 403
├── KnucklesValidationError # 422
└── KnucklesRateLimitError # 429
Every KnucklesAPIError carries .code, .message, .status_code.
Codes that warrant special handling:
| Code | What it means | What to do |
|---|---|---|
REFRESH_TOKEN_REUSED |
A consumed refresh token was presented again. Every refresh token for this user has been revoked. | Sign user out across every device. Force re-authentication. |
REFRESH_TOKEN_EXPIRED |
30-day lifetime elapsed. | Redirect to sign-in. |
REFRESH_TOKEN_INVALID |
Token unknown to Knuckles. | Same as expired. |
INVALID_CLIENT |
Wrong client_id/client_secret, or refresh token issued for a different app. |
Configuration bug — log loudly. |
RATE_LIMITED |
Per-email throttle on magic-link sends. | Surface a friendly retry message. |
MAGIC_LINK_* |
Token bad / expired / used. | Show "this link is no longer valid; request a new one." |
*_AUTH_FAILED |
Provider-side ceremony failure. | Show a generic "couldn't sign you in with that method" and offer alternatives. |
Other codes are bugs in your integration or in Knuckles itself — log
the full exception (code, message, status_code) and treat as 5xx.
Configuration reference
KnucklesClient(
base_url: str, # required
client_id: str, # required
client_secret: str, # required
timeout: float = 10, # per-request HTTP timeout, seconds
session: requests.Session | None = None, # bring your own pool / proxy / retries
)
base_url— exact origin Knuckles publishes itself as (also theissclaim it embeds in tokens). No trailing slash.client_id— used as the JWTaudKnuckles embeds. The SDK also enforces it on everyverify_access_tokencall.client_secret— sent asX-Client-Secreton every request that needs app-client auth. Keep it server-side.timeout— per-call timeout. Knuckles ceremonies talk to Google/Apple over the network, so leaving headroom (10s default) is reasonable.session— pass in a pre-builtrequests.Sessionto add retries (requests.adapters.HTTPAdapter), proxies, custom CAs, observability hooks, etc.
Recipes
Flask middleware
from flask import Flask, g, jsonify, request
from knuckles_client import KnucklesClient, KnucklesTokenError
app = Flask(__name__)
knuckles = KnucklesClient(base_url=..., client_id=..., client_secret=...)
@app.before_request
def authenticate():
if request.endpoint in {"login", "static"}:
return
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return jsonify({"error": "missing_bearer"}), 401
try:
claims = knuckles.verify_access_token(header.split(" ", 1)[1])
except KnucklesTokenError:
return jsonify({"error": "invalid_token"}), 401
g.user_id = claims["sub"]
FastAPI dependency
from fastapi import Depends, FastAPI, Header, HTTPException
from knuckles_client import KnucklesClient, KnucklesTokenError
app = FastAPI()
knuckles = KnucklesClient(base_url=..., client_id=..., client_secret=...)
def current_user(authorization: str = Header(...)) -> str:
if not authorization.startswith("Bearer "):
raise HTTPException(401, "missing_bearer")
try:
claims = knuckles.verify_access_token(authorization[7:])
except KnucklesTokenError as exc:
raise HTTPException(401, str(exc)) from exc
return claims["sub"]
@app.get("/me")
def me(user_id: str = Depends(current_user)):
return {"user_id": user_id}
Django middleware
from django.http import JsonResponse
from knuckles_client import KnucklesClient, KnucklesTokenError
knuckles = KnucklesClient(base_url=..., client_id=..., client_secret=...)
class KnucklesAuthMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
header = request.headers.get("Authorization", "")
if header.startswith("Bearer "):
try:
claims = knuckles.verify_access_token(header[7:])
request.user_id = claims["sub"]
except KnucklesTokenError:
return JsonResponse({"error": "invalid_token"}, status=401)
return self.get_response(request)
Versioning policy
- 0.x is pre-stable. Read
CHANGELOG.mdbefore upgrading minor versions; method signatures may change. - 1.0+ follows strict semver. Breaking changes require a major version bump.
- Pin in production.
knuckles-client==0.1.0in your requirements file is the right move at this stage.
Compatibility matrix
knuckles-client |
Knuckles server API | Python |
|---|---|---|
| 0.1.x | v1 (every route registered under /v1/... as of 2026-04) |
3.11, 3.12, 3.13 |
If your Knuckles deployment is older than the SDK targets, calls to
new endpoints (e.g. /v1/auth/passkey GET) will return 404. Upgrade
the server first.
Development
The SDK lives in the Knuckles monorepo
under packages/knuckles-client-py/.
# From a Knuckles checkout
cd packages/knuckles-client-py
pip install -e ".[dev]"
pytest
ruff check .
mypy src
The test suite mocks Knuckles' HTTP layer with the responses
library and the JWKS verifier with a hand-rolled fake. No live
Knuckles instance needed.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file knuckles_client-0.1.1.tar.gz.
File metadata
- Download URL: knuckles_client-0.1.1.tar.gz
- Upload date:
- Size: 26.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
22becf06d7dddd4add0da1eb975d1c5b733c8af44d17f311a43d833a7b67792d
|
|
| MD5 |
7c56def195ccabacdfb5cdd811f641c7
|
|
| BLAKE2b-256 |
a95e9b15ec676372ef153d417ac9fa0edb2533765dfb33df6d769358a59988c2
|
Provenance
The following attestation bundles were made for knuckles_client-0.1.1.tar.gz:
Publisher:
release-sdk-py.yml on gsooter/knuckles
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
knuckles_client-0.1.1.tar.gz -
Subject digest:
22becf06d7dddd4add0da1eb975d1c5b733c8af44d17f311a43d833a7b67792d - Sigstore transparency entry: 1419283040
- Sigstore integration time:
-
Permalink:
gsooter/knuckles@d75aebf2607c5e8ffe5e80ea3be532f08a93a8f4 -
Branch / Tag:
refs/tags/knuckles-client-py-v0.1.1 - Owner: https://github.com/gsooter
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-sdk-py.yml@d75aebf2607c5e8ffe5e80ea3be532f08a93a8f4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file knuckles_client-0.1.1-py3-none-any.whl.
File metadata
- Download URL: knuckles_client-0.1.1-py3-none-any.whl
- Upload date:
- Size: 19.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eab5f97c04b2ed4e418d31fec68ce345245a9c10dfcfa0caea933c7c1f2e3358
|
|
| MD5 |
f5c6a14679b4be95d8a3c6960879c6f3
|
|
| BLAKE2b-256 |
1987ee21c6f5a07b5533b28cacc62bf2a1099f96b8231b7bec91b9bd2e0e5915
|
Provenance
The following attestation bundles were made for knuckles_client-0.1.1-py3-none-any.whl:
Publisher:
release-sdk-py.yml on gsooter/knuckles
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
knuckles_client-0.1.1-py3-none-any.whl -
Subject digest:
eab5f97c04b2ed4e418d31fec68ce345245a9c10dfcfa0caea933c7c1f2e3358 - Sigstore transparency entry: 1419283163
- Sigstore integration time:
-
Permalink:
gsooter/knuckles@d75aebf2607c5e8ffe5e80ea3be532f08a93a8f4 -
Branch / Tag:
refs/tags/knuckles-client-py-v0.1.1 - Owner: https://github.com/gsooter
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release-sdk-py.yml@d75aebf2607c5e8ffe5e80ea3be532f08a93a8f4 -
Trigger Event:
push
-
Statement type: