Outline + Keycloak 单点登录完整部署手册(逐步骤配置说明)

从 DNS、证书、docker-compose、Keycloak realm 与 client、Outline 环境变量、nginx 反代到五项验收,每一步都说明配置在做什么、写错会怎样。核心结论:OIDC_SCOPES 为空字符串比不设置更糟。

把 Outline 的登录交给 Keycloak,理论上是标准 OIDC 授权码流程。但实际落地时,卡住大多数人的不是 Keycloak 后台那些选项,而是 Outline 侧一个看起来不起眼的变量:OIDC_SCOPES。当它被设成空字符串时,整条 OIDC 链路会静默降级成纯 OAuth2,最终表现是登录页上一句毫无信息量的「抱歉,发生未知错误」。

本文按准备 → 编排 → Keycloak → Outline → 反代 → 验收 → 排错的顺序给出完整流程。每一条配置都说明「它在做什么、写错会怎样」,全文的 example.top 全部替换成你自己的域名即可。

部署六个阶段
图 4:全文六个阶段。③④ 之间是最容易出问题的接缝——两侧配置必须严格对齐。

本文给出一套可以逐行照抄的 Outline + Keycloak 部署流程。区别在于:每一步都会说明「这条命令在做什么、为什么必须这么写、做错了会怎样」,而不是只丢一段配置让人猜。全文的落点是那个最容易翻车的地方——OIDC_SCOPES 被设成空字符串时,比完全没设更糟。

适用环境:单台 Linux(Debian/Ubuntu 或 TrueNAS SCALE 的 Docker),用 docker compose 编排,nginx 做 TLS 终结。全部示例用 example.top 占位,替换成你自己的域名即可。

Table of Contents

零、开始之前:把四件事定下来

这四项一旦确定,后续所有配置都围绕它们展开。建议现在就写下来,因为 Keycloak 的 issuer 里嵌了 realm 名,改一次要连带改 Outline 的四个地址。

项目 示例值 为什么必须现在定
Wiki 域名 outline.example.top Outline 不接受子目录部署,必须根路径
身份域名 auth.example.top Keycloak 前必须挂 TLS,否则 cookie 被判不安全
realm 名 zynetc 会进入 issuer:…/realms/zynetc
client id outline Outline 与 Keycloak 两侧必须逐字一致

定好之后,把下面四条「派生值」也记下来,后面会反复用到:

issuer        = https://auth.example.top/realms/zynetc
auth  端点     = https://auth.example.top/realms/zynetc/protocol/openid-connect/auth
token 端点     = https://auth.example.top/realms/zynetc/protocol/openid-connect/token
userinfo 端点  = https://auth.example.top/realms/zynetc/protocol/openid-connect/userinfo
回调地址      = https://outline.example.top/auth/oidc.callback

回调地址的路径是 Outline 写死的,不要试图改成 /auth/callback 或加尾斜杠,Keycloak 会因精确匹配失败而报 Invalid parameter: redirect_uri。

0.1 DNS 与防火墙

两条 A 记录都指向同一台机器:

A  outline.example.top  →  服务器公网 IP
A  auth.example.top     →  服务器公网 IP

放行 80 和 443。80 必须开,不是只为跳转——Let’s Encrypt 的 HTTP-01 验证走 80。

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw reload

验证方式:在另一台机器上 dig +short outline.example.top,必须返回你的公网 IP。DNS 没生效就往下做,后面 TLS 申请一定失败,会浪费很多时间。

另外不要把 3000(Outline)和 8080(Keycloak)暴露到公网,它们只监听 127.0.0.1,由 nginx 转发。这是本方案的安全边界。

0.2 生成三类密钥(各自用途不同,不能混用)

# ① Outline 的 SECRET_KEY:加密 session cookie 里的签名,长度必须 ≥ 32 字节
openssl rand -hex 32

# ② Outline 的 UTILS_SECRET:用于内部服务间通信
openssl rand -hex 32

# ③ PostgreSQL 密码、Redis 密码(如果开了 requirepass)
openssl rand -base64 24 | tr -d '/+=' | head -c 32

# ④ Keycloak 的 client secret(在 Keycloak 后台生成,见第三章,不需要手动造)

为什么要 hex 32 而不是 hex 16:Outline 启动时会校验 SECRET_KEY 长度,太短会直接报错退出。hex 32 得到 64 个字符,安全且充足。

把这些值存到密码管理器,不要只留在命令行历史里——下面 history 一敲什么都露出来了。

一、目录结构与文件准备

用一个目录装全部东西,备份和迁移都只需要拷这一份:

mkdir -p /opt/wiki && cd /opt/wiki
mkdir -p outline-data keycloak-data nginx/conf.d nginx/certs

# 最终结构
/opt/wiki
├── docker-compose.yml
├── .env                      # 所有密码集中放这里
├── nginx/
│   ├── conf.d/wiki.conf      # 两个 server 块
│   └── certs/                # 证书
├── outline-data/             # Outline 上传的附件
└── keycloak-data/            # Keycloak 的库 / 主题

