一篇讲清懒猫 SSO:从 /sys/oauth/auth 到 ID Token 校验与常见报错

忘机山人

发布于27天前
博客图片修整中,看不了可以先搜索公众号“忘机山人”看。
> 懒猫 SSO(懒猫微服自带的 OIDC 身份服务)并不是懒猫微服给你贴的“登录按钮”,
> 而是一套 **在你自己的家庭服务器这种“半可信环境”中,为所有应用建立统一身份信任的工程流程**。
>
> 如果你只记住懒猫帮你注入了哪几个环境变量,却不理解每一步在防什么,
> 这套 SSO 会“看起来能跑”,但迟早会在回调地址、token 校验、多用户等边界条件上出问题。



https://appstore.lazycat.cloud/#/shop/detail/xu.deploy.env



这篇文章从**端到端流程**出发,基于懒猫微服实际暴露的懒猫 SSO 端点,完整梳理:

* 懒猫 SSO 里的角色与核心概念(Client = 你的懒猫 App / IdP = 懒猫 SSO / User = 懒猫账号)
* `lzc-manifest.yml` 注入的那几个环境变量到底对应 OIDC 的哪些字段
* Authorization Code + PKCE 在懒猫里的完整时序
* scope、JWT / JWS、换 token、claims 校验
* 最常见、也最真实的报错与排查思路

下面所有例子都围绕同一个真实应用展开——**懒猫 ENV 查看器(`xu.deploy.env`)**,部署在一个叫 `alice` 的盒子上,对外域名是 `alice.heiyu.space`,回调路径是 `/callback`。


## 1. 懒猫 SSO 的角色与核心概念(速读版)

在任何基于懒猫 SSO 的应用里,都至少有这三类角色:

* **Client**:你的懒猫 App
  * 后端 Web / 有独立容器的应用 —— Confidential Client(`client_secret` 由懒猫注入到容器环境变量)
  * 纯前端 SPA —— 严格意义上是 Public Client,但因为懒猫把 `CLIENT_SECRET` 也注入进来了,在实操中介于两者之间(后面会专门讲这件事的边界)
* **OP / IdP(OpenID Provider)**:懒猫 SSO,盒子上自带的身份服务
  * 入口永远是 `https://.heiyu.space/sys/oauth`
* **User**:懒猫账号(盒子主人 + 被邀请的家庭成员)

几组**必须分清的概念**:

* **ID Token**:身份声明(你是谁,是不是盒子主人,邮箱多少)
* **Access Token**:访问资源的授权凭证(能调哪些 API)
* **Refresh Token**:用来续签,长生命周期,必须有 `offline_access` scope 才会下发
* **Scope**:Client 请求的授权范围(懒猫支持 `openid email groups profile offline_access`)
* **Claims**:token 里的具体声明字段(懒猫 SSO 声明支持 `iss sub aud iat exp email email_verified locale name preferred_username at_hash`)
* **JWT / JWS**:
  * JWT 是格式
  * JWS 是“签名后的 JWT”(懒猫 ID Token 的常态,签名算法是 `RS256`)

一句话记忆:

> **懒猫 SSO = OAuth 2.0 + 身份层(ID Token)+ 懒猫替你注入的 Client 凭证**


## 2. 端到端主流程(Authorization Code + PKCE)

懒猫 SSO discovery 里明确写了 `"response_types_supported": ["code"]`,
也就是说——**在懒猫上,除了授权码模式,别无选择**。

这也是现在最推荐、最安全的一种模式,适用于 Web、SPA、家庭内网应用。

我们按真实时序走一遍。


### Step 1:Discovery(发现懒猫 SSO 的能力)

容器启动时,或应用首次处理登录时,会访问:

```
GET https://alice.heiyu.space/sys/oauth/.well-known/openid-configuration
```

得到一份元数据,核心字段是这些(这段是**实际返回**,不是示意):

