Python (FastAPI · Flask)
📋 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 입니다.
pip install "logi-auth>=1.0.1" # import 이름은 logi_auth · v1.0.1 보안 하드닝(at_hash 바인딩) 권장# 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
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
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, .detail)·IdTokenError(.code).
id_token 검증 순서 (SDK 내장 — 전 SDK 동일)
alg==RS256 → kid→JWKS → 서명(RS256) → iss(=token_issuer "https://api.1pass.dev") → aud(내 client_id 포함) → azp(aud 가 복수일 때만 필수, 값=client_id) → exp(skew 60s) → iat → nonce(요청 시) → sub → at_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).
# 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()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 레퍼런스를 참고하세요.
# 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")배포 환경변수
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 되는지 확인하세요.