Skip to content

Identity Server

本章只对应 E:\gitcode\spire\soulsoft_identity_server\samples 里的样例。

样例不是单个程序,而是三个角色一起工作:

  • samples/server:认证与授权服务器,监听 http://127.0.0.1:5000
  • samples/client:前端 OIDC 客户端,运行在 http://127.0.0.1:3000
  • samples/api:受保护资源服务器,监听 http://127.0.0.1:8080

它们组合起来展示的是一条完整链路:

text
浏览器客户端
    -> Identity Server 登录与授权
    -> 拿到 code / token
    -> 带 access_token 调用 API

样例里到底配了什么

samples/server/src/main.cj 做了四件核心事情:

  1. 注册 Identity Server,并设置 issuerissuerUri、登录页和同意页地址。
  2. 通过内存配置加载客户端、身份资源、API Scope、API Resource。
  3. 注册签名凭据、ProfileService、密码模式校验器、扩展授权校验器。
  4. 把 Identity Server 中间件插入 HTTP 管道。

最小骨架就是这样:

cangjie
import soulsoft_identity_tokens.*
import soulsoft_identity_server.*
import soulsoft_web_hosting.*
import soulsoft_web_routing.*
import soulsoft_web_staticfiles.*
import soulsoft_web_authentication.*
import soulsoft_web_authentication_cookies.*
import soulsoft_web_authorization.*
import soulsoft_extensions_injection.*
import soulsoft_identity_server.authentication.*

let builder = WebHost.createBuilder()
let rsaKey = RsaSecurityKey.fromPemFiles("rsa256_public.pem", "rsa256_private.pem")

builder.services.addIdentityServer { options =>
    options.issuer = "soulsoft"
    options.issuerUri = "http://localhost:5000"
    options.interaction.loginUrl = "/account/login.html"
    options.interaction.consentUrl = "/account/consent.html"
}
    .addInMemoryClients(builder.configuration.getSection("identityserver:clients"))
    .addInMemoryApiScopes(builder.configuration.getSection("identityserver:apiScopes"))
    .addInMemoryApiResources(builder.configuration.getSection("identityserver:apiResources"))
    .addInMemoryIdentityResources(builder.configuration.getSection("identityserver:identityResources"))
    .addInMemorySigningCredentials([SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256)])
    .addProfileService<ProfileService>()
    .addExtensionGrantValidator<EmailExtensionGrantValidator>("email")
    .addExtensionGrantValidator<PhoneExtensionGrantValidator>("phone")
    .addPasswordGrantValidator<ResourceOwnerPasswordValidator>()

builder.services.addRouting()
builder.services.addAuthentication(LocalApiAuthenticationDefaults.Scheme)
    .addCookie()
    .addLocalApi()
builder.services.addAuthorizationBuilder().addPolicy(CookieAuthenticationDefaults.Scheme) { policy =>
    policy.addAuthenticationSchemes(CookieAuthenticationDefaults.Scheme)
    policy.requireAuthenticatedUser()
}

let app = builder.build()
app.useIdentityServer()
app.useFileServer()
app.useRouting()
app.useAuthentication()
app.useAuthorization()

配置模型

samples/server/appsettings.json 里把 Identity Server 的元数据都放进了 identityserver 节点。

样例里的资源模型是:

  • 客户端 interactive.public
    • authorization_code + refresh_token
    • requirePkce = true
    • allowedRedirectUris = ["http://127.0.0.1:3000/callback.html"]
    • allowedScopes = ["profile", "openid", "api"]
  • 客户端 local
    • authorization_code + password
    • clientSecrets
  • 身份资源
    • openid
    • profile
  • API Scope
    • api
    • offline_access
  • API Resource
    • order
    • 允许的 scope 是 api

这说明样例同时覆盖了两类场景:

  • 浏览器前端通过授权码模式登录
  • 本地客户端通过密码模式或扩展授权模式拿令牌

协议端点

Identity Server 默认暴露的协议端点可以直接从 ConstantEndpointPaths 看出来:

  • /connect/authorize
  • /.well-known/openid-configuration
  • /.well-known/openid-configuration/jwks
  • /connect/token
  • /connect/userinfo
  • /connect/revocation
  • /connect/introspect
  • /connect/endsession
  • /connect/endsession-callback