```json
{
  "issuer": "https://alice.heiyu.space/sys/oauth",
  "authorization_endpoint": "https://alice.heiyu.space/sys/oauth/auth",
  "token_endpoint": "https://alice.heiyu.space/sys/oauth/token",
  "jwks_uri": "https://alice.heiyu.space/sys/oauth/keys",
  "userinfo_endpoint": "https://alice.heiyu.space/sys/oauth/userinfo",
  "device_authorization_endpoint": "https://alice.heiyu.space/sys/oauth/device/code",
  "introspection_endpoint": "https://alice.heiyu.space/sys/oauth/token/introspect",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code",
    "urn:ietf:params:oauth:grant-type:token-exchange"
  ],
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256", "plain"],
  "scopes_supported": [
    "openid", "email", "groups", "profile", "offline_access"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic", "client_secret_post"
  ],
  "claims_supported": [
    "iss", "sub", "aud", "iat", "exp",
    "email", "email_verified", "locale",
    "name", "preferred_username", "at_hash"
  ]
}
```

**非常重要的一点**:

> 后续所有校验,都会以这里返回的 `issuer` 为“信任锚点”。

在懒猫里,`issuer` 的规则是:

```
https://.heiyu.space/sys/oauth
```

**常见坑**:

* 把 `issuer` 配成 `https://alice.heiyu.space/sys/oauth/`(尾部多斜杠)→ 后面 `iss` 校验直接失败
* 把 `issuer` 写成 `https://alice.heiyu.space/`(漏掉 `/sys/oauth`)→ discovery 拿不到
* 应用跑在容器里,**别**去硬编码域名,而是用懒猫注入的 `LAZYCAT_AUTH_OIDC_ISSUER_URI`

---

### Step 2:/sys/oauth/auth(浏览器跳转)

这是用户“看到懒猫授权页面”的那一步。

一个典型的跳转 URL(由 Client 拼出来,让浏览器跳过去):

```
GET https://alice.heiyu.space/sys/oauth/auth?
  response_type=code
  &client_id=xu.deploy.env
  &redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback
  &scope=openid%20email%20profile
  &state=...
  &nonce=...
  &code_challenge=...
  &code_challenge_method=S256
```

关键点说明:

* `client_id`
  * 懒猫里 **等于应用包名**,比如 `xu.deploy.env`。容器里从 `LAZYCAT_AUTH_OIDC_CLIENT_ID` 读
* `redirect_uri`
  * 由 `lzc-manifest.yml` 里的 `application.oidc_redirect_path` 决定,懒猫会把它拼成 `https://..heiyu.space/`
* `openid` scope
  * **必须有**,否则退化成纯 OAuth,懒猫 SSO 不会下发 ID Token
* `state` / `nonce`
  * CSRF + 重放防护,两者都是 Client 自己生成、自己校验
* `code_challenge`
  * PKCE 的 public 半(后面第三章详细讲)

这一跳之后,浏览器会被懒猫 SSO 接管,用户看到的是懒猫那套统一的“登录 / 授权确认”页面(Grant Access)。

**常见报错**:

* `invalid_redirect_uri`
  * `redirect_uri` 不在白名单 —— 在懒猫里,白名单是用 `oidc_redirect_path` 隐式注册的,你没在 manifest 里写,就不会被允许
* `invalid_scope`
  * 忘了 openid,或请求了 `scopes_supported` 之外的 scope(比如自己脑补的 `admin`)
* `unauthorized_client`
  * 应用在懒猫上根本没启用 OIDC(manifest 里没写 `oidc_redirect_path`)

---

### Step 3:回调(拿到 code)

Grant Access 完成后,浏览器被重定向回:

```
https://env.alice.heiyu.space/callback?code=abc123&state=xyz
```

Client 必须做的第一件事:

* **校验 `state`**(对上第 2 步生成的那个,对不上直接拒绝,不要进入 /token)

**常见情况**:

* `access_denied`
  * 用户点了拒绝
* `login_required` / `interaction_required`
  * 懒猫要求重新登录(比如 session 过期、换用户)

