Skip to main content

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 aud check can be tricked into accepting a token minted for a different client (cross-client account takeover — the launchcrew/krx incident). exchange_code_and_verify always verifies signature + iss + aud + exp + nonce before returning sub.

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) -> str Builds the /oauth/authorize redirect URL.
  • .exchange_code_and_verify(*, code, nonce, code_verifier=None) -> LogiSession Exchanges the authorization code for tokens, then verifies the returned id_token (signature via JWKS + iss + aud + exp + nonce, with a transparent single JWKS refetch on key rotation) before returning a LogiSession.

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 noncebefore 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

logi_auth-1.0.1.tar.gz (17.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

logi_auth-1.0.1-py3-none-any.whl (14.8 kB view details)

Uploaded Python 3

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

Hashes for logi_auth-1.0.1.tar.gz
Algorithm Hash digest
SHA256 184604262aa7409a0bad15b6d6d61ea64e320019cb7372876d08de1609fdefe4
MD5 639efdef5be461a369e5c70f9d70e65b
BLAKE2b-256 b13341da250a1d5cb56ba1fba0d00df8e2652f3e490990b9b06cb2b559ba10f6

See more details on using hashes here.

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

Hashes for logi_auth-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 40dc98acb380343d4a6d518a1dbf3fa996bf9fd8590e5a5fa15669542c193cf6
MD5 f20af270839824e13aaf979c423f6b11
BLAKE2b-256 aa22962218f18694e9df8bbdb46d796453ecc6d956135a5be1d9eaec7a3d06ef

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page