Server-side "Sign in with logi" for Python/Django — confidential OAuth 2.0 + id_token (RS256) verification.
Project description
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
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 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)
request.session["logi_state"] = state
request.session["logi_nonce"] = nonce
return redirect(LOGI.authorization_url(state=state, nonce=nonce))
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),
)
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 (PKCE, no secret)
Omit client_secret and pass a code_challenge / code_verifier:
logi = LogiAuthServer(client_id=client_id, redirect_uri=redirect_uri) # no secret
url = logi.authorization_url(state=state, nonce=nonce, code_challenge=code_challenge)
result = logi.exchange_code_and_verify(code=code, nonce=nonce, code_verifier=code_verifier)
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, scopes=None, code_challenge=None, prompt=None) -> strBuilds the/oauth/authorizeredirect URL..exchange_code_and_verify(*, code, nonce, code_verifier=None) -> 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 |
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)
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
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 logi_auth-1.0.1.tar.gz.
File metadata
- Download URL: logi_auth-1.0.1.tar.gz
- Upload date:
- Size: 17.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
184604262aa7409a0bad15b6d6d61ea64e320019cb7372876d08de1609fdefe4
|
|
| MD5 |
639efdef5be461a369e5c70f9d70e65b
|
|
| BLAKE2b-256 |
b13341da250a1d5cb56ba1fba0d00df8e2652f3e490990b9b06cb2b559ba10f6
|
File details
Details for the file logi_auth-1.0.1-py3-none-any.whl.
File metadata
- Download URL: logi_auth-1.0.1-py3-none-any.whl
- Upload date:
- Size: 14.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.8
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
40dc98acb380343d4a6d518a1dbf3fa996bf9fd8590e5a5fa15669542c193cf6
|
|
| MD5 |
f20af270839824e13aaf979c423f6b11
|
|
| BLAKE2b-256 |
aa22962218f18694e9df8bbdb46d796453ecc6d956135a5be1d9eaec7a3d06ef
|