为什么单独建 .env:compose 文件里的美元大括号变量会从同目录 .env 自动读取。这样 docker-compose.yml 可以放进 git,而密码不会泄露。权限收紧:

chmod 600 /opt/wiki/.env

二、编排文件:五个服务,逐段说明

Outline 与 Keycloak 部署拓扑
图 1:部署拓扑。两个域名分别由 nginx 终结 TLS 后转发到各自容器,容器端口只绑 127.0.0.1。

先写 .env(把 0.2 生成的值填进去):

# ---------- 通用 ----------
DOMAIN=example.top
WIKI_HOST=outline.example.top
AUTH_HOST=auth.example.top
TZ=Asia/Shanghai

# ---------- 数据库 ----------
PG_USER=outline
PG_PASSWORD=把0.2生成的密码填这里
PG_DB=outline

# ---------- Outline ----------
OUTLINE_SECRET_KEY=把hex32填这里
OUTLINE_UTILS_SECRET=另一个hex32

# ---------- Keycloak ----------
KC_DB_USER=keycloak
KC_DB_PASSWORD=另一个强密码
KC_ADMIN_USER=admin
KC_ADMIN_PASSWORD=另一个强密码
KEYCLOAK_REALM=zynetc
OIDC_CLIENT_SECRET=先占位,第三章末尾回来填

注意:.env 里不要给值加引号。docker compose 读 .env 时会保留引号字符,导致值真的带引号——这正是本文要讲的那个坑的源头之一。

2.1 docker-compose.yml 全文(可直接复制)

services:

  # ============================================================
  # PostgreSQL:Outline 与 Keycloak 共用一个实例,分两个库
  # ============================================================
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${PG_USER}
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      POSTGRES_DB: ${PG_DB}
      TZ: ${TZ}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init-kc-db.sql:/docker-entrypoint-initdb.d/10-kc.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${PG_USER}"]
      interval: 10s
      timeout: 5s
      retries: 10
    ports:
      - "127.0.0.1:5432:5432"

  # ============================================================
  # Redis:Outline 的缓存 + 协同编辑的发布订阅通道
  # ============================================================
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s

  # ============================================================
  # Keycloak:身份提供方
  # ============================================================
  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    restart: unless-stopped
    command: ["start"]
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USER}
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: ${KC_DB_USER}
      KC_DB_PASSWORD: ${KC_DB_PASSWORD}
      KC_HOSTNAME: https://${AUTH_HOST}
      KC_HTTP_ENABLED: "true"
      KC_PROXY_HEADERS: xforwarded
      KC_HEALTH_ENABLED: "true"
      TZ: ${TZ}
    volumes:
      - ./keycloak-data:/opt/keycloak/data
    depends_on:
      postgres:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:8080"
      - "127.0.0.1:9000:9000"

  # ============================================================
  # Outline:Wiki 本体
  # ============================================================
  outline:
    image: docker.getoutline.com/outlinewiki/outline:latest
    restart: unless-stopped
    env_file:
      - ./outline.env
    volumes:
      - ./outline-data:/var/lib/outline/data
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    extra_hosts:
      - "${AUTH_HOST}:host-gateway"
    ports:
      - "127.0.0.1:3000:3000"

  # ============================================================
  # nginx:TLS 终结 + 两个域名分流
  # ============================================================
  nginx:
    image: nginx:1.27-alpine
    restart: unless-stopped
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - outline
      - keycloak

volumes:
  pgdata:
  redisdata:

2.2 每一段为什么这么写

配置项 它在做什么 写错会怎样
init-kc-db.sql 挂载到 /docker-entrypoint-initdb.d/ Postgres 首次初始化时自动执行,建 keycloak 库和用户 不建库 → Keycloak 启动报 FATAL: database "keycloak" does not exist
"127.0.0.1:3000:3000" 端口只绑本机回环,公网必须经 nginx 写成 "3000:3000" → 可绕过 HTTPS 直连,session 全废且暴露面变大
KC_HOSTNAME 把 issuer 固定成公网地址,不随请求 Host 漂移 不设 → issuer 可能变成容器内地址,Outline 报 unexpected iss
KC_PROXY_HEADERS: xforwarded 信任 X-Forwarded-*,认为外部协议是 HTTPS 不设 → 日志刷 Non-secure context detected,跨域 POST 丢 cookie
KC_HTTP_ENABLED: "true" 容器内仍用明文 HTTP,TLS 在 nginx 终结 不设 → Keycloak 自己去找证书,容器起不来
KC_BOOTSTRAP_ADMIN_USERNAME 26.x 的管理员账号变量名 用旧的 KEYCLOAK_ADMIN → 找不到管理员,进不去后台
extra_hosts: host-gateway 把公网域名解析到宿主机网关 不设 → Outline 服务端换 token 时 ECONNREFUSED
condition: service_healthy 等数据库真正能接受连接再启动 只写 depends_on: [postgres] → 容器起来了但库没就绪,连接被拒
9000:9000 端口 Keycloak 管理端口,/health/ready 在这里 去 8080 找健康检查 → 404,误以为服务挂了

