No description
  • TypeScript 52.9%
  • Rust 25.8%
  • Python 20.7%
  • CSS 0.3%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-10 13:36:28 +08:00
auth-web fix(web): render styled 404 pages for unknown UI routes 2026-10-10 13:26:43 +08:00
docs fix(web): render styled 404 pages for unknown UI routes 2026-10-10 13:26:43 +08:00
edge feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
hydra fix(oauth): revalidate offline grants with current account permissions 2026-10-08 08:02:48 +08:00
kratos feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
postgres feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
tests feat(oauth): add self-service authorization management and lasting consent 2026-10-10 07:32:24 +08:00
web feat(web): add registration links with prefilled invite codes 2026-10-10 13:36:28 +08:00
.dockerignore feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
.env.example fix(oauth): revalidate offline grants with current account permissions 2026-10-08 08:02:48 +08:00
.envrc feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
.gitignore feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
.muxrun.toml feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
CONTEXT.md feat: add OIDC app management and improve passkey workflows 2026-10-06 20:14:37 +08:00
devenv.lock feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
devenv.nix feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
devenv.yaml feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
docker-compose.yml fix(oauth): revalidate offline grants with current account permissions 2026-10-08 08:02:48 +08:00
LICENSE feat: publish authentication gateway 2026-10-06 16:30:06 +08:00
README.md feat: add dynamic groups and OIDC application icons 2026-10-08 00:10:36 +08:00

gjz010/auth

gjz010/auth 是一个可让用户用一个账号登录多个服务(OIDC 接入)的鉴权网关。身份与会话由 Ory Kratos 负责,OIDC 由 Ory Hydra 负责;auth-web(Rust/Axum)只做薄协调层与邀请/ 管理逻辑,React 前端提供界面,均不自行实现密码学原语。

功能

  • 签名初始化首个 root:站主用离线 OpenPGP 私钥签署一次性 challenge 来创建首个管理员。 库内不提供任何站主密钥,也不接受从网络自动拉取公钥。
  • 邀请制注册:root 创建邀请码,受邀者以“用户名 + 密码 + 邀请码”注册。
  • 多凭据登录:密码与 passkey(WebAuthn discoverable)并存;同一身份可添加/移除多把 passkey。
  • 资料 / 会话 / 恢复:显示名与密码设置;中央会话列表与撤销;无邮件场景下的管理员恢复 链接。
  • OIDC 接入:为下游应用(如 Forgejo)签发 OIDC 身份;groups claim 输出所有有效普通组,root 永不出现在 token/UserInfo。
  • 管理员:查看/禁用/启用用户、管理动态群组(创建、描述与继承关系)与邀请码,并从网页创建 OIDC 应用,以及维护面向用户的独立应用程序入口。

架构

   浏览器 / 接入应用
          │ HTTPS(唯一入口)
          ▼
   Cloudflare Tunnel ──► edge (Caddy)
                            ├─► auth-web (Rust/Axum) ─┐
                            └─► Kratos / Hydra ────────┤
                                                        ▼
                                                   PostgreSQL

