Files
iam-service/docs/SSO_INTEGRATION.md
2026-02-03 17:31:08 +08:00

6.1 KiB
Raw Blame History

业务服务接入指引SSO 授权码模式)

本指引描述业务服务(如 CMS/TMS如何接入统一登录页iam-front与 IAMiam-service实现单点登录Authorization Code → Token

1. 关键约定

  • 登录页:{IAM_FRONT_BASE_URL}/login?clientId={clientId}&tenantId={tenantId}&callback={encodeURIComponent(redirectUri)}
  • 授权码codeJWTHS256有效期 5 分钟Redis 单次使用
  • 换取 token业务服务端携带 clientSecret 调用
    • POST {IAM_SERVICE_BASE_URL}/api/v1/auth/code2token(外部第三方:必须携带 tenant_id 并校验 clientId/clientSecret
    • POST {IAM_SERVICE_BASE_URL}/api/v1/internal/auth/code2token(内部服务:需 X-Internal-Token 预共享密钥,不强制 tenant_id
  • 签发授权码:由 IAM 服务端完成(校验 redirectUri 是否在该 clientId 的 allowlist 中)
    • POST {IAM_SERVICE_BASE_URL}/api/v1/auth/login-code
  • token 刷新:POST {IAM_SERVICE_BASE_URL}/api/v1/auth/refresh
  • 退出:POST {IAM_SERVICE_BASE_URL}/api/v1/auth/logout

2. 业务 Next.js前端接入中间件跳转登录

2.1 最小示例:缺少 token 则 302 去登录

在业务项目根目录创建 /src/middleware.ts

import { NextRequest, NextResponse } from "next/server";

function isExpired(jwt: string): boolean {
  const parts = jwt.split(".");
  if (parts.length < 2) return true;
  const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
  const padLen = (4 - (normalized.length % 4)) % 4;
  const json = atob(normalized + "=".repeat(padLen));
  const payload = JSON.parse(json) as { exp?: number };
  if (!payload.exp) return true;
  return Math.floor(Date.now() / 1000) >= payload.exp;
}

export function middleware(req: NextRequest) {
  const tenantId = req.headers.get("x-tenant-id") ?? "";
  const accessToken = req.cookies.get("accessToken")?.value ?? "";

  if (!accessToken || isExpired(accessToken)) {
    const currentUrl = req.nextUrl.clone();
    const callback = `${process.env.CMS_SERVICE_BASE_URL}/auth/callback?next=${encodeURIComponent(
      currentUrl.toString(),
    )}`;
    const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent(
      process.env.CMS_CLIENT_ID ?? "",
    )}&tenantId=${encodeURIComponent(
      tenantId,
    )}&callback=${encodeURIComponent(callback)}`;
    return NextResponse.redirect(loginUrl, 302);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|api/public).*)"],
};

说明:

  • 示例使用 cookie accessToken;你也可以改为本地存储或自定义 header。
  • 校验是否过期仅做 payload 解析(不验签);服务端真正鉴权仍应使用 auth-kit + RS256 验签。

3. 业务 Axum后端接入code → token服务端通道

3.1 业务后端回调接口(接收 code

用户从 iam-front 回跳到 callbackUrl?code=... 后,建议业务服务由后端处理 code 换 token并以 HttpOnly Cookie 存储 refresh token若换取失败可重定向到业务前端的错误页/auth-error)给出提示。

redirectUri allowlist 建议配置为“固定回调地址scheme+host+port+path不要把 next=... 这类动态参数写进 allowlist例如

  • http://localhost:5031/auth/callback
  • http://localhost:5031/auth/callback?next=http%3A%2F%2Flocalhost%3A6031%2F
use axum::{extract::Query, response::Redirect, routing::get, Router};
use serde::Deserialize;

#[derive(Deserialize)]
struct CallbackQuery {
    code: String,
}

async fn sso_callback(Query(q): Query<CallbackQuery>) -> Redirect {
    let iam_base = std::env::var("IAM_SERVICE_BASE_URL").unwrap();
    let client_id = std::env::var("IAM_CLIENT_ID").unwrap();
    let client_secret = std::env::var("IAM_CLIENT_SECRET").unwrap();

    let http = reqwest::Client::new();
    let resp = http
        .post(format!("{}/api/v1/auth/code2token", iam_base.trim_end_matches('/')))
        .json(&serde_json::json!({
            "code": q.code,
            "clientId": client_id,
            "clientSecret": client_secret
        }))
        .send()
        .await
        .unwrap()
        .json::<serde_json::Value>()
        .await
        .unwrap();

    let data = &resp["data"];
    let access_token = data["accessToken"].as_str().unwrap_or_default();
    let refresh_token = data["refreshToken"].as_str().unwrap_or_default();

    let target = format!("/?loggedIn=1");
    let mut r = Redirect::temporary(&target).into_response();
    r.headers_mut().append(
        axum::http::header::SET_COOKIE,
        format!("accessToken={}; Path=/; Secure; SameSite=Strict", access_token)
            .parse()
            .unwrap(),
    );
    r.headers_mut().append(
        axum::http::header::SET_COOKIE,
        format!("refreshToken={}; HttpOnly; Path=/; Secure; SameSite=Strict", refresh_token)
            .parse()
            .unwrap(),
    );
    r
}

pub fn router() -> Router {
    Router::new().route("/auth/callback", get(sso_callback))
}

3.2 后端鉴权与自动刷新

推荐:

  • 业务后端对受保护接口强制 Authorization: Bearer <access_token>(来自 cookie 或前端 header
  • 过期时由业务后端调用 POST /api/v1/auth/refresh,轮换 refresh token并回写 cookie。

4. clientId / clientSecret 管理

由平台管理员通过 IAM 平台接口创建与轮换(仅示例,实际需要具备平台权限码 iam:client:*

curl -X POST "$IAM_SERVICE_BASE_URL/api/v1/platform/clients" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "clientId": "cms", "name": "CMS", "redirectUris": ["https://cms-api.example.com/auth/callback"] }'

轮换:

curl -X POST "$IAM_SERVICE_BASE_URL/api/v1/platform/clients/cms/rotate-secret" \
  -H "Authorization: Bearer $PLATFORM_TOKEN"

更新允许回调地址redirectUris

curl -X PUT "$IAM_SERVICE_BASE_URL/api/v1/platform/clients/cms/redirect-uris" \
  -H "Authorization: Bearer $PLATFORM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "redirectUris": ["https://cms-api.example.com/auth/callback"] }'