Skip to content

Python (FastAPI · Flask)

Source: integrations/python.md · Live: https://docs.1pass.dev/integrations/python LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).

📍 Jump-to Index

  • L25-L143: ## SDK 빠른 시작 (권장)
    • L49-L87: ### FastAPI
    • L88-L143: ### Flask
  • L144-L169: ## 첫 로그인 추가 정보 & canonical_sub
  • L170-L203: ## 수동 통합 (SDK 미사용)
  • L204-L215: ## 배포 환경변수

Python (FastAPI · Flask)

⚠️ Warning: 📋 Confidential client 등록이 필요합니다 이 가이드는 client_secret 을 서버에서 /oauth/token 에 전달합니다. Developer Console 에서 RP 등록 시 Client type: Confidential 을 선택하세요. Public/SPA 클라이언트라면 Public Clients 가이드를 따르고 client_secret 을 절대 클라이언트에 두지 마세요.

SDK 빠른 시작 (권장)

공식 Python SDK logi-auth 가 authorize URL 생성 + code 교환 + id_token 검증(RS256) 을 한 번에 처리합니다. confidential 통합에서 서버가 audience(aud) 를 검증하는 것이 SoT — 아래 SDK 경로가 권장이고, 수동 통합은 fallback 입니다.

bash
pip install "logi-auth>=1.0.1"   # import 이름은 logi_auth · v1.0.1 보안 하드닝(at_hash 바인딩) 권장
python
# logi_client.py — 앱 시작 시 1회 생성
from logi_auth import LogiAuthServer

