Python (FastAPI · Flask)
📋 Confidential client registration required
This guide sends client_secret to /oauth/token from the server. When you register the RP in the Developer Console, choose Client type: Confidential. For a Public/SPA client, follow the Public Clients guide and never put client_secret in client-side code.
SDK quick start (recommended)
The official Python SDK logi-auth builds the authorize URL and does the code exchange + id_token verification (RS256) in one call. For a confidential integration, the server verifying the audience (aud) is the SoT — the SDK path below is recommended; the manual integration is a fallback.
pip install "logi-auth>=1.0.1" # the import name is logi_auth · v1.0.1 security hardening (at_hash binding) recommended# logi_client.py — create once at app startup
from logi_auth import LogiAuthServer
logi = LogiAuthServer(
client_id=os.environ["LOGI_CLIENT_ID"],
client_secret=os.environ["LOGI_CLIENT_SECRET"], # confidential — server env only
redirect_uri=os.environ["LOGI_REDIRECT_URI"],
# issuer defaults to "https://api.1pass.dev" · token_issuer defaults to "https://api.1pass.dev" (the id_token iss)
# scopes default to ["openid", "profile:basic", "email"]
)FastAPI
import os, secrets
from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse, JSONResponse
from logi_auth import LogiAuthServer, ServerError
app = FastAPI()
@app.get("/auth/login")
def login(request: Request):
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
# store state·nonce in the session / a signed cookie (checked in the callback)
request.session["logi_state"] = state
request.session["logi_nonce"] = nonce
return RedirectResponse(logi.authorization_url(state=state, nonce=nonce))
@app.get("/auth/callback")
def callback(request: Request, code: str = "", state: str = ""):
if not code or state != request.session.get("logi_state"):
return JSONResponse({"error": "state mismatch"}, status_code=400)
try:
# exchange_code_and_verify: token exchange + id_token signature/iss/aud/azp/exp/nonce checks in one call
session = logi.exchange_code_and_verify(
code=code,
nonce=request.session.pop("logi_nonce"), # matched against id_token.nonce (required)
)
except ServerError as e:
# e.code: "token_exchange_failed" | "id_token_invalid" | ... · e.detail
return JSONResponse({"error": e.code}, status_code=400)
# session.sub — verified subject. Key your user records on this value
# session.email / session.access_token / session.refresh_token / session.claims
request.session["user_id"] = session.sub
return RedirectResponse("/")Flask
import os, secrets
from flask import Flask, redirect, request, session
from logi_auth import LogiAuthServer, ServerError
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET_KEY"]
@app.get("/auth/login")
def login():
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(32)
session["logi_state"] = state
session["logi_nonce"] = nonce
return redirect(logi.authorization_url(state=state, nonce=nonce))
@app.get("/auth/callback")
def callback():
if not request.args.get("code") or request.args.get("state") != session.get("logi_state"):
return {"error": "state mismatch"}, 400
try:
s = logi.exchange_code_and_verify(
code=request.args["code"],
nonce=session.pop("logi_nonce"),
)
except ServerError as e:
return {"error": e.code}, 400
session["user_id"] = s.sub
return redirect("/")LogiSession fields: sub / email / id_token / access_token / refresh_token / expires_at / scope / claims. The PKCE (public) variant omits client_secret and uses authorization_url(state=, nonce=, code_challenge=) + exchange_code_and_verify(code=, nonce=, code_verifier=). Standalone verification is the verify_id_token(id_token, jwks, expected, access_token=...) function (expected={"issuer", "client_id", "nonce"}) — pass access_token to also confirm the at_hash binding. Errors are ServerError (.code, .detail) and IdTokenError (.code).
id_token verification order (built into the SDK — identical across all SDKs)
alg==RS256 → kid→JWKS → signature (RS256) → iss (=token_issuer "https://api.1pass.dev") → aud (must contain your client_id) → azp (required only when aud is multi-valued; value=client_id) → exp (60s skew) → iat → nonce (when requested) → sub → at_hash (last, present-only). azp is conditional on a multi-audience token, not unconditional (OIDC §3.1.3.7). scope is an authorization axis, not an id_token claim check — don't put it in the verification order.
at_hash binding (v1.0.1): the contract is base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6). Verified only when the id_token carries at_hash and an access_token is supplied (rejected with at_hash_mismatch); exchange_code_and_verify wires the access_token internally so the RP need not pass it. v1.0.1 also selects the signing key with a kty=="RSA"+use/alg filter, robust to EC-key mix-in (JWKS).
First-login extras & canonical_sub
logi only sends sub + email (+ nickname). Don't auto-create on extra fields; route new users through a first-login mini-form: First-login completion form.
Across surfaces (web/iOS/Android) the client_id differs, so the pairwise sub differs per platform — identify a person by canonical_sub. Port the Rails LogiIdentityLink + canonical resolver + webhook/polling reconciler pattern straight to Python; the contract (aud verification, canonical resolution) is the same: Rails · canonical_sub (Account Merge).
# canonical resolution (same contract as Rails LogiCanonicalResolution)
def resolve_logi_user(claims: dict):
sub = claims["sub"]
canonical_sub = claims.get("canonical_sub") or sub
# if canonical_sub != sub, record a link linked_user_id=sub -> primary_user_id=canonical_sub (idempotent)
# always look up by canonical_sub
return User.query.filter_by(logi_sub=canonical_sub).first()No auto-bootstrap on verified email
Auto-linking an existing password account by logi email alone in the callback is an account-takeover vector on RPs that don't verify email. Never attach to an existing account by email match while unauthenticated. Background: Email claim policy · Sub policy.
Manual integration (without the SDK)
A fallback that rolls it by hand. Call authorize/token with requests, then verify the id_token or access token with PyJWT + cryptography against the JWKS. You must reproduce the verification order from the tip above (in particular enforce alg==RS256 and check aud/azp). If you only need access-token JWKS verification, see the JWKS reference.
# pip install requests pyjwt cryptography
import requests, jwt
from jwt import PyJWKClient
ISSUER = os.environ["LOGI_API_URL"] # https://api.1pass.dev
# Call the authorize + token endpoints from the SDK example directly with requests
# (grant_type=authorization_code, code, redirect_uri, code_verifier?, client_id, client_secret)
# Verify id_token / access_token (RS256 + JWKS)
jwks_client = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"], # never allow any other alg
issuer=ISSUER, # the id_token iss == ISSUER URL (same as access token)
audience=os.environ["LOGI_CLIENT_ID"],
options={"require": ["exp", "iat", "sub"]},
)
# ⚠️ PyJWT does not verify azp — for a multi-audience token, check azp == client_id yourself:
aud = claims.get("aud")
if isinstance(aud, list) and len(aud) > 1 and claims.get("azp") != os.environ["LOGI_CLIENT_ID"]:
raise ValueError("azp must equal client_id for a multi-audience id_token")Deployment environment variables
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_CLIENT_SECRET=logi_secret_... # confidential only · server env only, never expose to the client
LOGI_REDIRECT_URI=https://yourapp.com/auth/callback
LOGI_API_URL=https://api.1pass.dev # only needed for the manual integrationInject these on Render (or similar), redeploy, then confirm the login-start route 302-redirects to api.1pass.dev/oauth/authorize.