快速开始
本页带你从空目录开始,写一个约 100 行的 Go 程序:它把 wicket 挂到标准库 net/http 上,注册一个后台服务客户端,然后用 curl 拿到一枚 JWT 访问令牌,再通过内省端点确认它有效。
整个示例在 wicket v0.1.4 上逐行跑通过,源码位于仓库的 examples/quickstart/。
准备
| 需要 | 说明 |
|---|---|
| Go 1.27 | wicket 的 go.mod 声明 go 1.27,更低版本无法解析依赖 |
curl | 用于手动调用端点 |
mkdir wicket-quickstart && cd wicket-quickstart
go mod init example.com/quickstart
go get github.com/gonewx/wicket@v0.1.4关于 go.work
如果你的机器上有全局 go.work,拉取依赖时请加 GOWORK=off,避免工作区把 wicket 解析到本地目录。
完整代码
把下面的内容保存为 main.go,然后执行 go mod tidy。
package main
import (
"crypto/rand"
"crypto/rsa"
"log"
"log/slog"
"net/http"
"os"
"github.com/gonewx/godi"
wicket "github.com/gonewx/wicket"
"github.com/gonewx/wicket/config/options"
"github.com/gonewx/wicket/di"
"github.com/gonewx/wicket/hosting"
"github.com/gonewx/wicket/server"
wicketmodels "github.com/gonewx/wicket/models"
"github.com/gonewx/wicket/storage"
"github.com/gonewx/wicket/storage/memory"
"github.com/gonewx/wicket/storage/models"
"github.com/gonewx/wicket/tokens"
"github.com/gonewx/wicket/utils"
"github.com/lestrrat-go/jwx/v3/jwk"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
// 1. Composition root: wires every Wicket service
root, err := di.NewRoot()
if err != nil {
log.Fatal(err)
}
defer root.Close()
// 2. Protocol options: the issuer identifier
opts := godi.GetRequired[*options.WicketOptions](root)
opts.IssuerUri = "http://localhost:5000"
// 3. Register resources and clients
api := models.NewApiScope()
api.Name = "orders.read"
apiSecret := models.NewSecret()
apiSecret.Value = utils.Sha256("api-secret")
orders := models.NewApiResource()
orders.Name = "orders"
orders.Scopes = []string{"orders.read"}
orders.ApiSecrets = []*models.Secret{apiSecret}
secret := models.NewSecret()
secret.Value = utils.Sha256("change-me")
client := models.NewClient()
client.ClientId = "billing-worker"
client.ClientSecrets = []*models.Secret{secret}
client.AllowedScopes = []string{"orders.read"}
if err := client.SetAllowedGrantTypes(wicket.GrantTypesClientCredentials); err != nil {
log.Fatal(err)
}
cfg := root.ServerConfig()
signing, validation := devSigningKey()
cfg.SetSigningCredentialStores(signing)
cfg.SetValidationKeysStores(validation)
cfg.SetApiScopes([]*models.ApiScope{api})
cfg.SetApiResources([]*models.ApiResource{orders})
cfg.SetClients([]*models.Client{client})
cfg.AllowInMemoryFallback() // local development only
cfg.Freeze()
// 4. Mount the middleware
srv, err := server.DefaultServer(root, server.WithLogger(logger))
if err != nil {
log.Fatal(err)
}
app := http.NewServeMux()
app.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("host app\n"))
})
handler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))
log.Println("listening on :5000")
log.Fatal(http.ListenAndServe(":5000", handler))
}
// devSigningKey creates an in-process RSA signing key (development only: a restart means a new key).
func devSigningKey() ([]storage.SigningCredentialStore, []storage.ValidationKeysStore) {
raw, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatal(err)
}
// Use the RFC 7638 thumbprint as kid so it matches the kid published in the JWKS
key, err := jwk.Import(raw)
if err != nil {
log.Fatal(err)
}
if err := jwk.AssignKeyID(key); err != nil {
log.Fatal(err)
}
kid, _ := key.KeyID()
cred, err := tokens.NewSigningCredentials(raw, tokens.WithAlgorithm("RS256"), tokens.WithKid(kid))
if err != nil {
log.Fatal(err)
}
info := &wicketmodels.SecurityKeyInfo{Key: raw.Public(), SigningAlgorithm: cred.Algorithm}
return []storage.SigningCredentialStore{memory.NewMemorySigningCredentialStore(cred)},
[]storage.ValidationKeysStore{memory.NewMemoryValidationKeysStore([]*wicketmodels.SecurityKeyInfo{info})}
}逐段说明
1. 组合根
root, err := di.NewRoot()di.NewRoot() 构造 wicket 的组合根:所有端点处理器、验证器、令牌服务都在这里装配好。它返回 (*Root, error),用完后调用 root.Close() 释放持有的资源(例如注入的数据库连接所对应的 store)。
2. 协议选项
opts := godi.GetRequired[*options.WicketOptions](root)
opts.IssuerUri = "http://localhost:5000"WicketOptions 是协议层的主配置,需在 NewRoot() 之后、UseWicket() 之前修改。IssuerUri 会写进每一枚令牌的 iss 声明,也是发现文档里所有端点 URL 的前缀;生产环境必须是对外可访问的 HTTPS 地址。
自动密钥管理默认关闭,所以示例手动提供了一把进程内的 RSA 签名密钥(见 devSigningKey),通过 ServerConfig 注入。这样最容易理解,但重启即换钥,之前签发的令牌全部无法验签。生产环境的做法见签名密钥。
3. 资源与客户端
示例注册了三样东西:
- API 作用域
orders.read:客户端可以申请的权限单位。 - API 资源
orders:一个受保护的后端服务,它拥有orders.read作用域。令牌的aud声明会写成orders;资源自己的密钥api-secret用于调用内省端点。 - 客户端
billing-worker:只允许client_credentials授权,只能申请orders.read。
客户端密钥以 SHA-256 哈希形式保存(utils.Sha256("change-me")),明文只出现在调用方。
4. ServerConfig 与冻结
cfg := root.ServerConfig()
cfg.SetClients(...)
cfg.AllowInMemoryFallback() // 仅限本地开发
cfg.Freeze()ServerConfig 是宿主向 wicket 注入数据和实现的唯一入口:客户端、资源、签名密钥、各类存储都从这里进。
AllowInMemoryFallback()显式允许未注入的存储回退到内存实现。不调用它时,只要有一个存储未注入,Freeze()就会 panic——这是有意为之,防止生产环境悄悄跑在内存上。Freeze()在装配完成时做一次校验(例如上面的存储检查)。它不会锁住配置——之后调用 setter 仍会生效,所以请把所有注入放在Freeze()之前。
5. 挂载中间件
handler := hosting.ScopeMiddleware(root)(srv.UseWicket()(app))两层包装缺一不可:
hosting.ScopeMiddleware(root)为每个请求创建依赖注入作用域;srv.UseWicket()拦截协议路径(/connect/*、/.well-known/*),其余请求原样交给你的app。
运行并验证
go run .另开一个终端:
发现文档——确认服务已就绪:
curl -s http://localhost:5000/.well-known/openid-configuration签发令牌——客户端凭据走 HTTP Basic 认证:
curl -s -u billing-worker:change-me \
-d grant_type=client_credentials \
-d scope=orders.read \
http://localhost:5000/connect/token{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"expires_in": 3600,
"scope": "orders.read",
"token_type": "Bearer"
}把 access_token 的中段做 base64url 解码,可以看到它携带的声明:
{
"aud": ["orders"],
"client_id": "billing-worker",
"iss": "http://localhost:5000",
"scope": ["orders.read"],
"iat": 1794538592,
"nbf": 1794538592,
"exp": 1794542192
}内省令牌——以 API 资源 orders 的身份调用:
curl -s -u orders:api-secret \
-d token=<上一步的 access_token> \
http://localhost:5000/connect/introspect{ "active": true, "aud": ["orders"], "client_id": "billing-worker", "scope": "orders.read", "...": "..." }宿主路由不受影响:
curl -s http://localhost:5000/
# host app常见问题
| 现象 | 原因 |
|---|---|
令牌端点返回 no signing credential available | 没有可用的签名密钥:要么注入签名凭据,要么正确配置密钥管理 |
{"error":"invalid_client"} | 客户端 ID 或密钥不对,或密钥未用 utils.Sha256 哈希保存 |
{"error":"invalid_scope"} | 申请的作用域不在客户端的 AllowedScopes 中,或未注册对应的 ApiScope |
Freeze() 时 panic:store seam "..." not injected by host and in-memory fallback not declared | 有存储未注入,且未调用 AllowInMemoryFallback() |
下一步
- 核心概念:弄清客户端、资源、作用域与令牌之间的关系
- 嵌入宿主应用:登录页、同意页如何与 wicket 协作
- 授权码 + PKCE:为有用户参与的 Web 与移动应用签发令牌
- 持久化存储:上线前必须完成的存储装配