Skip to content

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.

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.

bash
pip install "logi-auth>=1.0.1"   # the import name is logi_auth · v1.0.1 security hardening (at_hash binding) recommended
python
# 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

python
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

python
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==RS256kid→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) → iatnonce (when requested) → subat_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).

python
# 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.

python
# 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

bash
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 integration

Inject these on Render (or similar), redeploy, then confirm the login-start route 302-redirects to api.1pass.dev/oauth/authorize.

Identity가 제품의 신뢰를 만듭니다.