这些端点由 useIdentityServer() 挂进去,不是你手工 mapGet() / mapPost() 出来的。

中间件位置

样例把 useIdentityServer() 放在最前面,这个顺序是有意义的:

text
IdentityServer -> StaticFiles -> Routing -> Authentication -> Authorization -> Endpoint

原因很直接:

  • 协议端点先由 Identity Server 中间件接管
  • 登录页、同意页再由静态文件中间件提供
  • 业务端点继续走普通路由、认证和授权链路

另外 useIdentityServer() 内部还会做两件事:

  • useCors()
  • 检查 IdentityServerOptions 是否已经注册

所以它不是一个“纯路由映射”扩展,而是真正的协议中间件入口。

授权码流程时序

samples/client 使用 oidc-client-ts,配置如下:

  • authority = "http://localhost:5000"
  • client_id = "interactive.public"
  • response_type = "code"
  • scope = "openid profile offline_access"
  • redirect_uri = "http://127.0.0.1:3000/callback.html"

整条交互链路如下:

这里有两个页面不是协议端点,而是样例自己提供的交互页面:

  • /account/login.html
  • /account/consent.html

对应的处理端点则是:

  • POST /account/login
  • POST /account/consent/data
  • POST /account/consent

访问受保护 API 的时序

前端拿到 access_token 后,会调用 http://127.0.0.1:8080/order

samples/api 的配置是:

  • JWT Bearer 认证
  • authority = "http://127.0.0.1:5000"
  • validAudiences = ["order"]
  • 默认授权策略要求 scope = "api"

对应执行链路如下:

这里要注意两个约束是分开的:

  • validAudiences = ["order"] 解决“这个 token 是不是发给当前 API 的”
  • 默认授权策略 requireClaim("scope", "api") 解决“这个 token 有没有访问当前资源的权限”

样例里的几个扩展点

ProfileService

samples/server/src/services/ProfileService.cj 负责把用户资料转成 claim。样例里返回了:

  • name
  • email
  • picture

这意味着 userinfo 和身份令牌里的 profile 类声明,最终都可以从这里扩展。

ResourceOwnerPasswordValidator

samples/server/src/validators/ResourceOwnerPasswordValidator.cj 演示了密码模式的接入点。

它接收 usernamepassword,验证成功后返回 IdentityUser("1024")。样例里用户名密码是硬编码的:

  • admin
  • 1024

EmailExtensionGrantValidator / PhoneExtensionGrantValidator

这两个类展示的是扩展授权类型,不需要改协议中间件本身,只要注册校验器即可:

  • grantType = "email"
  • grantType = "phone"

它们分别从请求参数里读取:

  • email + code
  • phone + code

验证通过后同样返回 IdentityUser("1024")

这正是这个架构最有意思的地方:协议端点是统一的,但授权方式和用户装载逻辑是可插拔的。

样例服务端同时用了两套认证:

  • Cookie
  • LocalApi

它们解决的问题不同:

  • Cookie 用于浏览器登录态和同意页
  • LocalApi 用于当前 Identity Server 自己暴露的本地 API 认证

LocalApiAuthenticationHandler 的执行方式是:

  1. 从请求里解析 token。
  2. 调用 ITokenValidator.validateAccessToken(token)
  3. 把结果 claim 写入 ClaimsIdentity(options.authenticationType)
  4. 返回 AuthenticationTicket,认证方案名固定为 "local"

所以 LocalApi 不是外部 API 的 Bearer 认证替代品,而是 Identity Server 宿主进程内部的本地访问方案。

可以直接照着样例理解的几个结论

1. addIdentityServer(...) 注册的是整套协议能力

它不只是一个配置对象,还会继续挂上:

  • validators
  • default endpoints
  • response generators
  • consent / refresh_token / authorization_code 的内存存储

2. useIdentityServer() 是协议入口,不是普通业务路由

协议请求先被它接住,只有不是协议路径的请求才会继续落到静态文件或业务端点。

3. 登录页和同意页是普通页面,但它们驱动的是标准 OIDC 流程

也就是说,页面可以换,协议端点不需要换。

4. 资源服务器不需要自己理解授权码流程

samples/api 只需要:

  • 信任 authority
  • 验证 aud
  • 再做自己的授权策略判断

它只消费 access token,不参与登录交互。