关于 host-gateway:这是 Docker 20.10+ 的内置特殊值,指向宿主机网关。比硬编码 172.17.0.1 好,因为 bridge 网络和自定义网络网关不同,硬编码换机器就挂。

关于 KC_PROXY_HEADERS:旧版写作 KC_PROXY=edge。26.x 改成了 KC_PROXY_HEADERS=xforwarded,两者不能混用。

2.3 创建初始化 SQL(注意那条 GRANT)

新建 /opt/wiki/init-kc-db.sql:

CREATE DATABASE keycloak;
CREATE USER keycloak WITH ENCRYPTED PASSWORD '和.env里KC_DB_PASSWORD一致';
GRANT ALL PRIVILEGES ON DATABASE keycloak TO keycloak;
\c keycloak
GRANT ALL ON SCHEMA public TO keycloak;

两个容易漏的点:

  • GRANT ALL ON SCHEMA public 必须写。PostgreSQL 15+ 收紧了 public schema 默认权限,不加这一句 Keycloak 建表会被拒:permission denied for schema public。
  • 这个脚本只在数据目录为空时执行一次。如果已经 up 过再改这个文件,不会重跑;要么 docker compose down -v 清卷重来,要么手动进 psql 补建。

SQL 里的密码是硬编码的,必须和 .env 的 KC_DB_PASSWORD 完全一致——这一处很容易忘。

2.4 先起来三个基础服务

不要一次 up 全部,分批起更容易定位问题:

cd /opt/wiki

# 第一步:只起数据库和缓存,确认健康
docker compose up -d postgres redis
docker compose ps

docker compose ps 的 STATUS 列应显示 healthy(不是 running)。等到 healthy 再继续:

# 第二步:起 Keycloak,观察它连库是否成功
docker compose up -d keycloak
docker compose logs -f keycloak | grep -i -E "started|error|exception"

看到 Running the server in production mode 且无异常即为成功。Ctrl+C 退出日志跟踪(不会停容器)。

# 第三步:确认 Keycloak 健康检查端口可用
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9000/health/ready

必须返回 200。若 404,说明 KC_HEALTH_ENABLED 没生效或端口映射错了。

三、nginx:两个域名,两条规则

3.1 先拿证书

用 certbot 独立模式最省事(此时 nginx 容器还没起,80 端口空闲):

sudo apt install -y certbot
sudo certbot certonly --standalone \
  -d outline.example.top -d auth.example.top \
  --agree-tos -m you@example.top --no-eff-email

证书会落在 /etc/letsencrypt/live/outline.example.top/。拷进 nginx 目录:

sudo cp /etc/letsencrypt/live/outline.example.top/fullchain.pem /opt/wiki/nginx/certs/
sudo cp /etc/letsencrypt/live/outline.example.top/privkey.pem   /opt/wiki/nginx/certs/
sudo chmod 644 /opt/wiki/nginx/certs/*.pem

为什么拷而不是直接挂载 /etc/letsencrypt:容器里挂载符号链接目录经常因为 archive/ 没一起挂而断链。拷贝是最不容易错的做法,续期后用钩子再拷一次即可:

# /etc/letsencrypt/renewal-hooks/deploy/copy-to-nginx.sh
#!/bin/sh
cp /etc/letsencrypt/live/outline.example.top/*.pem /opt/wiki/nginx/certs/
docker exec wiki-nginx-1 nginx -s reload

chmod +x 一下,之后 certbot renew 自动生效。

3.2 nginx/conf.d/wiki.conf 全文

# ============================================================
# Outline Wiki —— 注意:这里【没有】auth_request
# ============================================================
server {
    listen 80;
    server_name outline.example.top;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name outline.example.top;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # 附件上传上限,要和 Outline 的 FILE_STORAGE_UPLOAD_MAX_SIZE 对齐
    client_max_body_size 30m;

    location / {
        proxy_pass http://outline:3000;
        proxy_http_version 1.1;

        # ↓↓ 这两行是协同编辑实时生效的关键,缺了会变成「看似能编辑但不同步」
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Host  $host;

        # WebSocket 长连接,默认 60s 会断
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;

        # 关闭缓冲,否则协同编辑的事件流会被攒批
        proxy_buffering off;
    }
}

# ============================================================
# Keycloak
# ============================================================
server {
    listen 80;
    server_name auth.example.top;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name auth.example.top;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # ↓↓ Keycloak 的 header 和 token 很大,不加大必 502
    proxy_buffer_size        128k;
    proxy_buffers          4 256k;
    proxy_busy_buffers_size  256k;

    location / {
        proxy_pass http://keycloak:8080;
        proxy_http_version 1.1;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  443;
    }
}

3.3 每条配置在解决什么问题

指令 为什么必须有
Upgrade + Connection "upgrade" Outline 的实时协作走 WebSocket。少了这两行,页面能打开、能编辑,但别人的改动不会实时出现,刷新才看到
proxy_read_timeout 300s WebSocket 是长连接,默认 60 秒无数据就断开,表现为「编辑几分钟后需要刷新」
proxy_buffering off 事件流被 nginx 缓冲后会攒批下发,光标和文字出现明显延迟
X-Forwarded-Proto Outline 用它判断外部是 HTTPS;缺了可能生成 http 的绝对链接,或把 cookie 标成非 Secure
proxy_buffer_size 128k 等三行 Keycloak 的 Set-Cookie、JWT 响应头很大,默认 4k/8k 缓冲会报 upstream sent too big header,浏览器看到 502
client_max_body_size 30m 上传附件超过 1m(nginx 默认)会被 413 拒绝,且 Outline 侧看不到任何日志

三个必须提醒的「不要」:

  • 不要在 Outline 的 server 块里加 auth_request 指向 Keycloak。Keycloak 没有 forward-auth 端点(那是 Authelia 的能力),加了必然 403 或 500。
  • 不要把 Outline 挂到子路径(如 /wiki/)。它生成绝对路径资源,子目录部署会全站 404。
  • 不要在 nginx 里给授权请求注入 scope 参数。Outline 的 state 里存了 nonceHash,它要靠自己发出的 nonce 校验;nginx 单方面补 scope 只会把错误从「userinfo 403」变成「nonce 校验失败」。

改完配置先测语法再重载:

docker compose exec nginx nginx -t
docker compose exec nginx nginx -s reload

nginx -t 必须输出 syntax is ok 和 test is successful。有 emerg 就不要 reload,先修错。

四、Keycloak 后台:七步配完

浏览器打开 https://auth.example.top,点 Administration Console,用 .env 里的 KC_ADMIN_USER / KC_ADMIN_PASSWORD 登录。

进不去后台的排查顺序:① 控制台 502 → 检查 nginx 那三行 buffer;② 一直转圈 → 检查 KC_HOSTNAME 是否和你访问的域名一致;③ 提示账号密码错 → 确认用的是 KC_BOOTSTRAP_ADMIN_*(26.x)而不是旧变量名。

步骤 1:建 realm(不要用 master)

左上角 realm 下拉框 → Create realm:

字段 填什么 说明
Realm name zynetc 会成为 issuer 的一部分:…/realms/zynetc
Enabled ON 关闭的话 realm 直接不可用

为什么不用 master:master 只用于管理其他 realm,不能被删除,且它下面的 client 语义混乱。业务 client 放在独立 realm 里,将来备份、迁移、删库重建都干净。

建好后进入 Realm settings → Login 标签页,改三项:

  • Email as username → ON:Keycloak 内用邮箱作为登录名
  • Login with email → ON:允许用邮箱登录,即使用户名不是邮箱
  • Duplicate emails → OFF:禁止两个账号同一邮箱

为什么这三项重要:Outline 用 email 作为用户唯一标识。如果 Keycloak 里出现两个同邮箱账号,或登录流程拿不到邮箱,Outline 侧会出现「登录成功但进了另一个账号」或「缺 email 无法建号」。

步骤 2:建 client

左侧 Clients → Create client。分两页填:

第 1 页 General settings:

字段 取值 为什么
Client type OpenID Connect Outline 只支持 OIDC,不支持 SAML
Client ID outline 要和 Outline 的 OIDC_CLIENT_ID 逐字一致(区分大小写)
Name Outline Wiki 纯展示用,不影响功能

第 2 页 Capability config:

开关 取值 为什么
Client authentication ON 打开后才是 confidential client,能拿到 client secret。Outline 是服务端换 token,必须要 secret
Authorization OFF 这是 Keycloak 的细粒度授权功能,Outline 用不到
Standard flow ON 授权码流程,OIDC 登录就靠它
Implicit flow OFF 已废弃,token 直接暴露在前端
Direct access grants OFF 用户名密码直接换 token,仅调试时临时开
Service accounts roles OFF 机器到机器的 client_credentials,不需要

第 3 页 Login settings(最关键一页):

字段 填什么
Valid redirect URIs https://outline.example.top/auth/oidc.callback
Web origins https://outline.example.top
Post logout redirect URIs https://outline.example.top/*(要用单点登出才需要)

关于 redirect URI 的三个致命细节:

  1. 协议必须是 https,写 http 会静默失败;
  2. 末尾不能有斜杠,…/callback/ 与 …/callback 在 Keycloak 眼里是两个不同地址;
  3. 路径是 Outline 写死的 /auth/oidc.callback,不是 /callback、不是 /oidc/callback。

填错的表现是登录跳转后 Keycloak 显示一页英文错误:Invalid parameter: redirect_uri。

点 Save。

步骤 3:取出 client secret

进入刚建好的 client → Credentials 标签页 → 复制 Client secret 的值。

把它填进 .env 的 OIDC_CLIENT_SECRET,也就是后面 outline.env 里用的那个。

注意:这个 secret 只在这里显示。如果关掉页面忘了复制,点 Regenerate 重新生成——但要记得同步改 outline.env,否则 token 请求会返回 invalid_client。

步骤 4:确认 email 会出现在 userinfo 里(最容易漏)

Outline 拿到 token 后会调 /userinfo 取用户信息,其中最关键的字段是 email。Keycloak 默认不保证 email 出现在 userinfo 响应里,必须确认三处:

① Client scopes 标签页 → 看 Default client scopes 列表,必须有:

profile        ← 提供 preferred_username、name 等
email          ← 提供 email、email_verified
roles          ← 可选

如果 email 在 Optional 列表里而不在 Default 里,把它 Add to default。

② 点进 email scope → Mappers 标签页 → 找到名为 email 的 mapper → 打开确认 Add to userinfo 是 ON。

为什么这一项是硬要求:mapper 的「Add to userinfo」关掉时,email 会出现在 ID token 里,但 不出现在 userinfo 响应。Outline 读的是 userinfo,于是报错 OIDC profile is missing required email 或建号失败。

③ 关于那条 Full scope allowed 警告

Keycloak 日志里会刷:

Client 'outline' in the realm 'zynetc' has 'Full scope allowed' switch enabled.
This client switch is deprecated and will be removed in the future.

这是弃用提醒,不是错误。在没配好 client role scope 之前不要关它——关掉后 Keycloak 会按 client scope 重新计算 token 里的 scope,openid 反而更容易被过滤掉,正好引发下一节的问题。先把功能跑通,之后再考虑处理这条警告。

步骤 5:建测试用户

左侧 Users → Create new user:

字段 填什么 说明
Username alice 如果开了 Email as username,这里直接填邮箱
Email alice@example.top 必填,Outline 靠它识别账号
Email verified ON 关着可能在登录流程里被要求先验证邮箱
First / Last name 随意 会作为 Outline 里的显示名

保存后进 Credentials → Set password → 输入两次 → Temporary 设为 Off。

Temporary 必须关:开启时第一次登录会强制跳转改密码页,这个页面在 iframe 或某些回调场景下会让登录流程卡住,看起来像「登录了但没回来」。

步骤 6:验证 realm 元数据(命令行)

不用等 Outline 起来,先在服务器上确认 Keycloak 对外暴露的地址是对的:

curl -sS https://auth.example.top/realms/zynetc/.well-known/openid-configuration \
  | jq '{issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri}'

期望输出(四个地址都必须是公网域名):

{
  "issuer": "https://auth.example.top/realms/zynetc",
  "authorization_endpoint": "https://auth.example.top/realms/zynetc/protocol/openid-connect/auth",
  "token_endpoint": "https://auth.example.top/realms/zynetc/protocol/openid-connect/token",
  "userinfo_endpoint": "https://auth.example.top/realms/zynetc/protocol/openid-connect/userinfo",
  "jwks_uri": "https://auth.example.top/realms/zynetc/protocol/openid-connect/certs"
}

如果 issuer 变成 http:// 或容器内部主机名,说明 KC_HOSTNAME 没生效,回到 2.2 检查。这一条不通过,后面全白搭。

再确认授权端点活着(不带参数应返回 400 而不是 404):

curl -sS -o /dev/null -w '%{http_code}\n' \
  https://auth.example.top/realms/zynetc/protocol/openid-connect/auth

返回 400 正常(缺 client_id),返回 404 说明 realm 名写错或反代路径有问题。

步骤 7:(可选)开事件日志便于排错

Realm settings → Events → Event settings:

  • Save events → ON
  • Expiration → 7 Days
  • Saved types 里勾上 LOGIN、LOGIN_ERROR、USER_INFO_REQUEST_ERROR、CODE_TO_TOKEN_ERROR

为什么值得开:后面出问题时,Events 页面会直接告诉你 error="access_denied" reason="Missing openid scope" 这类精确原因,比 Outline 那句笼统的「未知错误」有用得多。这是排查 OIDC 问题最高效的入口。

五、Outline 侧:outline.env 逐项说明

新建 /opt/wiki/outline.env。注意这是独立文件,不是 .env——compose 里用 env_file 引用它,这样避免 compose 变量插值和 env 文件互相干扰。

# ================== 基础 ==================
NODE_ENV=production
URL=https://outline.example.top
PORT=3000
FORCE_HTTPS=true
TZ=Asia/Shanghai

# ================== 密钥 ==================
SECRET_KEY=把0.2的第一个hex32填这里
UTILS_SECRET=把0.2的第二个hex32填这里

# ================== 数据库 ==================
DATABASE_URL=postgres://outline:PG密码@postgres:5432/outline
PGSSLMODE=disable

# ================== 缓存与协同 ==================
REDIS_URL=redis://redis:6379
REDIS_COLLABORATION_URL=redis://redis:6379

# ================== 文件存储 ==================
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400

# ================== 语言 ==================
DEFAULT_LANGUAGE=zh_CN

# ================== 注册限制(用 OIDC 时可留空) ==================
# ALLOWED_DOMAINS=example.top

# ================== ★ OIDC:七个变量,一个都不能少 ==================
OIDC_CLIENT_ID=outline
OIDC_CLIENT_SECRET=第四章步骤3复制的secret
OIDC_AUTH_URI=https://auth.example.top/realms/zynetc/protocol/openid-connect/auth
OIDC_TOKEN_URI=https://auth.example.top/realms/zynetc/protocol/openid-connect/token
OIDC_USERINFO_URI=https://auth.example.top/realms/zynetc/protocol/openid-connect/userinfo
OIDC_DISPLAY_NAME=Keycloak
OIDC_SCOPES=openid profile email

5.1 每个变量在做什么

变量 作用 写错的后果
URL 生成绝对链接、拼回调地址 写错 → 回调地址对不上,redirect_uri 报错
SECRET_KEY 签名 session cookie 长度不足 → 启动即退出
FORCE_HTTPS 强制 cookie 带 Secure 标记 设 false 且走 http → 浏览器拒收 cookie,登录后立刻回登录页
PGSSLMODE=disable 容器内同网络不需 TLS 不设 → 连接报 no pg_hba.conf entry
REDIS_COLLABORATION_URL 协同编辑的 pub/sub 通道 不设 → 降级单进程(见 5.3)
FILE_STORAGE_UPLOAD_MAX_SIZE 上传上限,单位字节 小于 nginx 的 client_max_body_size 时,前端会先被拦
OIDC_AUTH_URI 用户点击登录后跳转的地址 少了 /realms/zynetc → 404
OIDC_TOKEN_URI 服务端换 token 的地址 写成内网地址 → issuer 不匹配
OIDC_USERINFO_URI 取用户信息(含 email) 403 空响应 → 「Failed to parse response」
OIDC_SCOPES 请求哪些 scope 设成空字符串 → 整条链路崩(见 5.2)

三个 URI 都必须带 /realms/zynetc。漏掉这一段会跳到 realm 根路径,得到 404 或 Realm does not exist。

5.2 全文最重要的一个坑:OIDC_SCOPES 不能为空

这是那种「看起来配了,实际全废」的问题,值得单独用一节说清楚。

机制是这样的:

  1. Outline 对 OIDC_SCOPES 有内置默认值(openid profile email);
  2. 但默认值只在变量完全不存在时才生效;
  3. 一旦变量存在但值为空字符串,Outline 认为「使用方明确要求不要 scope」;
  4. 于是授权请求里 scope 参数整个消失;
  5. 底层 OIDC 库只在 scope 含 openid 时才生成 nonce,所以 nonce 也没了;
  6. Keycloak 收到无 scope 的请求 → 按纯 OAuth2 处理,签出的 token 里没有 openid;
  7. Outline 拿 token 调 /userinfo → Keycloak 强制要求 openid scope → 返回 403 且响应体为空;
  8. Outline 拿到空 body,报出那句极具误导性的话:Failed to parse response … Expected JSON, got: 空。

为什么特别容易踩:很多平台(TrueNAS catalog、某些 k8s Helm chart、部分 PaaS)会把「可选字段」渲染成空字符串而不是不设置该变量。它看起来”没填”,实际是”填了空值”,语义完全不同。

排查的正确姿势——不要看配置文件,看容器实际拿到了什么:

docker compose exec outline printenv | grep -i OIDC

判读结果:

printenv 输出 结论 动作
完全没有 OIDC_SCOPES 这一行 变量没传进去,会走默认值 通常没问题,但建议显式设上
OIDC_SCOPES=(等号右边空) 确诊:空字符串 改配置,见下
OIDC_SCOPES="openid profile email" 引号成了字面字符 去掉引号
OIDC_SCOPES=openid profile email 正确 查别处

正确写法(三条都要满足):

  • 值用空格分隔:openid profile email
  • 不要加引号(env_file 不剥引号)
  • 不要用逗号(openid,profile,email 会被当成一个未知 scope)

5.3 顺手消掉协同编辑的降级警告

日志里这条不致命但会降低性能:

Note: Restricting process count to 1 due to use of collaborative service
without REDIS_COLLABORATION_URL

含义是 Outline 的协同编辑服务被迫跑单进程(无法跨进程广播)。加上 REDIS_COLLABORATION_URL 即可,它和 REDIS_URL 用同一个 Redis 实例没问题。

5.4 初始化数据库并启动

cd /opt/wiki

# 第一步:跑数据库迁移(首次必做,否则表不存在)
docker compose run --rm outline yarn db:migrate

# 第二步:启动应用
docker compose up -d outline

# 第三步:确认 OIDC 插件已挂载
docker compose logs --tail=50 outline | grep -i oidc

期望日志:

{"label":"plugins","level":"info","message":"OIDC endpoints mounted with manual configuration"}
{"label":"plugins","level":"info","message":"OIDC plugin registered"}

看不到这两行说明 OIDC 变量没被识别,回到 5.1 逐个核对变量名。看到 mounting 字样就说明配置已读取——这也意味着改 env 后必须重启,OIDC 插件只在启动时挂载一次。

六、验收:五项检查,逐项过

OIDC 授权码流程时序图
图 2:授权码流程八步。第 2 步的 scope 与 nonce 是全流程成败的分水岭。

命令行能确认的先用命令行确认,最后再用浏览器点一次。

检查 1:Keycloak 元数据(服务端视角)

curl -sS https://auth.example.top/realms/zynetc/.well-known/openid-configuration \
  | jq -r '.issuer'

必须输出 https://auth.example.top/realms/zynetc。

检查 2:容器内能否访问身份域名(最容易失败)

docker compose exec outline node -e "
  fetch('https://auth.example.top/realms/zynetc/.well-known/openid-configuration')
    .then(r => console.log('status', r.status))
    .catch(e => console.log('FAIL', e.message))
"

必须输出 status 200。

为什么这条最关键:token 请求是 Outline 服务端发的,不是浏览器发的。容器内的 DNS 往往解析不了公网域名,或者回环不到宿主机。失败时表现为日志里 ECONNREFUSED、ENOTFOUND,或一直卡在回调。

失败就先修 extra_hosts: host-gateway,或在容器内验证解析:

docker compose exec outline getent hosts auth.example.top

检查 3:容器里的 OIDC 变量实际值

docker compose exec outline printenv | grep -i OIDC

重点看 OIDC_SCOPES 是否为 openid profile email(非空、无引号)。

检查 4:浏览器抓授权 URL(唯一的成功判据)

  1. 打开 https://outline.example.top,按 F12 → Network → 勾选 Preserve log;
  2. 点「使用 Keycloak 继续」;
  3. 找到跳转到 auth.example.top 的那条请求,看它的 query string。

必须同时出现 scope 和 nonce:

.../protocol/openid-connect/auth?client_id=outline&response_type=code
   &redirect_uri=https%3A%2F%2Foutline.example.top%2Fauth%2Foidc.callback
   &scope=openid+profile+email      ← 有
   &nonce=xxxxxxxxxxxxxxxx          ← 有
   &state=eyJ...

这两项出现,就说明 5.2 那个坑已经绕过了。只看到 client_id 和 state 而没有 scope/nonce,就是 OIDC_SCOPES 为空。

检查 5:走完整流程并看两侧日志

# 终端 A:跟 Outline 日志
docker compose logs -f outline

# 终端 B:跟 Keycloak 日志
docker compose logs -f keycloak

浏览器完成登录后,两侧都应该是干净的:

  • Outline 侧不应出现 Error during authentication、Failed to parse response;
  • Keycloak 侧不应出现 USER_INFO_REQUEST_ERROR、CODE_TO_TOKEN_ERROR。

浏览器应停在 Outline 首页,且 Settings → Members 里能看到你的账号。

登录失败时的完整因果链

如果卡在「抱歉,发生未知错误」,这就是你正在经历的路径,对照着看能少走很多弯路:

空 scope 导致的故障链
图 3:一个空字符串如何一路演变成「抱歉,发生未知错误」。第 6 步的报错是假象,根因在第 1 步。

关键认知:第 6 步那句报错是假象。Failed to parse response … got: 空 的真实含义是「我收到的响应体长度为零」,因为 Keycloak 返回了 403 且没有 body。它和 JSON 解析、Content-Type 都无关,根因永远在上游的 scope。

七、TrueNAS SCALE(iX Apps)上的三个额外坑

如果 Outline 是从应用商店装的 catalog 版本,会遇到一些和配置文件无关、只在平台上才有的问题。

7.1 RenderError:变量已被开发者定义

base_v2_3_17.error.RenderError: Environment variable [OIDC_SCOPES]
is already defined from the application developer.

原因:catalog 模板自己声明了 OIDC_SCOPES,而平台渲染库里明确禁止用户在「额外环境变量」中重复定义同名变量,直接拒绝渲染。

处理:

  1. 先把「额外环境变量」里那一行删掉,让应用能保存;
  2. 去 UI 表单的 OIDC 分组里找对应字段填写;
  3. 注意这个字段很可能是列表类型——留空就渲染成空字符串,正好触发 5.2 的问题。列表型请添加三个独立条目 openid / profile / email;单行文本型填 openid profile email。

不要用改名的办法绕过(比如写成 OIDC_SCOPES2),那会静默失效,回到最初的问题。

7.2 空变量比没变量更糟

这是全文最值得记住的一条经验:

很多程序区分「变量未设置」和「变量设置为空」。前者走默认值,后者被当成明确的用户意图。平台模板把可选字段渲染成空字符串时,就悄悄改变了程序行为。

所以排查这类问题时,永远不要看配置文件写了什么,要看进程实际拿到了什么:

docker inspect 容器名 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i -E "OIDC|SCOPE"

看到 OIDC_SCOPES=(等号右边为空)就确诊了。

7.3 /mnt 看起来是空的

报错里给的路径是 /mnt/.ix-apps/app_configs/…,但登录进去发现 /mnt 空,通常是这三类原因:

原因 确认方式
在容器 Shell 里看宿主机的路径 退出,改用 System Settings → Shell(宿主机 root)
池没挂在默认位置 / 用了非标准路径 df -h | grep -E "mnt|ix-apps"
apps 目录换了位置 find / -maxdepth 10 -path "*outline*" -name "docker-compose.yaml" 2>/dev/null

但排查 OIDC 变量根本不需要模板路径——上面那条 docker inspect 才是唯一真相。

八、排错对照表

按「你看到的现象」查,不用从头读。

现象 真正原因 去哪一节
Failed to parse response … Expected JSON, got: 空 Keycloak 返回 403 空 body,上游是 token 缺 openid scope 5.2
Keycloak 事件:USER_INFO_REQUEST_ERROR、reason="Missing openid scope" 同上,token 未经 OIDC 流程签发 5.2
授权 URL 里没有 scope 也没有 nonce OIDC_SCOPES 为空字符串 5.2、检查 3
Environment variable [OIDC_SCOPES] is already defined 平台模板已声明同名变量 7.1
Invalid parameter: redirect_uri 回调地址不精确匹配 步骤 2 第 3 页
invalid_client / token 端点 401 secret 不符,或 client 不是 confidential 步骤 2、步骤 3
unexpected iss / 签名校验失败 URI 混用内网与公网,或未设 KC_HOSTNAME 2.2、步骤 6
userinfo 正常但缺 email,建号失败 email mapper 未加入 userinfo,或用户没填邮箱 步骤 4、步骤 5
Outline 容器 ECONNREFUSED / ENOTFOUND 容器内解析不到身份域名 检查 2
Non-secure context detected 警告 缺 X-Forwarded-Proto 或 KC_PROXY_HEADERS 2.2、3.2
Restricting process count to 1 未设 REDIS_COLLABORATION_URL 5.3
登录成功但立刻跳回登录页 cookie 被判非安全 / 反代没透传协议头 3.2、3.3
多人编辑不同步,刷新才看到 nginx 缺 Upgrade / Connection 3.3
上传附件 413 client_max_body_size 太小 3.2、5.1
Keycloak 后台一直转圈或 502 nginx buffer 不足,或 KC_HOSTNAME 与访问域名不一致 3.2、2.2

九、上线之后的三件事

9.1 迁移已有数据

Outline 用 email 认人。只要 Keycloak 里用户的邮箱与 Outline 中已有账号一致,登录后会合并到原账号——文档、收藏、权限全部保留。sub 变了不影响,因为匹配键是 email。

邮箱不一致会生成全新账号,看起来像”数据丢了”。所以切换认证方式前,先把两边邮箱核对一遍:

docker compose exec postgres psql -U outline -d outline \
  -c "SELECT email, name, \"createdAt\" FROM users ORDER BY \"createdAt\";"

导出这张表,和 Keycloak 的用户邮箱逐个对照。

9.2 第一个用户是管理员

Outline 里第一个注册的用户是 admin,之后登录的都是 member(无管理权限)。

如果实例已经初始化过,新登录用户需要由现有 admin 在 Settings → Members 里手动提升为 Admin。OIDC 不会自动授予管理员权限,这一点和 Keycloak 里的 realm role 无关。

9.3 备份哪些东西

# 数据库(最重要,含所有文档内容)
docker compose exec postgres pg_dump -U outline outline > outline-$(date +%F).sql

# 附件目录
tar czf outline-data-$(date +%F).tar.gz /opt/wiki/outline-data

# 配置文件(不含密码,密码在 .env 和 outline.env 里单独保管)
tar czf wiki-conf-$(date +%F).tar.gz /opt/wiki/docker-compose.yml /opt/wiki/nginx

Keycloak 的数据在数据库的 keycloak 库里,一起备份即可:

docker compose exec postgres pg_dump -U keycloak keycloak > keycloak-$(date +%F).sql

十、安全红线(四条)

  1. client secret 只存在服务端环境变量里。不要贴进工单、截图、聊天记录。它泄露等于任何人都能冒充你的 Outline 去换 token。
  2. 调试时只看三个字段。需要检查 token 时,只解码打印 scope、azp、sub,不要把整段 access_token 或 ID token 输出到日志:
    echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{azp, scope, sub}'
  3. 3000 / 8080 不要暴露公网。全部绑 127.0.0.1,公网只留 80 / 443。检查:docker compose ps 看 PORTS 列,ss -tlnp | grep -E '3000|8080' 应只见 127.0.0.1。
  4. 调试用的开关要关回去。为验证 token 而临时打开的 Direct access grants,验证完必须关掉——它允许用用户名密码直接换 token,绕过浏览器流程。

小结

整套流程真正会卡住人的只有三处:

  1. OIDC_SCOPES 不能为空——空字符串会让整条 OIDC 链路静默降级成 OAuth2;
  2. Outline 容器要能访问身份域名——token 请求是服务端发的,容器 DNS 常常不通;
  3. 反代要透传协议头——否则 Keycloak 认为在明文环境,cookie 被判不安全。

其余配置只要逐字对齐就不会出问题。而排错时最重要的一条经验是:不要相信最外层那句报错——先去身份提供方的 Events 页面看,它会直接告诉你缺了哪个 scope。

主题测试文章,只做测试使用。发布者:admin,转转请注明出处:https://zynetc.top/?p=8802

赞 (0)
adminadmin
文枢DocSmith:把本地代码仓库变成可发布的 WordPress 技术文档
上一篇 5天前
PixivDesk:从零构建一个可断点续传的 Pixiv 桌面客户端
下一篇 6天前