logi-auth (Python)
Server-side "Sign in with logi" for Python / Django backends — confidential
OAuth 2.0 Authorization Code exchange + id_token (RS256) verification. Only
dependency: cryptography (no HTTP client dependency — stdlib urllib).
This is the confidential / backend counterpart to the public-client SDKs (browser, iOS, Android, Flutter). If your RP has a backend, verify on the server with this package — do not rely on a client-side check.
Why it matters: a backend that skips the id_token
audcheck can be tricked into accepting a token minted for a different client (cross-client account takeover — the launchcrew/krx incident).exchange_code_and_verifyalways verifies signature + iss + aud + exp + nonce before returningsub.
Supported versions
| Requirement | Version |
|---|---|
| Python | >= 3.9 |
| Django | any — the package is framework-agnostic; use it from any view |
| Flask / FastAPI / etc. | any |
| Dependencies | cryptography >= 41.0 |
Install
pip install logi-auth
Upgrading from 1.x
2.0 is a breaking change. logi mandates PKCE for every client type,
confidential included — .authorization_url()'s code_challenge and
.exchange_code_and_verify()'s code_verifier are now required keyword
arguments (they were optional in 1.x). A call that omitted them was already
being rejected by the server, so working code is unaffected; only calls that
relied on the (always-failing) optional path need the two arguments added.
See the Django example below for the full PKCE flow.
Django view example
# settings.py (or a dedicated logi_auth.py config module)
import os
from logi_auth import LogiAuthServer
LOGI = LogiAuthServer(
client_id=os.environ["LOGI_CLIENT_ID"],
client_secret=os.environ["LOGI_CLIENT_SECRET"], # confidential client
redirect_uri="https://app.example.com/auth/logi/callback",
)
# views.py
import base64
import hashlib
import secrets
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from logi_auth import ServerError
from .settings import LOGI
from .models import User
def logi_start(request):
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
# PKCE is mandatory on logi for confidential clients too — keep the
# verifier server-side and send only its S256 hash in the redirect.
verifier = secrets.token_urlsafe(64)
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
request.session["logi_state"] = state
request.session["logi_nonce"] = nonce
request.session["logi_verifier"] = verifier
return redirect(
LOGI.authorization_url(state=state, nonce=nonce, code_challenge=challenge)
)
def logi_callback(request):
if request.GET.get("state") != request.session.pop("logi_state", None):
return HttpResponseBadRequest("state mismatch")
# The provider may redirect back with ?error=access_denied (user cancelled)
# and no `code` — handle that before the exchange instead of 500-ing.
if request.GET.get("error"):
return redirect(f"/login?error={request.GET['error']}")
code = request.GET.get("code")
if not code:
return HttpResponseBadRequest("missing authorization code")
try:
result = LOGI.exchange_code_and_verify(
code=code,
nonce=request.session.pop("logi_nonce", None),
code_verifier=request.session.pop("logi_verifier", None),
)
except ServerError as e:
# result.sub is only ever set after signature+iss+aud+exp+nonce all pass.
return redirect(f"/login?error={e.code}")
# result.sub is the verified pairwise subject — key your User record on it.
user, _ = User.objects.get_or_create(logi_sub=result.sub, defaults={"email": result.email})
request.session["user_id"] = user.id
return redirect("/")
Identity claims (email/name) are not guaranteed on the id_token — fetch
them from GET {issuer}/oauth/userinfo with the returned access_token as a
Bearer token if you need more than sub/email.
Public client (no secret)
PKCE is not what separates the two client types — logi requires
code_challenge / code_verifier from every client, confidential ones
included, and the example above already carries them. The only difference for a
public client is that there is no secret to send:
logi = LogiAuthServer(client_id=client_id, redirect_uri=redirect_uri) # no secret
Everything else — the challenge on the way out, the verifier on the way back — is identical to the confidential flow.
API
from logi_auth import LogiAuthServer, LogiSession, verify_id_token, LogiAuthError, IdTokenError, ServerError
LogiAuthServer(...)
LogiAuthServer(
*,
client_id: str,
redirect_uri: str,
client_secret: str | None = None,
issuer: str = "https://api.1pass.dev",
token_issuer: str = "https://api.1pass.dev",
scopes: list[str] | None = None, # default: ["openid", "profile:basic", "email"]
jwks_cache_ttl: int = 3600,
)
.authorization_url(*, state, nonce, code_challenge, scopes=None, prompt=None) -> strBuilds the/oauth/authorizeredirect URL.code_challengeis required — it is the base64url-encoded SHA-256 of your verifier, and the server rejects a request without it regardless of client type..exchange_code_and_verify(*, code, nonce, code_verifier) -> LogiSessionExchanges the authorizationcodefor tokens, then verifies the returnedid_token(signature via JWKS +iss+aud+exp+nonce, with a transparent single JWKS refetch on key rotation) before returning aLogiSession.
LogiSession
Returned only once id_token verification has fully passed:
| Field | Type |
|---|---|
sub |
str — verified pairwise subject |
email |
str | None |
id_token |
str |
access_token |
str |
refresh_token |
str | None |
expires_at |
int | None — unix timestamp |
scope |
str | None |
claims |
dict — full verified id_token claim set |
Error handling
Both error types carry a .code string for programmatic branching (same
codes as the Ruby/Node/Web SDKs and the shared golden vectors).
ServerError.code:
| Code | Meaning |
|---|---|
invalid_nonce |
Missing nonce — the sign-in session likely expired |
invalid_code_verifier |
Missing PKCE verifier — it was lost with the session, or never stored at /start |
token_exchange_failed |
/oauth/token returned a non-2xx status or malformed body |
missing_id_token |
Token response had no id_token (was openid in scopes?) |
id_token_invalid |
id_token failed verification — .detail carries the underlying IdTokenError.code |
jwks_fetch_failed |
JWKS endpoint unreachable or returned a malformed document |
network_error |
Transport-level failure talking to the issuer |
IdTokenError.code (raised by verify_id_token directly, or wrapped into
ServerError("id_token_invalid", ...) by exchange_code_and_verify):
malformed, missing_kid, unknown_kid, bad_signature, iss_mismatch,
aud_mismatch, expired, nonce_mismatch, missing_claim, at_hash_mismatch.
from logi_auth import ServerError
try:
result = LOGI.exchange_code_and_verify(
code=code, nonce=nonce, code_verifier=code_verifier
)
except ServerError as e:
logger.warning("logi sign-in failed: %s (%s)", e.code, e.detail)
return redirect(f"/login?error={e.code}")
Security
exchange_code_and_verify performs the full id_token verification —
signature against the issuer's JWKS, iss, aud (against your client_id),
exp, and nonce — before it ever returns a sub. Verifying on the
server, not just trusting a client-supplied token, is what closes the
cross-client account-takeover class of bug: a frontend alone cannot prove that
an id_token it received was actually minted for your client_id.
License
Apache-2.0
Metadata
Release files for logi-auth 2.0.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 | |
|---|---|---|---|
| logi_auth-2.0.0.tar.gz | 19.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| logi_auth-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 35.0 kB
Release files / logi_auth-2.0.0.tar.gz
| Download URL | logi_auth-2.0.0.tar.gz |
|---|---|
| Size | 19.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6947808946534b93178e831a4680c809ac0fd0ff4bb078044512a897ca679f06
|
|
BLAKE2b-256 checksum How to use checksums |
0ae237ee5e77db375be56fcf2d30c5bf0df4f9c7d2d3a5385c13be73e731e044
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|
Release files / logi_auth-2.0.0-py3-none-any.whl
| Download URL | logi_auth-2.0.0-py3-none-any.whl |
|---|---|
| Size | 15.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
31e079068d10576de9e9a4301c795dbb9b665642899c45692f8c022a365dc720
|
|
BLAKE2b-256 checksum How to use checksums |
1c5a0f47239b9cf1085d14dac9beb1e080e061646fa966de87e63fbb0402be7d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.8
|