logi = LogiAuthServer(
    client_id=os.environ["LOGI_CLIENT_ID"],
    client_secret=os.environ["LOGI_CLIENT_SECRET"],   # confidential — 서버 env 전용
    redirect_uri=os.environ["LOGI_REDIRECT_URI"],
    # issuer 기본값 "https://api.1pass.dev" · token_issuer 기본값 "https://api.1pass.dev" (id_token 의 iss)
    # scopes 기본값 ["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)
    # state·nonce 를 세션/서명 쿠키에 저장 (콜백에서 대조)
    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: code 교환 + id_token 서명·iss·aud·azp·exp·nonce·at_hash 검증까지 한 번에
        session = logi.exchange_code_and_verify(
            code=code,
            nonce=request.session.pop("logi_nonce"),   # id_token.nonce 대조 (필수)
        )
    except ServerError as e:
        # e.code: "token_exchange_failed" | "id_token_invalid" | ... · e.detail
        return JSONResponse({"error": e.code}, status_code=400)

    # session.sub — 검증 완료된 subject. 이 값으로 사용자 레코드 키잉
    # 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 필드: sub / email / id_token / access_token / refresh_token / expires_at / scope / claims. PKCE(public) 변형은 client_secret 생략 + authorization_url(state=, nonce=, code_challenge=) + exchange_code_and_verify(code=, nonce=, code_verifier=). 스탠드얼론 검증은 verify_id_token(id_token, jwks, expected) 함수(expected={"issuer", "client_id", "nonce"}) — access_token 바인딩까지 확인하려면 verify_id_token(id_token, jwks, expected, access_token=...). 에러는 ServerError(.code, .detailIdTokenError(.code).

💡 Tip: id_token 검증 순서 (SDK 내장 — 전 SDK 동일) alg==RS256kid→JWKS → 서명(RS256) → iss(=token_issuer "https://api.1pass.dev") → aud(내 client_id 포함) → azp(aud 가 복수일 때만 필수, 값=client_id)exp(skew 60s) → iatnonce(요청 시) → subat_hash(마지막, present-only). azp 는 무조건이 아니라 멀티 aud 조건부입니다(OIDC §3.1.3.7). scope 는 id_token claim 검증이 아니라 인가(authorization) 축 — 검증 순서에 넣지 마세요.

at_hash 바인딩 (v1.0.1): 계약은 base64url_nopad(SHA256(access_token 의 UTF-8 바이트)[처음 16바이트]) (OIDC §3.1.3.6). id_token 에 at_hash 가 있고 access_token 이 제공될 때만 검증하고, 불일치 시 at_hash_mismatch. exchange_code_and_verify 는 access_token 을 내부적으로 배선하므로 RP 가 별도로 넘길 필요가 없습니다. 또한 v1.0.1 은 JWKS 에서 kty=="RSA" + use/alg 필터로 서명 키를 선택해 EC 키 혼입에 견고합니다(JWKS 레퍼런스).

첫 로그인 추가 정보 & canonical_sub

logi 는 sub + email(+ nickname) 만 전달합니다. 추가 필드는 자동 create 금지, 첫 로그인 미니 폼으로: 첫 로그인 완료 폼.

멀티플랫폼(web/iOS/Android)은 client_id 가 갈려 pairwise sub 이 플랫폼마다 다르므로 사람 단위 식별은 canonical_sub 로 해야 합니다. Rails 예시의 LogiIdentityLink + canonical resolver + webhook/polling reconciler 패턴을 Python 으로 그대로 옮기세요 — 계약(aud 검증, canonical resolve)은 동일합니다: Rails · canonical_sub (Account Merge).

python
# canonical resolve (Rails LogiCanonicalResolution 과 동일 계약)
def resolve_logi_user(claims: dict):
    sub = claims["sub"]
    canonical_sub = claims.get("canonical_sub") or sub
    # canonical_sub != sub 이면 linked_user_id=sub -> primary_user_id=canonical_sub 링크 기록(멱등)
    # 조회는 항상 canonical_sub 기준
    return User.query.filter_by(logi_sub=canonical_sub).first()

🚨 Danger: verified-email 자동 bootstrap 금지 콜백에서 logi email 만으로 기존 비번 계정을 자동 연결하면 이메일 미검증 RP 에서 계정 탈취가 됩니다. 로그인되지 않은 상태에서 email 매칭만으로 기존 계정에 붙이지 마세요. 배경: Email Claim 정책 · Sub 정책.

수동 통합 (SDK 미사용)

SDK 없이 직접 구현하는 fallback 입니다. requests 로 authorize·token 을 호출하고, id_token 또는 access token 을 PyJWT + cryptography 로 JWKS 검증합니다. 검증 순서는 위 tip 과 동일하게 구현해야 합니다 (특히 alg==RS256 강제, aud/azp 검사). access token JWKS 검증만 필요하면 JWKS 레퍼런스를 참고하세요.

python
# pip install requests pyjwt cryptography
import requests, jwt
from jwt import PyJWKClient

ISSUER = os.environ["LOGI_API_URL"]  # https://api.1pass.dev

# authorize + token 교환은 위 SDK 예시의 URL 을 직접 requests 로 호출
# (grant_type=authorization_code, code, redirect_uri, code_verifier?, client_id, client_secret)

# 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"],            # 절대 다른 alg 허용 금지
    issuer=ISSUER,                   # id_token 의 iss == ISSUER URL (access token 과 동일)
    audience=os.environ["LOGI_CLIENT_ID"],
    options={"require": ["exp", "iat", "sub"]},
)
# ⚠️ PyJWT 는 azp 를 검증하지 않음 — 멀티 aud 일 때 azp == client_id 를 직접 확인:
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")

배포 환경변수

bash
LOGI_CLIENT_ID=logi_xxxxxxxxxxxxxxxx
LOGI_CLIENT_SECRET=logi_secret_...   # confidential 만 · 서버 env 전용, 클라 노출 금지
LOGI_REDIRECT_URI=https://yourapp.com/auth/callback
LOGI_API_URL=https://api.1pass.dev   # 수동 통합에서만 필요

Render 등에 주입하고 재배포 후, 로그인 시작 라우트가 api.1pass.dev/oauth/authorize 로 302 되는지 확인하세요.

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