---

### Step 4:/sys/oauth/token(换 token)

这是 **Client 容器与懒猫 SSO 的直连请求**(从用户浏览器看不到)。

```http
POST https://alice.heiyu.space/sys/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)     # client_secret_basic

grant_type=authorization_code
&code=abc123
&redirect_uri=https%3A%2F%2Fenv.alice.heiyu.space%2Fcallback
&code_verifier=zzz
```

其中:

* `client_id` = `LAZYCAT_AUTH_OIDC_CLIENT_ID`
* `client_secret` = `LAZYCAT_AUTH_OIDC_CLIENT_SECRET`
* 懒猫 SSO 支持 `client_secret_basic` 和 `client_secret_post` 两种方式,任选

**PKCE 校验发生在这里**:

```
SHA256(code_verifier) == bound_challenge ?
```

返回体典型形态:

```json
{
  "access_token": "eyJ...",
  "id_token": "eyJ...",
  "refresh_token": "...",         // 只有带了 offline_access 才会有
  "token_type": "bearer",
  "expires_in": 86400
}
```

**最常见报错(真实世界第一名)**:

* `invalid_grant`
  * code 过期(懒猫 SSO 默认几十秒)
  * code 已用过(授权码是**一次性**的)
  * redirect_uri 和第 2 步不一致(字符级精确匹配,包括结尾斜杠)
  * `code_verifier` 不匹配

👉 **90% 的懒猫 SSO 排障时间,都花在这里**

---

## 3. Token 到手以后:你必须做的校验

拿到 token ≠ 可以直接 `jwt.decode(id_token)["email"]` 然后登录用户。

### 3.1 验签(JWS)

ID Token 是 JWS,结构是:

```
header.payload.signature
```

验签流程是固定的:

1. 从 header 取 `alg`(懒猫固定 `RS256`)、`kid`
2. 从 `jwks_uri`(`https://alice.heiyu.space/sys/oauth/keys`)拉 JWKS
3. 用 `kid` 找到公钥
4. 验证签名

**必须注意的反模式**:

* ❌ 信任 token header 里的 `jku` / `x5u`(永远从 discovery 绑定的 `jwks_uri` 去拿)
* ❌ 接受 `alg=none`
* ❌ 用 `LAZYCAT_AUTH_OIDC_ISSUER_URI` 以外的地方“凑”出 jwks 地址

---

### 3.2 Claims 校验(ID Token Validation)

这是懒猫 SSO **最核心、也最容易被省略的一步**。

**必须校验的 claims**:

* `iss`
  * 必须等于 `https://alice.heiyu.space/sys/oauth`(即 `LAZYCAT_AUTH_OIDC_ISSUER_URI`)
* `aud`
  * 必须包含 `LAZYCAT_AUTH_OIDC_CLIENT_ID`(也就是你的 App 包名,如 `xu.deploy.env`)
* `exp`
  * 当前时间  **签名验证通过,只能说明 token 是懒猫 SSO 发的,
> 不能说明 token 现在可以被你用。**

---

## 4. Scope 到底在干什么?

Scope 经常被误解成“权限”。在懒猫里,它真正的语义是:

> **Client 向懒猫 SSO 声明的授权意图范围**

懒猫 `scopes_supported` 就 5 个:

* `openid`
  * 表示“我要做身份认证”,没有它就退化成纯 OAuth
* `email`
  * 允许返回 `email`、`email_verified`
* `profile`
  * 允许返回 `name`、`preferred_username`、`locale`
* `groups`
  * 懒猫最有价值的一个 —— 能拿到用户属于哪些分组(比如“主账号”“家庭成员”),用来做业务层的角色判断
* `offline_access`
  * 允许下发 `refresh_token`

**重要澄清**:

* Scope ≠ 懒猫里的“用户角色”
* Scope ≠ 业务权限

正确做法是:

```
Scope → claims(特别是 groups / preferred_username) → 你自己的业务权限判断
```

---

## 5. 各种授权模式怎么选?

