FastAPI 本身并不提供一套开箱即用的 SSO 系统,它更像是承载业务接口的地基。真正完成单点登录的,是外部身份提供商、OIDC/OAuth 2.0 协议,以及 FastAPI 应用内部的会话管理。实践中,最稳妥的组合通常是 OpenID Connect + Authorization Code Flow + PKCE,FastAPI 则作为 OIDC 客户端,也叫 Relying Party。
下面从原理、核心概念、代码实现、安全设计和生产部署几个层面,把这条链路完整拆开。
一、SSO 到底是怎样发生的
SSO 的关键不是让多个系统共享同一个 Cookie,而是让它们共同信任同一个身份提供商,例如 Keycloak、Microsoft Entra ID、Okta、Auth0 或企业自建 OIDC 服务。
假设公司有系统 A 和系统 B。用户第一次访问系统 A 时,被重定向到身份提供商登录。之后再访问系统 B,系统 B同样会把浏览器重定向到身份提供商,但身份提供商已经拥有登录会话,因此不再要求输入密码,而是直接签发新的授权码并跳回系统 B。
也就是说:
- 系统 A 有自己的本地会话 Cookie
- 系统 B 也有自己的本地会话 Cookie
- 身份提供商维护统一的登录会话
- 多个系统通过重定向间接复用这次登录状态
这才是 SSO,而不是粗暴地在多个域名之间共享 Cookie。
OpenID Connect 在 OAuth 2.0 之上增加了用户身份认证能力。OAuth 2.0 更关心客户端是否有权访问资源,OIDC 则回答了用户是谁。Authorization Code Flow 中,浏览器只传递短期授权码,真正的 Token 由 FastAPI 后端通过服务器间通信换取,因此比直接把 Token 暴露在浏览器地址栏中更稳妥。
二、实现 SSO 必须理解的核心概念
协议名不少,但不用被术语吓住。把每个对象在登录链路中的职责理清,整个系统就会变得相当直观。
1. 身份提供商与客户端
| 概念 | 常见缩写 | 承担的角色 | 典型实例 |
|---|---|---|---|
| Identity Provider | IdP | 登录用户、维护统一会话、签发 Token | Keycloak、Okta、Entra ID |
| Relying Party | RP | 信任 IdP,并消费身份结果 | FastAPI 应用 |
| Resource Server | RS | 接收 Access Token,提供受保护 API | FastAPI API 服务 |
| User Agent | UA | 承载跳转和 Cookie | 浏览器 |
| End User | --- | 被认证的自然人 | 系统用户 |
一个 FastAPI 服务可能同时扮演 RP 和 Resource Server。比如网页登录使用 OIDC,API 接口又负责校验调用方携带的 Access Token。
2. OAuth 2.0 和 OIDC 的关系
OAuth 2.0 是授权框架,不是完整的登录协议。
它主要表达这样的关系:
用户允许某个客户端,在限定范围内访问某些资源。
OIDC 在 OAuth 2.0 之上引入了:
openidscope- ID Token
- UserInfo Endpoint
- 标准用户声明,如
sub、name、email - 身份提供商发现机制
- 更明确的身份认证语义
因此,用 GitHub OAuth 获取用户资料可以拼出一套登录功能,但严格意义上的标准化 SSO,通常优先采用 OIDC。
3. Authorization Code Flow
OIDC Web 应用最常见的流程如下:
- FastAPI 生成登录请求。
- 浏览器跳转到 IdP 的 Authorization Endpoint。
- 用户在 IdP 完成登录。
- IdP 将浏览器重定向到 FastAPI 的回调地址。
- 回调地址收到一个短期、一次性的
code。 - FastAPI 后端调用 Token Endpoint,用
code换取 Token。 - FastAPI 验证 Token,识别用户并创建本地会话。
授权码不能直接访问业务 API,而且通常只能使用一次,生命周期也很短。
4. 几种 Token 的区别
| Token | 回答的问题 | 主要消费者 | 典型用途 |
|---|---|---|---|
| ID Token | 用户是谁,本次认证如何完成 | OIDC 客户端 | 建立登录身份 |
| Access Token | 持有者可以访问哪些资源 | Resource Server | 调用受保护 API |
| Refresh Token | 如何获得新的 Access Token | 客户端与 IdP | 延长访问能力 |
| Authorization Code | 如何安全地换取 Token | 客户端后端 | 登录回调中的临时凭证 |
这里最容易踩坑的一点是,不要拿 ID Token 当作 API Access Token 使用。
ID Token 的受众通常是完成登录的客户端,aud 往往对应 OIDC Client ID。Access Token 的受众应当是具体 API。两者都可能长得像 JWT,但用途完全不同。
5. JWT 与 Claims
OIDC 的 ID Token 通常采用 JWT 格式,由三部分组成:
text
header.payload.signature
常见声明包括:
iss,签发者,也就是 IdP 地址sub,该签发者范围内稳定的用户标识aud,Token 的目标接收方exp,过期时间iat,签发时间nonce,绑定本次浏览器认证请求auth_time,用户实际完成认证的时间acr,认证强度,例如是否经过 MFAazp,被授权的客户端
本地用户应当通过 iss + sub 进行关联,而不是只依赖邮箱。邮箱可能修改、复用,也未必完成验证;sub 才是 IdP 为该用户提供的稳定标识。
6. state、nonce 和 PKCE
这三个参数长得都像随机字符串,但防御的风险不同。
-
state将登录发起请求与回调绑定,主要防御登录 CSRF,也可承载经过保护的流程上下文。
-
nonce将 ID Token 与本次认证请求绑定,用来降低 Token 重放和响应注入风险。
-
PKCE
客户端先生成
code_verifier,再派生出code_challenge。回调换取 Token 时必须提交原始code_verifier,即使授权码被截获,攻击者也很难单独使用它。
PKCE 最初主要解决移动端和桌面端等公共客户端的授权码劫持问题,现在对于 Web 应用也属于推荐的纵深防御措施。
三、FastAPI 中的完整实现
Python 生态中,使用 Authlib 作为 OIDC 客户端是较成熟的做法。它能够读取 OIDC Discovery 文档,完成重定向、授权码交换、签名密钥发现以及 ID Token 解析。
1. 安装依赖
bash
pip install fastapi uvicorn authlib httpx itsdangerous
如果后续需要自行验证 JWT,还可以安装:
bash
pip install pyjwt cryptography
2. 在身份提供商中注册客户端
需要在 Keycloak、Okta 或其他 IdP 中创建一个 OIDC Client,并配置:
text
Client ID: fastapi-web
Client Type: confidential
Redirect URI: https://app.example.com/auth/callback
Post Logout Redirect URI: https://app.example.com/
Allowed Origin: https://app.example.com
生产环境应当精确登记回调地址,避免使用宽泛的通配符。
IdP 通常会提供 Discovery 地址:
text
https://id.example.com/realms/company/.well-known/openid-configuration
这个文档会告诉客户端:
- Authorization Endpoint 在哪里
- Token Endpoint 在哪里
- UserInfo Endpoint 在哪里
- JWKS 公钥地址在哪里
- 支持哪些签名算法
- 是否提供注销、撤销等能力
3. 基础代码
下面是一个通用 OIDC 客户端示例,适用于大多数支持标准发现文档的身份提供商。
python
import os
from urllib.parse import urlparse
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
app = FastAPI()
app.add_middleware(
SessionMiddleware
secret_key=os.environ["SESSION_SECRET"]
session_cookie="app_session"
max_age=8 * 60 * 60
same_site="lax"
https_only=True
)
oauth = OAuth()
oauth.register(
name="company_sso"
client_id=os.environ["OIDC_CLIENT_ID"]
client_secret=os.environ["OIDC_CLIENT_SECRET"]
server_metadata_url=os.environ["OIDC_DISCOVERY_URL"]
client_kwargs={
"scope": "openid profile email"
"code_challenge_method": "S256"
}
)
这里的 SessionMiddleware 不只用于保存登录用户。Authlib 在授权跳转过程中还需要临时保存 state 等上下文,以便在回调阶段完成校验。
4. 登录入口
python
def safe_next_url(value: str | None) -> str:
if not value:
return "/"
parsed = urlparse(value)
# 只允许站内相对地址,防止开放重定向
if parsed.scheme or parsed.netloc or not value.startswith("/"):
return "/"
return value
@app.get("/login")
async def login(request: Request, next: str = "/"):
request.session["login_next"] = safe_next_url(next)
redirect_uri = request.url_for("auth_callback")
return await oauth.company_sso.authorize_redirect(
request
redirect_uri
)
authorize_redirect() 会生成授权请求并把浏览器送往 IdP。具体版本和提供商配置不同,Authlib 可以负责生成及保存 state、nonce 和 PKCE 相关参数。
不要不加校验地将 next 放进回调跳转,否则攻击者可能构造:
text
/login?next=https://phishing.example.com
用户完成真实登录后,又被送往钓鱼网站。这类问题叫作 Open Redirect。
5. 登录回调
python
@app.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request):
try:
token = await oauth.company_sso.authorize_access_token(request)
except Exception as exc:
raise HTTPException(
status_code=401
detail="SSO 登录回调校验失败"
) from exc
userinfo = token.get("userinfo")
if not userinfo:
userinfo = await oauth.company_sso.userinfo(token=token)
issuer = userinfo.get("iss")
subject = userinfo.get("sub")
if not subject:
raise HTTPException(
status_code=401
detail="身份提供商没有返回 sub"
)
# 实际项目中应查询或创建本地用户
local_user = {
"id": f"{issuer}|{subject}"
"issuer": issuer
"subject": subject
"name": userinfo.get("name")
"email": userinfo.get("email")
"email_verified": userinfo.get("email_verified", False)
}
next_url = safe_next_url(request.session.get("login_next"))
# 清除授权阶段的临时状态,减少会话固定风险
request.session.clear()
# Cookie 中只保存最少量信息
request.session["user"] = {
"id": local_user["id"]
"name": local_user["name"]
}
return RedirectResponse(next_url, status_code=302)
生产系统中,回调阶段通常还要完成一系列本地业务处理:
- 按
iss + sub查询身份绑定记录 - 第一次登录时创建本地用户
- 同步昵称、头像和已验证邮箱
- 将 IdP Group 或 Role 映射成本地权限
- 检查用户是否被禁用
- 记录登录时间、IP、设备和认证方式
- 创建新的服务端会话
- 写入安全审计日志
不要把 IdP 返回的所有 Claims 都无条件写入数据库。身份数据会变化,而且其中可能存在应用不需要的敏感字段。
6. 保护页面
python
from fastapi import Depends
def require_login(request: Request) -> dict:
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=401
detail="尚未登录"
)
return user
@app.get("/me")
async def get_current_user(
user: dict = Depends(require_login)
):
return {
"authenticated": True
"user": user
}
@app.get("/dashboard")
async def dashboard(
user: dict = Depends(require_login)
):
return {
"message": f"欢迎回来,{user.get('name') or user['id']}"
}
这种模式适合传统 Web 应用或 BFF 架构。浏览器只保存会话 Cookie,Access Token 和 Refresh Token 不直接暴露给前端 JavaScript。
7. 注销
注销通常分为三个层次:
- 本地注销,只删除 FastAPI 应用会话。
- Token 撤销,让 Refresh Token 或 Access Token 失效。
- IdP 注销,结束身份提供商的统一登录会话。
只做本地注销后,用户再次点击登录,IdP 可能因为统一会话仍然存在而立刻把用户登录回来。这不是注销失败,而是只完成了局部退出。
python
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
response = JSONResponse(
{"message": "本地会话已退出"}
)
response.delete_cookie(
"app_session"
path="/"
)
return response
如果 IdP 的 Discovery 文档提供 end_session_endpoint,可以在清除本地会话后把浏览器重定向到该地址。具体参数需要按照提供商要求设置,常见参数包括:
id_token_hintpost_logout_redirect_uriclient_idstate
不能假设所有 IdP 都支持完全相同的单点注销机制。特别是在多个系统并存时,全局注销的传播方式和可靠性往往比登录复杂得多。
四、会话模式与 Bearer Token 模式
FastAPI 登录成功后,通常有两种身份维持方式。选哪一种,要看你的系统是服务网页,还是提供纯 API。
浏览器 Web 应用,优先采用 BFF 会话
浏览器登录成功后,只接收:
text
Set-Cookie: app_session=...
后端保存:
- 本地用户 ID
- IdP 会话关联信息
- Access Token
- Refresh Token
- Token 过期时间
前端 JavaScript 不直接读取 Token。
这种设计的优势很实际:
- Token 不容易被 XSS 脚本直接窃取
- 刷新 Token 的逻辑集中在后端
- 可在服务端主动废除会话
- 权限检查与审计更统一
不过,只要认证依赖 Cookie,就必须考虑 CSRF。修改数据的接口应使用:
- CSRF Token
SameSite=Lax或更严格的策略Origin或Referer校验- 非幂等操作只接受
POST、PUT、PATCH、DELETE
纯 API,采用 Bearer Access Token
移动端、CLI、第三方服务或独立 SPA 访问 API 时,通常发送:
http
Authorization: Bearer eyJ...
FastAPI API 需要验证:
- Token 签名
issaudexpnbf- 允许的签名算法
- scope、role 或 permission
下面展示核心结构:
python
import os
import jwt
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials
from fastapi.security import HTTPBearer
from jwt import PyJWKClient
security = HTTPBearer()
OIDC_ISSUER = os.environ["OIDC_ISSUER"]
OIDC_JWKS_URI = os.environ["OIDC_JWKS_URI"]
API_AUDIENCE = os.environ["OIDC_API_AUDIENCE"]
jwks_client = PyJWKClient(OIDC_JWKS_URI)
def verify_access_token(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> dict:
encoded_token = credentials.credentials
try:
signing_key = jwks_client.get_signing_key_from_jwt(
encoded_token
)
claims = jwt.decode(
encoded_token
signing_key.key
algorithms=["RS256"]
issuer=OIDC_ISSUER
audience=API_AUDIENCE
options={
"require": ["exp", "iss", "sub"]
}
)
return claims
except jwt.PyJWTError as exc:
raise HTTPException(
status_code=401
detail="Access Token 无效或已经过期"
headers={"WWW-Authenticate": "Bearer"}
) from exc
@app.get("/api/orders")
async def list_orders(
claims: dict = Depends(verify_access_token)
):
return {
"user": claims["sub"]
"orders": []
}
这里不能从 JWT Header 读取任意 alg 后直接接受。服务端应配置明确的算法白名单,例如只允许 RS256 或 IdP 实际使用的非对称算法。
同样不要把客户端的 Client ID 想当然地当成 API Audience。很多身份平台需要单独注册 API Resource,并为它配置独立的 Audience。
五、权限设计不等于登录设计
用户成功登录,只能说明身份通过了认证,并不代表他有权执行所有操作。
可以把认证与授权理解为两个连续问题:
- Authentication,你是谁
- Authorization,你能做什么
常见授权模型包括:
RBAC,基于角色
text
用户 → 角色 → 权限
例如:
text
alice → finance_manager → invoice:approve
适合组织结构和权限边界较稳定的企业系统。
Scope,基于访问范围
Access Token 中可能包含:
json
{
"scope": "openid profile orders:read orders:write"
}
FastAPI 可以检查某个接口是否要求 orders:write。
python
def require_scope(required_scope: str):
def checker(
claims: dict = Depends(verify_access_token)
):
scopes = set(claims.get("scope", "").split())
if required_scope not in scopes:
raise HTTPException(
status_code=403
detail="权限不足"
)
return claims
return checker
@app.post("/api/orders")
async def create_order(
claims: dict = Depends(require_scope("orders:write"))
):
return {"created_by": claims["sub"]}
IdP 权限和本地权限的取舍
完全依赖 IdP 中的角色,管理起来统一,但业务权限可能不断膨胀。完全使用本地权限,业务表达更灵活,却容易出现多个系统各管一套。
比较稳妥的办法是:
- IdP 管理身份、组织、基础组和粗粒度角色
- FastAPI 应用管理业务资源与细粒度权限
- 登录时同步必要的组和角色
- 每次访问关键资源时执行本地授权
- 不把 JWT 当成永久有效的权限快照
例如 IdP 只表达用户属于财务部门,而某张具体发票能不能审批,仍由业务系统根据金额、区域和审批链判断。
六、生产环境中最容易出问题的地方
代码跑通只是起点。SSO 真正的难处,往往藏在代理、Cookie、密钥轮换和多实例部署里。
1. 不要把敏感 Token 塞进普通 Session Cookie
Starlette 的 SessionMiddleware 使用签名保护 Cookie,可以检测篡改,但 Cookie 内容并不等同于加密存储。用户仍可能读取其中的数据。
因此不要在 Session Cookie 中直接保存:
- Access Token
- Refresh Token
- Client Secret
- 完整 ID Token
- 敏感用户资料
更稳妥的模式是:
text
浏览器 Cookie → 随机会话 ID → Redis/数据库中的会话记录
Token 保存在服务端,并设置静态加密、过期时间和吊销机制。
2. 多实例必须共享授权临时状态
登录开始时生成的 state、nonce、PKCE Verifier,需要在回调阶段读取。如果登录请求落在实例 A,回调落在实例 B,而两边不共享状态,就会随机出现回调失败。
解决方法包括:
- 使用 Redis 保存服务端会话
- 使用可靠的共享会话后端
- 临时使用负载均衡 Sticky Session,但不宜把它当成长久方案
- 所有实例使用一致的 Session 签名密钥
3. 反向代理必须正确传递协议
若外部地址是:
text
https://app.example.com
但 FastAPI 在代理后看到的是:
text
http://127.0.0.1:8000
那么 request.url_for() 可能生成错误回调地址。需要正确配置:
X-Forwarded-ProtoX-Forwarded-Host- 可信代理列表
- Uvicorn 或部署平台的代理头处理
- IdP 中精确登记的外部 HTTPS 回调地址
不要无条件信任来自任意客户端的转发头,否则攻击者可能影响生成的绝对 URL。
4. 必须验证的 Token 属性
不应只验证 JWT 签名。至少还要检查:
iss是否为预期 IdPaud是否包含当前客户端或 APIexp是否过期nbf是否已经生效- 签名算法是否位于固定白名单
nonce是否匹配当前认证请求azp在多 Audience 情况下是否合理- JWKS Key ID 是否能在可信密钥集中找到
可以允许几十秒的时钟偏差,但不能因为服务器时间混乱而大幅放宽过期判断。所有服务器都应同步 NTP。
5. Refresh Token 需要像密码一样保护
Refresh Token 往往活得更久,一旦泄露,攻击者可以持续换取新的 Access Token。
生产中应考虑:
- Refresh Token Rotation
- 重用检测
- 服务端加密存储
- 最长绝对会话期限
- 空闲会话期限
- 用户停用时主动吊销
- 密码修改或风险事件后使会话失效
6. 错误日志不能泄露凭证
下面这类日志应当禁止:
python
logger.info("OIDC token response: %s", token)
日志中不应出现:
- Authorization Header
- Access Token
- Refresh Token
- Authorization Code
- Client Secret
- Session Cookie
可以记录 iss、sub 的脱敏值、Client ID、错误类型、请求 ID、登录时间和结果。
7. SSO 依赖的可用性问题
统一身份提供商一旦故障,所有新登录都可能停止。生产架构应明确:
- 已建立的本地会话能否继续使用
- JWKS 是否允许安全缓存
- IdP 短暂故障时是否重试
- 管理员是否有受控的紧急访问账号
- Discovery 和 JWKS 的超时设置
- 密钥轮换期间如何同时接受新旧公钥
不要在每一个 API 请求中实时访问 IdP 校验 JWT。JWT 的意义之一,就是 Resource Server 可以利用缓存的公钥进行本地验证。
七、一套推荐的落地架构
对于大多数 FastAPI 企业应用,可以采用下面的组合:
推荐策略可以浓缩为以下几条:
- 使用 OIDC,而不是自己发明登录协议
- Web 应用使用 Authorization Code Flow
- 即使是机密客户端,也启用 PKCE
- 浏览器只保存
HttpOnly + Secure会话 Cookie - Token 存放在服务端 Redis 或数据库
- 用户身份通过
iss + sub绑定 - ID Token 只用于身份认证,不作为业务 API 凭证
- API 只接受面向自身 Audience 的 Access Token
- 同时验证签名、Issuer、Audience 和有效期
state防登录 CSRF,nonce绑定 ID Token,PKCE 防授权码劫持- 权限检查放在服务端,不能只相信前端按钮是否可见
- 本地注销、Token 撤销和 IdP 注销分别设计
- 对 Token 刷新、密钥轮换、IdP 故障和多实例状态提前做预案
FastAPI 做 SSO 的本质,并不是写一个 /login 接口,而是把外部身份认证、本地用户映射、会话生命周期、Token 验证和业务授权连成一条可信链路。协议负责证明身份,FastAPI 负责把这个身份安全地转化成应用中的用户与权限。边界划清之后,系统会比自建用户名密码登录更统一,也更容易接入 MFA、企业目录和集中审计。
参考资料
- OpenID Foundation, OpenID Connect Core 1.0 , openid.net/specs/openi...
- IETF, RFC 6749 --- The OAuth 2.0 Authorization Framework , www.rfc-editor.org/rfc/rfc6749
- Authlib, FastAPI OAuth Client , docs.authlib.org/en/v1.6.4/c...
- IETF, RFC 7636 --- Proof Key for Code Exchange by OAuth Public Clients , www.rfc-editor.org/rfc/rfc7636
- IETF, RFC 7519 --- JSON Web Token , www.rfc-editor.org/rfc/rfc7519
- Starlette, Session Middleware , www.starlette.io/middleware/...
- OpenID Foundation, RP-Initiated Logout 1.0 , openid.net/specs/openi...
- FastAPI, Security Documentation , fastapi.tiangolo.com/tutorial/se...
- IETF, RFC 8725 --- JSON Web Token Best Current Practices , www.rfc-editor.org/rfc/rfc8725
- OWASP, OAuth 2.0 Protocol Cheat Sheet , cheatsheetseries.owasp.org/cheatsheets...