leap-oauth-sdk
Business-side SDK for services that accept access tokens issued by Leap Auth, an OAuth 2.1 authorization server. It verifies tokens locally against the server's JWKS and reports every failure as one of four error categories, so each service handles retries, re-login and permission errors the same way.
Status: alpha. This release contains access token verification, the server side of browser
sign-in (code exchange, refresh, online session check, logout), encrypted cookie sessions,
leap-sso.js (the matching browser module), principal lookup for admin pickers and admin
member rules.
Install
uv add leap-oauth-sdk
Python 3.12 or newer.
Feishu native v2
The leap_oauth_sdk.native module accepts the final native contract. Its ver is
sakamoto_feishu_access_token.v2, its header is typ=at+jwt, and client_id, purpose,
feishu_tenant and an exact five-field person object are mandatory. Earlier pilot tokens
with the same ver are rejected. Native verification never invokes the Principal v1 verifier.
from leap_oauth_sdk import LeapAuthConfig
from leap_oauth_sdk.native import NativeAccessTokenVerifier, grant_matches
native = NativeAccessTokenVerifier(
LeapAuthConfig(issuer="https://auth.example.com", audience="example-service"),
expected_feishu_tenant="configured-tenant-key",
required_purpose="access",
allowed_channels=("feishu_oauth", "feishu_bot", "obo"),
)
identity = native.verify(bearer_token) # verify_async is also available
if grant_matches(stored_person_key, stored_union_fingerprint, identity):
... # apply this grant's local business permissions
Reuse one verifier per process for JWKS caching and rotation. Static JWKS can instead be
passed to verify_native_access_token(token, *, issuer, audience, jwks, expected_feishu_tenant, required_purpose, allowed_channels). The pure helper defaults to
EdDSA/RS256; NativeAccessTokenVerifier uses LeapAuthConfig.allowed_algorithms (EdDSA by
default). Native leeway is limited to 60 seconds. All signature and registered-claim
checks remain enabled.
identity.authorization_key is the exact (issuer, person_key) pair. Scope stored grants
to the configured issuer before calling grant_matches; the helper compares person keys
without trimming, case folding or normalization. Only the fixed feishu:user_id: prefix
is removed, so a user ID may contain a colon. identity.display.name and .email, the
client_id, and delegated actors are never grant subjects. A configured union fingerprint
requires an exact non-null match; this check alone does not establish account-generation
or employment lifecycle guarantees. unknown source status stays unknown; inactive is
rejected.
For browser SSO, select a single local profile explicitly:
sso = BrowserSSO(
config, browser_client,
token_profile="feishu_native_v2",
expected_feishu_tenant="configured-tenant-key",
)
exchange_code, refresh and check_session then use the native verifier, including their
async variants. Browser identity must also match the registered client. Omitting
token_profile keeps the existing Principal v1 behavior. No response triggers a retry using
the other identity contract. PKCE, state, cookie encoding and leap-sso.js are unchanged.
Native directory and grant selection
Use a backend-held directory_reader credential; never send it to the browser:
from leap_oauth_sdk.native import DirectoryClient
directory = DirectoryClient(
config,
lookup_token=directory_reader_credential,
bound_app_id="central-app-id",
)
person = directory.resolve(email="person@example.com")
person = directory.resolve(union_id="native-union-id")
person = directory.resolve(open_id="central-app-open-id") # uses bound_app_id
grant_person = directory.resolve_for_grant(person_key=person.person_key)
stored_grant = grant_person.to_dict() # only person_key and optional union_id
resolve accepts exactly one of person_key, user_id, email, union_id, open_id.
An Open ID needs app_id= unless bound_app_id was supplied; conflicting App bindings
are refused before a request. External-App Open IDs must first be converted by their own
App. The client never retries a failed locator using a different locator. Email and union
lookups are checked against the returned fresh profile; Open-ID checking remains on the
server because that field is not returned.
search(q, limit=20) returns .items and .has_more, and batch(person_keys) returns one
typed result per distinct requested key in request order. Batch requires the final
results contract, checks full coverage and rejects duplicate, extra or mismatched results;
legacy items/invalid_keys are not a fallback. resolve_for_grant always performs a fresh
resolve and rejects inactive users. Search profiles alone cannot create grants, and changing
an email's owner never rewrites a previously selected key. Sync calls have matching _async
methods. Upstream failures remain retryable; rejected reader credentials are configuration
errors and do not start user login. No user access token, phone or employee-ID locator is
supported.
The shared contract file is testdata/feishu_native_v2_vectors.json in this repository and
source distribution, or leap_oauth_sdk/testdata/feishu_native_v2_vectors.json in the wheel.
It fixes a test clock and contains full headers/claims: consumers sign each vector with a
local test key, apply any signature=tampered marker and check its accept result. It is
the same file for signing-side, resource-side and non-Python contract tests.
Verify an access token
Create one verifier per process and reuse it, so the JWKS cache is shared by all requests.
from leap_oauth_sdk import AccessTokenVerifier, LeapAuthConfig, OAuthSDKError
verifier = AccessTokenVerifier(
LeapAuthConfig(
issuer="https://auth.example.com", # must equal the token's iss claim exactly
audience="example-service", # this service's own audience
)
)
async def current_subject(bearer_token: str) -> str:
try:
context = await verifier.verify_async(bearer_token)
except OAuthSDKError as exc:
# exc.status is the suggested local HTTP status; exc.code is stable for logs.
raise HTTPException(status_code=exc.status, detail={"error": exc.code})
return context.subject_id
verify() is the blocking variant. Both share the same cache.
The SDK does not read environment variables and has no default hosts. Load settings in your
own configuration layer and pass them to LeapAuthConfig.
Verify registered claims for an existing token contract
Use verify() or verify_async() for the complete access-token contract above. Services
that already accept a different metadata shape can instead use verify_claims() or
verify_claims_async(). Both return VerifiedTokenClaims(raw_claims=...), with original
claim types and no invented identity fields.
from leap_oauth_sdk import AccessTokenVerifier, LeapAuthConfig, RegisteredClaimsPolicy
verifier = AccessTokenVerifier(
LeapAuthConfig(
issuer="https://auth.example.com",
audience="example-service",
allowed_algorithms=("EdDSA", "RS256"),
leeway_seconds=0,
jwks_refresh_cooldown_seconds=30,
)
)
verified = verifier.verify_claims(
bearer_token,
policy=RegisteredClaimsPolicy.PYJWT_COMPATIBLE,
)
claims = verified.raw_claims
# Apply the service's established version, purpose, status, revocation and authorization
# rules before using these claims as an authenticated application identity.
RegisteredClaimsPolicy has exactly two presets; arbitrary JWT options are not accepted:
| Rule | STRICT (default) |
PYJWT_COMPATIBLE (explicit) |
|---|---|---|
| Required registered claims | exp, iat, nbf, iss, sub, aud, jti |
exp, iat, sub, aud; issuer validation also requires iss |
| Audience | One exact string | A string or string array containing the configured audience |
Optional nbf / jti |
Required | Validated when present |
| Key selection | Signing key matching kid and explicit alg |
PyJWT-style signing-key order and exact kid; missing JWK alg is inferred by PyJWT |
| Additional size limits | 16 KiB token, 64 KiB JWKS | No additional token/JWKS byte cap |
| First unknown-key refresh | Respects the interval since the last fetch | Immediate, then limited by jwks_refresh_cooldown_seconds |
Both presets always verify the signature, algorithm allowlist, issuer, audience and token timestamps. The compatible preset preserves the registered-claim and public-key selection behavior used with PyJWT 2.10.1, including numeric-string timestamps and the first matching signing key. It never retries a failed signature with another key. Private key material is rejected, and keys declaring an encryption or other non-signature use are excluded in both presets. No preset disables verification or changes the configured issuer or audience.
Raw verified claims are not a complete application identity. Neither raw-claims method
validates application metadata such as status, ver, purpose, source or
principal_type. Missing fields remain missing. The caller must enforce its own existing
contract. The complete verify() and verify_async() methods always use STRICT, retain
all metadata checks and return the unchanged AccessTokenContext type.
All four methods share one JWKS cache and one in-flight fetch, including mixed sync/async
callers. Size and key-selection checks apply on every use, so compatible cache entries do
not bypass strict checks. A failed refresh invalidates previously cached keys and enters
the configured failure cooldown. Empty, malformed or unusable JWKS produce an
InvalidResponse infrastructure error; transport failures produce Retryable. A usable
JWKS with no matching key produces ReauthRequired. Services can map infrastructure
errors to their established availability response without treating them as invalid tokens.
HTTP redirects are not followed. Call blocking methods from a worker thread rather than
an asynchronous event-loop thread.
Structurally broken entries in a mixed JWKS are infrastructure failures, rather than being
skipped to accept a later key. Key-shape or construction errors that previously escaped as
unhandled exceptions are reported as InvalidResponse.
Browser sign-in
One browser client is registered per service and environment. The browser runs authorization code + PKCE with a top-level redirect; your backend exchanges the code. Tokens never appear in URLs. Two ways to hold credentials:
- bearer: the page keeps the access and refresh token in
sessionStorageand sendsAuthorization: Bearer. Your API verifies withcheck_session(). - cookie: the backend keeps both tokens in an encrypted HttpOnly cookie and the page never
sees them. Use
CookieSessionManager.
from leap_oauth_sdk import BrowserClientConfig, BrowserSSO, LeapAuthConfig, OAuthSDKError
sso = BrowserSSO(
LeapAuthConfig(issuer="https://auth.example.com", audience="example-service"),
BrowserClientConfig(
client_id="web-example",
redirect_uri="https://app.example.com/auth/callback", # exact registered callback
resource="https://app.example.com/",
),
)
Serve these routes under one base path (bearer mode shown). On OAuthSDKError every route
responds with exc.status, exc.headers and the body exc.to_dict():
| Route | Handler |
|---|---|
GET {base}/config |
sso.browser_config() |
POST {base}/exchange {code, code_verifier} |
sso.exchange_code(...).to_browser_payload() |
POST {base}/refresh {refresh_token} |
sso.refresh(...).to_browser_payload() |
POST {base}/logout {scope} + Bearer |
sso.logout(token, scope) |
Protected API routes call sso.check_session(bearer_token) on every request and must not
cache the result, so that signing out in one service takes effect everywhere on the next
request; pass verified= when the same token was already verified locally. An already-ended
sign-in counts as a successful logout.
Browser module
leap-sso.js ships inside the package; leap_oauth_sdk.static_dir() returns its directory.
<script src="/static/leap-sso.js"></script>
<script>
const sso = LeapSSO.create({
endpointBase: "/api/auth/browser",
credentialMode: "bearer", // or "cookie"
storagePrefix: "example.admin", // sessionStorage keys; storageKeys can keep old names
defaultNext: "/admin",
hooks: {
onLoading: () => showSpinner(),
onLogin: (message, retryable) => showSignIn(message, retryable),
onForbidden: () => showNoAccess(),
},
});
const state = await sso.boot(); // "ready" | "redirecting" | "login" | "forbidden" | "disabled"
if (state === "ready") render(await (await sso.fetch("/api/me")).json());
signInButton.onclick = () => sso.login();
signOutButton.onclick = () => sso.logout().catch(showRetry);
</script>
boot()finishes a callback, restores the deep link, or starts one automatic sign-in that only reuses an existing sign-in. Every page load makes at most one automatic attempt; a failed callback shows sign-in instead of looping.fetch()adds the bearer token and refreshes 60 seconds before expiry, one refresh at a time. A 401 drops local credentials and makes the one automatic attempt; 403 and other statuses are returned to you untouched. A late 401 for an older bearer token keeps any newer session:GET,HEADandOPTIONSmay retry once; other methods return the original readable 401 without replaying the request. A safe retry also keeps the newer session if its retry limit is reached. The method of aRequestobject is respected.- A duplicated tab copies
sessionStorageand with it a refresh token the other tab may already have spent. The server then answersrefresh_rotation_in_progress; the module drops the copy and signs in again through SSO instead of replaying it. In cookie mode the same answer comes from two replicas refreshing at once, so safe methods are retried briefly.POST,PUT,PATCH,DELETEand other non-safe methods return the original 503 response. Business writes are never automatically replayed; the application decides whether and how to ask the user to retry. logout()sendsscope=browser, which ends the sign-in for every service in this browser. Credentials are cleared only when the backend confirms; the next page load shows sign-in once.- Messages default to Chinese; pass
messagesto override.
Cookie sessions
from leap_oauth_sdk import CookieCipher, CookieSessionManager
cookies = CookieSessionManager(sso, CookieCipher(secret)) # secret: 32+ characters
# callback: value = cookies.issue(sso.exchange_code(code, verifier), extra={"csrf": csrf})
# per request:
result = cookies.resolve(request.cookies.get("example_session"))
if result.cookie is not None: # refreshed or migrated: write it back
response.set_cookie(
"example_session", result.cookie, max_age=cookies.max_age(result.session),
httponly=True, secure=True, samesite="lax",
)
# logout: cookies.logout(value), then delete the cookie
resolve() refreshes shortly before expiry and checks the sign-in online. Requests carrying
the same old cookie share one refresh within the process, and the result is reused for ten
seconds so a late request never replays the spent refresh token. CookieCipher accepts
previous_secrets for rotation and a context string; legacy_loader lets a service read its
old cookie format once and rewrite it.
Principal lookup
Admin pages pick people through the authorization server instead of typing subject ids.
PrincipalDirectory uses a service lookup credential, never the admin's own token.
from leap_oauth_sdk import PrincipalCandidate, PrincipalDirectory
directory = PrincipalDirectory(config, lookup_token=lookup_token)
result = directory.search("ann") # principals plus directory users; < 2 chars → empty
rows = [c.to_dict() for c in result.candidates] # send to the page; the page posts one back
picked = PrincipalCandidate.from_dict(posted) # untrusted: only ids are kept
accepted = directory.canonicalize([picked, "usr_existing"])
canonicalize()binds directory users to principals, follows aliases to the canonical subject and requires active principals ofallowed_principal_types(defaulthuman). It is all or nothing: any rejected pick raisesPrincipalSelectionError(422) whoserejectedmaps each pick to a reason, and nothing is accepted.validate()returns status and profile per subject in batches of 100, for listing.- A rejected lookup credential is reported as
InvalidResponse, notReauthRequired: it is a configuration problem, and the admin must not be sent to sign in again. api_base_urlpoints lookups at an internal address of the authorization server.
Admin members
MemberService applies the same rules in every service; storage stays in the service.
from leap_oauth_sdk import JsonFileMemberStore, MemberPolicy, MemberService
members = MemberService(
MemberPolicy(owners=frozenset({owner_subject})), # owners come only from configuration
JsonFileMemberStore("/data/admins.json"), # or your own MemberStore
directory, # None: list and remove only
)
members.role_for(subject) # "owner" | "member" | None, read on every call
snapshot = members.list() # profiles from validate(); degrades when it fails
version = members.add(actor, [picked], expected_version=snapshot.version)
members.remove(actor, subject, expected_version=version)
- Only owners add or remove members, owners cannot be removed, and owners in a selection are
ignored. Every write carries the version the admin saw; a concurrent change raises
MemberErrormember_version_conflict(409) instead of overwriting. MemberStorehas two methods,load()andapply(change, expected_version=);applymust persist the change and itsMemberChangeaudit record atomically.JsonFileMemberStoreis a reference implementation for a single replica on a persistent volume.MemberErrorhas the sameto_dict()shape as the SDK errors. Messages are English; mapcodeto your own wording.
Verification rules
- Signature algorithm must be in
allowed_algorithms(defaultEdDSAonly); the token must carry akidthat exists in the JWKS. - Registered claims
exp,iat,nbf,iss,sub,aud,jtiare required;audmust be a single string equal toaudience;issmust equalissuer. vermust equaltoken_version;statusmust beactive;source.channelmust be present;purposemust be inallowed_purposes(a token withoutpurposecounts asaccess).- The JWKS is cached for
jwks_cache_seconds(300). An unknownkidforces at most one refetch perjwks_refresh_cooldown_seconds(10). A failed or malformed fetch is never cached, never falls back to expired keys and is not retried within the same cooldown. Redirects are not followed.
Business authorization (roles, resources, fields, actions) stays in your service. A verified token only proves who the caller is.
Error categories
| Exception | Status | Meaning | What to do |
|---|---|---|---|
Retryable |
503 | Authorization server or JWKS unavailable, timeout, 5xx or 429, temporarily_unavailable (including refresh_rotation_in_progress) |
Keep credentials, retry later |
ReauthRequired |
401 | Token invalid, expired, revoked or unknown key; upstream 401 or invalid_grant; sign-in no longer active |
Clear credentials, sign in again |
Forbidden |
403 | Principal inactive, token purpose not accepted, upstream 403 | Show a permission error, do not re-login |
InvalidResponse |
502 | Malformed upstream response, redirect, invalid_client, token not bound to a browser sign-in |
Alert; do not loop sign-in |
All four inherit from OAuthSDKError and expose code, message, status and retryable;
to_dict() and headers (Retry-After when retryable) build your error response. Messages
never include tokens or upstream response bodies.
Development
uv sync --extra dev
bash scripts/check.sh # also runs the browser module tests (Node 20+)
License
MIT
Metadata
Release files for leap-oauth-sdk 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| leap_oauth_sdk-0.4.0.tar.gz | 47.2 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| leap_oauth_sdk-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.1 kB
Release files / leap_oauth_sdk-0.4.0.tar.gz
| Download URL | leap_oauth_sdk-0.4.0.tar.gz |
|---|---|
| Size | 47.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4f13f91a5abd24159a8be0c61f41fe4e89832a26f9a3c0b2032dbf4debf80b79
|
|
BLAKE2b-256 checksum How to use checksums |
e25ebf459e1309f03ae3da5d7a72185fa943a758fd66c86376e6d99ec8952bcf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logRelease files / leap_oauth_sdk-0.4.0-py3-none-any.whl
| Download URL | leap_oauth_sdk-0.4.0-py3-none-any.whl |
|---|---|
| Size | 52.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
55cadeac1030d7042bcbcf70874bf74075fe7cfc24508521c21af8e5d9b4950d
|
|
BLAKE2b-256 checksum How to use checksums |
25836c996af0c6aeef141c847bf556fea9fd4a39d66cbac0f76dfbba5304d096
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logSecurity for leap-oauth-sdk 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for leap_oauth_sdk-0.4.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.