在懒猫里这张表其实很短,因为懒猫 SSO 已经替你做了决定:

| 模式                        | 在懒猫里能不能用          | 用途                 |
| ------------------------- | ----------------- | ------------------ |
| Authorization Code + PKCE | ✅ 默认              | Web / SPA / 内置应用    |
| Client Credentials        | ❌ 懒猫 SSO 没在 `grant_types_supported` 里开 | —                  |
| Device Code               | ✅ 有 `device_authorization_endpoint` | CLI / 智慧屏这类无浏览器场景  |
| Refresh Token             | ✅ 需要 `offline_access` | 会话续期               |
| Token Exchange            | ✅ 高级用法            | 跨应用换 token         |
| Implicit                  | ❌                | 已过时                |

**经验法则**:

> 在懒猫上,**默认就选 Authorization Code + PKCE**。其他模式有特殊需求再上。

---

## 6. 端到端常见报错与排查思路

### `invalid_grant`

* 优先检查:
  * `redirect_uri` 是否和第 2 步**字符级一致**(尤其是端口、尾部斜杠、大小写)
  * code 是否被用过(很多人在 callback 路由上写了重试逻辑,第二次触发就一定 400)
  * PKCE verifier 是否跨 session 丢了(典型场景:callback 用了不同的进程 / cookie 丢了)

### `invalid_client`

* `LAZYCAT_AUTH_OIDC_CLIENT_SECRET` 没读对(经常是容器没拿到环境变量)
* client 认证方式错了:懒猫只支持 `client_secret_basic` 和 `client_secret_post`,别用 `none`

### `invalid_scope`

* 忘了 openid
* 请求了 `scopes_supported` 之外的 scope

### `unauthorized_client`

* `lzc-manifest.yml` 里根本没写 `application.oidc_redirect_path`,懒猫没有为你这个包名注册 client

### `login_required` / `interaction_required`

* 懒猫 session 过期、用户被切换,需要重新跳一次 `/sys/oauth/auth`

---

## 7. 一句话总结懒猫 SSO 的工程本质

如果你一路走完,会发现:

* PKCE 防的是 **code 被偷**
* 签名(JWKS)防的是 **token 被伪造**
* `iss` / `aud` / `nonce` 防的是 **token 被误用**
* Scope 控制的是 **授权边界**
* `lzc-manifest.yml` 里那一行 `oidc_redirect_path`,
  是你**加入懒猫信任链的唯一开关**

懒猫 SSO 的安全性,不在某一个字段,
而在**一整条不可跳过的验证链**。

---

## 结语

> **懒猫 SSO 从来没有承诺“帮你搞定登录”,
> 它只是把懒猫 SSO 封装成了“不用你填 client 表单”的形态,
> 给了你一套:
> 在家庭服务器这种半可信环境里,
> 逐步建立有限信任的工程方法。**

只要你能回答:

> “我为什么在这里相信这个懒猫账号?”

你就已经在**正确地使用懒猫 SSO**了。


后面九章,会把这张流程图拆开:

* 第一章:一次“看起来很普通”的懒猫登录
* 第二章:为什么 Client 不能信懒猫“返回结果”
* 第三章:PKCE 在懒猫场景下为什么不能省
* 第四章:ID Token 是声明,不是加密
* 第五章:签名——Client 凭什么相信懒猫的声明
* 第六章:JWKS——`/sys/oauth/keys` 到底在干什么
* 第七章:ID Token Validation——签名通过还不够
* 第八章:SDK 边界——懒猫注入的变量只是地基
* 第九章:Scope 与 Consent——懒猫的 `groups` 不是你的权限
* 第十章:失败案例与设计哲学

附录 A、B:Scope 在流程中的位置;state / nonce 对照。

![image.png](https://dl.playground.lazycat.cloud/guidelines/459/f495eb01-e6c6-4594-a71f-df698c8148a8.png "image.png")

评论

0

暂无评论

说点什么呢~
收藏
0
0
0