生产 docker-compose.yml 不发布任何 host port:唯一入口是 Cloudflare Tunnel → edge:8080。Kratos/Hydra Admin、PostgreSQL 与 auth-web 的 /internal/* 都不对公网暴露。

部署前置

  • Docker Engine + Docker Compose。凭据文件叠加层用到较新的 override 合并语义,建议保持 Compose 较新版本。
  • OpenSSL(生成随机密钥)。
  • 一个 Cloudflare 账号与 Tunnel;把 PUBLIC_ORIGIN 的主机名 ingress 指向 http://edge:8080。HTTPS 由 Cloudflare 终结。
  • 若要初始化首个 root,还需要你自己的 OpenPGP 密钥对(公钥用于配置,私钥离线保留)。

配置 .env

cp .env.example .env
chmod 600 .env

.env 已被 .gitignore 忽略,绝不提交。至少填写:

  • PUBLIC_ORIGIN=https://auth.example.com:不带结尾斜杠;Kratos/Hydra 的本服务 URL (issuer、login/consent/return URL 等)从它派生。下游集成的域名另见 OIDC 一节,不都由它 自动覆盖。
  • PASSKEY_RP_ID=auth.example.com:裸主机名(不带 scheme/路径/端口),必须等于 PUBLIC_ORIGIN 的主机名。两者必须成对修改。
  • CLOUDFLARE_TUNNEL_TOKEN:token 模式下从 Cloudflare Dashboard 获取;凭据文件模式下留空。
  • FIRST_SETUP_PUBLIC_KEY_PATH:使用 edge/compose.first-setup.yml 初始化首个 root 时 必填,指向你自己的 OpenPGP 公钥文件(放在仓库之外)。写进 .env,或先 export 再运行 compose;Compose 只对已导出的 shell 变量插值,普通赋值不会被读到。

用 OpenSSL 生成随机密钥并私下写入 .env:

openssl rand -hex 32   # POSTGRES_PASSWORD / KRATOS_DB_PASSWORD / HYDRA_DB_PASSWORD /
                       # AUTH_WEB_DB_PASSWORD / KRATOS_COOKIE_SECRET / WEBHOOK_SECRET /
                       # HYDRA_SYSTEM_SECRET
openssl rand -hex 16   # KRATOS_CIPHER_SECRET(必须恰好 32 字符)

数据库密码会被直接插值进 DSN,只能使用 URL 安全字符(建议十六进制随机串)。docker compose up 对必需变量使用 ${VAR:?} 快速失败。FORGEJO_OIDC_CLIENT_SECRET 只服务于旧 bootstrap-client CLI,网页创建应用不再需要。

启动

Tunnel 有两种模式,二选一;第一次启动还必须叠加签名初始化层,因此请先按上一节设置 FIRST_SETUP_PUBLIC_KEY_PATH(否则叠加层会因该变量缺失而拒绝启动)。

token 模式(remotely-managed Tunnel):

docker compose -f docker-compose.yml -f edge/compose.first-setup.yml up -d --build

在 Cloudflare Dashboard 把主机名指向 http://edge:8080,并把 token 填入 .env 的 CLOUDFLARE_TUNNEL_TOKEN。

本地凭据文件模式(不使用 token):

export CLOUDFLARE_TUNNEL_ID=<tunnel-uuid>
export CLOUDFLARE_CREDENTIALS_FILE=/absolute/path/outside/repo/<tunnel-uuid>.json
docker compose -f docker-compose.yml -f edge/compose.first-setup.yml \
  -f edge/compose.cloudflared-credentials.yml up -d --build

凭据 JSON 是高价值 secret:放在仓库之外并保持只读。凭据叠加层以固定的 user 运行 cloudflared;请把该 user 调整为凭据 JSON 的实际所有者,而不是放宽文件权限。首个 root 创建完成后,日常运行去掉 first-setup 叠加层即可;凭据文件模式仍需保留凭据叠加层。

初始化首个 root

root 是授权角色/组名,不是用户名;首个 root 的用户名由你在 setup 页自选。

  1. 准备你自己的 OpenPGP 公钥:把用于签名的密钥公钥导出到仓库外,并按上一节把 FIRST_SETUP_PUBLIC_KEY_PATH 指向该文件(写进 .env 或先 export)。私钥只留在本机, 不要提供给部署环境:

    gpg --armor --export <your-fingerprint> > /absolute/path/outside/repo/public-key.asc
    
  2. 用上面的启动命令(含 edge/compose.first-setup.yml)启动,然后打开 https://auth.example.com/setup。页面会显示所配置公钥的完整指纹,请先核对。

  3. 填写你自选的用户名,点“获取 challenge”,用页面下载按钮保存 challenge.txt。challenge 是精确 UTF-8 且带末尾换行;不要把它复制成 shell 变量或用 printf/命令替换转写,否则 会丢失换行并导致验签失败。

  4. 在本机对下载下来的文件做 detached ASCII 签名:

    gpg --armor --detach-sign \
      --local-user <your-fingerprint> \
      --output challenge.asc challenge.txt
    

    主密钥仅具认证(cert-only)能力时,gpg 会自动选用合法签名子密钥;用主密钥完整指纹即可。

  5. 打开 challenge.asc,把内容粘贴进页面的 signature 输入框,并在页面设置你自己的密码: 初始化 root 至少 10 个字符(该 UI 强制;Kratos 本身默认策略最短 8 位)。密码随这次 HTTPS 请求交给 Kratos,由 Kratos 负责存储与校验;私钥始终留在本机,不会上传。

  6. 提交成功后用该用户名与密码登录。初始化完成后移除 edge/compose.first-setup.yml 叠加层; 完成状态持久在数据库。

关于 pending:若 Kratos 在创建身份时失败或超时,状态会保留为 pending,不会自动 重试或释放。请人工核对 Kratos 中是否已创建该身份后再处理,不要反复重建数据库或开放公开 reset。这条约束用于保证“首个管理员只能创建一次”,请按通用安全原则谨慎对待。

日常操作

  • passkey:登录页可直接用 discoverable passkey 登录;在安全页可新增、移除或重命名自己的多把 passkey。自定义名称仅用于展示,保存在本人 Kratos identity 的 metadata_admin.passkey_aliases (键为与 passkey_remove 节点一致的凭据 ID 十六进制字符串),不写入或改动任何凭据材料、不新增 数据库表,也只对本人的现存凭据生效。
  • 会话:/sessions 列出中央会话并可撤销。撤销中央会话不等于撤销接入应用内的会话。
  • 恢复(无邮件):在 /admin/users 或 CLI recovery-link 为忘记密码的用户生成恢复 链接。链接有效期 15 分钟;请先带外核实用户身份,只经可信渠道交付,不写入日志。首发不需要 SMTP。
  • 邀请:root 在 /admin/invites 创建/停用邀请码,并可配置注册后授予的直接群组。群组配置仅影响之后开始注册的用户;已开始注册和已注册用户不变。CLI 子命令与入参见 docs/API.md。

OIDC 接入(OIDC 应用管理)

新用户接入下游应用全部通过网页完成,不需要改动源码。以 root 登录后进入 管理 → OIDC 应用 (/admin/apps)并点「新建应用」。这里管理的是用于登录的 OIDC 客户端,与上节独立启动器条目 (/admin/applications)是两套互不绑定的记录:

  1. 选模板。Forgejo:填站点 URL(如 https://git.example.com)与认证源名(默认 gjz010),Client ID 默认 forgejo;系统自动生成回调 https://git.example.com/user/oauth2/gjz010/callback。通用 OIDC:每行一个填写回调 地址。
  2. 创建成功后页面只显示一次 client_secret,并列出 Client ID、Issuer、Discovery 与 scopes;把它们复制到第三方管理页,关闭后无法再次查看。

Forgejo 侧认证源字段对照:

Forgejo 字段 值
认证类型 / 提供方 OAuth2 / OpenID Connect
源名 认证源名(默认 gjz010)
Client ID 应用页显示的 Client ID(默认 forgejo)
Client Secret 创建/重置时一次性显示的 secret
Discovery / Issuer 应用页显示(默认 https://auth.example.com)
Scopes openid profile email groups
  • 高级选项:scopes 固定为 openid profile email groups(openid 必选);访问范围为 all (全部用户)或指定组,指定组从群组目录选择,属于任一所选组即可(OR 匹配)。Forgejo 模板 默认指定组(需从目录选择),通用模板默认 all,均可修改。
  • 下游认证请求经 PUBLIC_ORIGIN(默认 https://auth.example.com)公网发起;Hydra Admin 只在 backend 网络内,永不外露。登录/同意时后端每次从 Hydra 现客户端重读策略,不缓存旧值。
  • 编辑不改 secret;「重置密钥」输入 Client ID 确认后生成新 secret,旧 secret 立即失效, 第三方须同步更新。删除应用停止后续认证,但不保证终止第三方已建立的会话。
  • 图标:默认(名称首字母)、外部 URL 或上传,与独立启动器的三择一致,并在 OIDC 授权同意页展示。 创建时会先保存应用再上传图标:上传失败仍保留已创建的 Client ID 与一次性 secret,可在创建成功 面板内只重试图标(不重复创建、不轮换 secret)。编辑时选「默认」清除、选「外部 URL」替换、保留 已上传的托管图标则在提交时省略该字段,绝不把本站托管地址当外部 URL 回发。上传需后端配置对象 存储;未配置时上传控件禁用,外部 URL 与默认仍可用。
  • 修改策略或 scopes 会立即影响后续与尚未完成的授权(包括已记住的 consent)。
  • /admin/apps 页面与 API 只允许 root,后端每个请求独立校验管理员;仅支持服务端 confidential 应用(client_secret_basic / client_secret_post),不含 SPA/public client。 旧 bootstrap-client CLI 仅兼容保留在 docs/API.md。

派生邮箱域由后端环境变量 DERIVED_EMAIL_DOMAIN 配置(默认 users.example.com,只接受 裸 DNS 域名,拒绝 scheme、@、路径与空白)。后端在 /api/me、管理端用户视图与 OIDC email claim 中共用该配置;前端直接显示服务端返回的 email,不再本地拼接。

应用程序入口(独立启动器)

/applications 是面向用户的独立服务入口列表,与 OIDC 客户端不绑定、也不一一对应:它只是 站主配置的名称、URL 与图标,点击后由浏览器直接以新标签页打开,不发起 OAuth/OIDC 授权,也不 自动登录目标站。用户只会看到后端按其身份过滤后可见的条目;可见性由后端决定,前端不重新判断。

  • 管理:root 在 管理 → 应用程序(/admin/applications)创建、编辑或删除。列表内用对话框 完成,没有单独详情页。
  • 字段:名称(1–120 字符)、服务 URL(必须 https,本机回环可用 http,允许 query/hash,不接受 user:pass@)、可见范围(全部用户,或从群组目录任选一个及以上,OR 匹配)。
  • 图标:默认、外部 URL 或上传。上传需后端配置对象存储;未配置时上传控件禁用,外部 URL 与默认 仍可用,已托管的图标可保留或移除。上传只接受 PNG/JPEG/WebP 且 ≤ 2 MiB,成功后由本站同源 地址提供,浏览器不直连对象存储。
  • 该页面与 /api/applications、/api/admin/applications 只允许已登录用户;管理端每个请求 仍独立校验 root。可见性只决定入口是否显示,不代表目标服务已授权或会接受该用户。

头像

用户可在账户设置页上传、更换或删除自己的头像。原图不直接公开:auth-web 解码后统一 转为 PNG(512×512 外接框内等比缩放、最多 512 边长,只接受 PNG/JPEG/WebP,拒绝 SVG/GIF/ 伪造内容),重编码丢弃 EXIF 等元数据,存入一个私有 S3 兼容 bucket,再由 auth-web 自己 的同源网关按当前指针提供给浏览器。浏览器永远拿不到 bucket 凭据,也不存在公开 bucket 或指向对象存储的直链。

  • 配置全部通过环境变量。六个必填项:AVATAR_S3_ENDPOINT、AVATAR_S3_REGION、 AVATAR_S3_BUCKET、AVATAR_S3_PREFIX、AVATAR_S3_ACCESS_KEY_ID、 AVATAR_S3_SECRET_ACCESS_KEY;可选 AVATAR_S3_FORCE_PATH_STYLE(默认 true)。
  • 全有或全无:六个必填值为空即禁用(GET /api/me/avatar 返回 {enabled:false,picture:null},上传/删除返回 503 avatar_unavailable);只设置了其中 一部分则服务启动失败,不静默降级。FORCE_PATH_STYLE 的默认值不算作已配置。 端点必须 HTTPS;仅回环地址允许 HTTP,供隔离的本地测试使用,没有生产 insecure 开关。
  • 可复用共享 bucket:AVATAR_S3_PREFIX 指定本应用在 bucket 内独占的相对前缀(首尾斜杠会 被归一化,拒绝空、./.. 路径段与控制字符,常规 B2 前缀如 team/app 都可用)。请创建一个 只对该前缀具 putObject / getObject / deleteObject 权限的受限 key(例如 Backblaze B2 应用密钥),不必授予整桶权限,也不必强制单独 bucket。不要把 key 写进仓库;本仓库不预置 任何个人部署值。
  • 指针是 Kratos identity 的 metadata_admin.avatar.object_id,不新增数据库表或迁移;每次 成功上传使用新的对象 UUID,对象键为 <prefix>/<identity_uuid>/<object_uuid>.png,不再 额外加 avatars/ 层,调用方无法自选键或 identity。metadata_admin 损坏时拒绝覆盖。 修改 AVATAR_S3_PREFIX 不会迁移已有对象,只影响新写入与查找;变更前请自行处理旧前缀下 的文件(用户尚未部署头像时无需迁移)。
  • 公开图片地址仍为 PUBLIC_ORIGIN/avatars/<identity_uuid>/<object_uuid>(无扩展名),匿名 可读;只有对应 identity 当前 active 且指针正好等于该对象的请求才返回图片,否则一律 404, 也不代理任意键或重定向到对象存储。成功响应为 image/png、nosniff、 Cache-Control: public, max-age=300;删除/换图后旧地址最长 5 分钟内仍可能被缓存,之后 失效。所有错误响应沿用 no-store。
  • OIDC 仅在授予 profile scope 且用户确有头像时附带 picture claim;无 profile 不发送, 也不接受客户端自报 URL。
  • 上传解码上限 2 MiB;该路由的 JSON body 上限为 3 MiB,其余所有路由仍保持 32 KiB。

HTTPS 与 passkey RP ID

  • 生产必须经 HTTPS 访问 PUBLIC_ORIGIN;Cloudflare 侧保持 Always Use HTTPS。
  • PASSKEY_RP_ID 必须等于浏览器地址栏的主机名(即 PUBLIC_ORIGIN 的主机名)。只改其一 会导致浏览器拒绝或 passkey 校验失败。
  • WebAuthn 允许 http://localhost 上的 HTTP,仅用于本地调试;此时 RP ID 也必须设为 localhost。不要用 IP 地址(如 127.0.0.1)充当 RP ID。
  • 迁移域名后已有 passkey 不能跨 RP ID 迁移:旧 passkey 在新域名下不可用,用户需要重新 绑定。这是 WebAuthn 的协议约束。

开发与测试

direnv allow          # 或 devenv shell

前端:

cd web
yarn install --immutable
yarn ci               # typecheck + lint + format check + vitest + build
yarn e2e              # Playwright(浏览器边界 mock,离线可跑)

Rust:

cargo fmt --manifest-path auth-web/Cargo.toml --check
cargo clippy --manifest-path auth-web/Cargo.toml --locked --all-targets -- -D warnings
DATABASE_URL='<test-postgres-connection-string>' \
  cargo nextest run --manifest-path auth-web/Cargo.toml --locked

SQLx 测试需要连接到一个独立的临时 PostgreSQL 测试库(不要指向生产或开发库),并确认 所用角色可 CREATE DATABASE。

tests/system/ 承载真实 Rust/Ory/Postgres/edge 的全栈集成测试。接口细节见 docs/API.md。

许可

本仓库自有代码以 Apache License 2.0 授权,见 LICENSE;第三方依赖保留其各自许可。