基于 Web API 与 SQLite 实现一个自包含的轻量级 OpenID Connect Provider


为内部工具或小型分布式服务集群提供身份认证,常常陷入一个两难境地。一方面,我们渴望 OpenID Connect (OIDC) 这样的标准化协议,它能与大量现有客户端库和生态系统无缝集成。另一方面,部署和维护 Keycloak、IdentityServer 甚至 Okta 这样的重量级身份提供商 (IdP) 及其所需的 PostgreSQL 或 MySQL 数据库,对于一个轻量级应用来说,其运维成本显得过于高昂。我们真正需要的,是一个单一的、无外部依赖的二进制文件,启动它,就能获得一个功能完备的 OIDC Provider。

这个痛点促使我开始构想一个完全自包含的 IdP 实现。它的核心技术栈选型非常明确:

  1. Web API Backend: 使用 Go 语言及其强大的 net/http 标准库。Go 能够编译成静态链接的单一二进制文件,这完美契合了“无依赖”的目标。
  2. 持久化存储: 使用 SQLite。它是一个基于文件的数据库,可以直接嵌入到 Go 应用程序中,无需独立的数据库服务进程。所有用户、客户端、授权码和令牌信息都将存储在一个 .db 文件中,与应用程序二进制文件一起分发。
  3. 核心协议: 实现 OIDC 的核心流程,特别是授权码流程 (Authorization Code Flow),这是 Web 应用最常用、最安全的认证模式。

这个方案的本质,是用 SQLite 的简便性替换传统 IdP 对外部数据库的依赖,从而创造一个极易部署和管理的认证组件。

第一步:数据模型的基石 - SQLite Schema 设计

在编码之前,首要任务是设计 SQLite 的数据结构。我们需要存储 OIDC 流程中的关键实体:客户端 (Client Applications)、用户 (Users)、授权码 (Authorization Codes) 和刷新令牌 (Refresh Tokens)。

这里的坑在于,必须仔细考虑索引和数据生命周期。授权码和刷新令牌都是有有效期的,需要定期清理。

-- clients.sql: 存储注册到 IdP 的客户端信息
CREATE TABLE IF NOT EXISTS clients (
    id TEXT PRIMARY KEY,               -- 客户端ID, e.g., 'my-web-app'
    secret TEXT NOT NULL,              -- 客户端密钥 (需要哈希存储)
    redirect_uris TEXT NOT NULL,       -- 回调URI列表, JSON array of strings: '["http://localhost:8080/callback"]'
    name TEXT NOT NULL                 -- 客户端应用名称
);

-- users.sql: 存储用户信息
CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,               -- 用户唯一标识 (subject)
    username TEXT NOT NULL UNIQUE,     -- 登录用户名
    password_hash TEXT NOT NULL        -- 哈希后的密码
);

-- auth_codes.sql: 存储一次性的授权码
CREATE TABLE IF NOT EXISTS auth_codes (
    code TEXT PRIMARY KEY,
    client_id TEXT NOT NULL,
    user_id TEXT NOT NULL,
    redirect_uri TEXT NOT NULL,
    scopes TEXT NOT NULL,              -- 授权范围, e.g., 'openid profile email'
    expires_at INTEGER NOT NULL,       -- UNIX timestamp 过期时间
    used INTEGER NOT NULL DEFAULT 0,   -- 是否已被使用 (0: false, 1: true)
    FOREIGN KEY(client_id) REFERENCES clients(id),
    FOREIGN KEY(user_id) REFERENCES users(id)
);

-- refresh_tokens.sql: 存储刷新令牌,用于获取新的 access token
CREATE TABLE IF NOT EXISTS refresh_tokens (
    token TEXT PRIMARY KEY,
    client_id TEXT NOT NULL,
    user_id TEXT NOT NULL,
    scopes TEXT NOT NULL,
    expires_at INTEGER NOT NULL,
    revoked INTEGER NOT NULL DEFAULT 0, -- 是否已被吊销
    FOREIGN KEY(client_id) REFERENCES clients(id),
    FOREIGN KEY(user_id) REFERENCES users(id)
);

-- 创建索引以加速查询
CREATE INDEX IF NOT EXISTS idx_auth_codes_expires_at ON auth_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);

第二步:配置、启动与核心服务结构

一个生产级的服务必须是可配置的。我们将 IdP 的发行者 URL、密钥和数据库路径等信息通过配置文件管理。

# config.yaml
issuer: "http://localhost:9096"
database:
  path: "./identity.db"
server:
  addr: ":9096"
token:
  signing_key_file: "./keys/private.pem" # RSA 私钥
  duration_minutes: 15 # Access Token 有效期

Go 语言的主程序结构需要整合配置加载、数据库初始化、密钥加载和路由设置。

// main.go
package main

import (
	"crypto/rsa"
	"database/sql"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
	_ "github.com/mattn/go-sqlite3"
	"gopkg.in/yaml.v3"
)

type Config struct {
	Issuer string `yaml:"issuer"`
	Database struct {
		Path string `yaml:"path"`
	} `yaml:"database"`
	Server struct {
		Addr string `yaml:"addr"`
	} `yaml:"server"`
	Token struct {
		SigningKeyFile string `yaml:"signing_key_file"`
		DurationMinutes int   `yaml:"duration_minutes"`
	} `yaml:"token"`
}

type OIDCProvider struct {
	db          *sql.DB
	config      Config
	signingKey  *rsa.PrivateKey
	publicKey   *rsa.PublicKey
	tokenTTL    time.Duration
	logger      *slog.Logger
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	// 1. 加载配置
	cfg, err := loadConfig("config.yaml")
	if err != nil {
		logger.Error("Failed to load config", "error", err)
		os.Exit(1)
	}

	// 2. 加载签名密钥
	signingKey, err := loadPrivateKey(cfg.Token.SigningKeyFile)
	if err != nil {
		logger.Error("Failed to load signing key", "error", err)
		os.Exit(1)
	}

	// 3. 初始化数据库
	db, err := initDB(cfg.Database.Path)
	if err != nil {
		logger.Error("Failed to initialize database", "error", err)
		os.Exit(1)
	}
	defer db.Close()

	provider := &OIDCProvider{
		db:          db,
		config:      cfg,
		signingKey:  signingKey,
		publicKey:   &signingKey.PublicKey,
		tokenTTL:    time.Duration(cfg.Token.DurationMinutes) * time.Minute,
		logger:      logger,
	}

	// 4. 设置路由
	mux := http.NewServeMux()
	mux.HandleFunc("/authorize", provider.handleAuthorize)
	mux.HandleFunc("/token", provider.handleToken)
	mux.HandleFunc("/.well-known/openid-configuration", provider.handleDiscovery)
	mux.HandleFunc("/.well-known/jwks.json", provider.handleJWKS)
    // 简单的登录页面
	mux.HandleFunc("/login", provider.handleLoginPage) 

	logger.Info("Starting OIDC provider", "address", cfg.Server.Addr)
	if err := http.ListenAndServe(cfg.Server.Addr, mux); err != nil {
		logger.Error("Server failed", "error", err)
		os.Exit(1)
	}
}

func loadConfig(path string) (Config, error) {
	var cfg Config
	f, err := os.ReadFile(path)
	if err != nil {
		return cfg, fmt.Errorf("reading config file: %w", err)
	}
	err = yaml.Unmarshal(f, &cfg)
	if err != nil {
		return cfg, fmt.Errorf("unmarshalling config: %w", err)
	}
	return cfg, nil
}

func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
	keyData, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("reading private key file: %w", err)
	}
	privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(keyData)
	if err != nil {
		return nil, fmt.Errorf("parsing private key: %w", err)
	}
	return privateKey, nil
}

func initDB(path string) (*sql.DB, error) {
    // 省略了执行 SQL 文件的代码,实际项目中应从文件加载 schema
	db, err := sql.Open("sqlite3", path)
	if err != nil {
		return nil, err
	}
	// ... execute schema SQLs ...
	return db, nil
}

// ... handler implementations below ...

这段代码奠定了整个服务的基础。一个常见的错误是在 main 函数中堆积所有逻辑。通过 OIDCProvider 结构体,我们将依赖(数据库连接、配置、密钥)注入到处理器中,这有利于后续的单元测试。

第三步:实现授权端点 (/authorize)

这是 OIDC 流程的起点,也是逻辑最复杂的部分。它需要验证客户端请求,与用户交互(登录、授权),最后生成授权码并重定向。

// handlers.go
func (p *OIDCProvider) handleAuthorize(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	clientID := query.Get("client_id")
	redirectURI := query.Get("redirect_uri")
	responseType := query.Get("response_type")
	scope := query.Get("scope")
	state := query.Get("state") // 必须原样返回给客户端

	// 1. 基础验证
	if responseType != "code" {
		http.Error(w, "Unsupported response_type", http.StatusBadRequest)
		return
	}
	if clientID == "" || redirectURI == "" {
		http.Error(w, "client_id and redirect_uri are required", http.StatusBadRequest)
		return
	}
	
	// 2. 验证客户端和回调 URI 是否合法
	var storedRedirectURIs string
	err := p.db.QueryRow("SELECT redirect_uris FROM clients WHERE id = ?", clientID).Scan(&storedRedirectURIs)
	if err != nil {
		if err == sql.ErrNoRows {
			http.Error(w, "Invalid client_id", http.StatusBadRequest)
			return
		}
		p.logger.Error("Database error checking client", "error", err)
		http.Error(w, "Server error", http.StatusInternalServerError)
		return
	}

	var uris []string
	if err := json.Unmarshal([]byte(storedRedirectURIs), &uris); err != nil {
        p.logger.Error("Failed to parse redirect_uris", "client_id", clientID, "error", err)
		http.Error(w, "Server configuration error", http.StatusInternalServerError)
		return
	}
	
	validRedirectURI := false
	for _, uri := range uris {
		if uri == redirectURI {
			validRedirectURI = true
			break
		}
	}
	if !validRedirectURI {
		http.Error(w, "Invalid redirect_uri", http.StatusBadRequest)
		return
	}

	// 3. 检查用户登录状态 (实际项目中通过 session cookie)
	// 此处简化处理:如果未登录,重定向到登录页,并携带原始授权请求参数
	sessionCookie, err := r.Cookie("user_session")
	if err != nil || sessionCookie.Value == "" {
		// 保存原始请求参数,登录后使用
		loginURL := fmt.Sprintf("/login?%s", r.URL.RawQuery)
		http.Redirect(w, r, loginURL, http.StatusFound)
		return
	}
	userID := sessionCookie.Value // 简化:cookie value 即 userID

	// 4. 生成授权码
	authCode := generateRandomString(32)
	expiresAt := time.Now().Add(10 * time.Minute).Unix()

	_, err = p.db.Exec(
		"INSERT INTO auth_codes (code, client_id, user_id, redirect_uri, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
		authCode, clientID, userID, redirectURI, scope, expiresAt,
	)
	if err != nil {
		p.logger.Error("Failed to store auth code", "error", err)
		http.Error(w, "Server error", http.StatusInternalServerError)
		return
	}
	
	// 5. 重定向回客户端
	finalRedirectURL, _ := url.Parse(redirectURI)
	q := finalRedirectURL.Query()
	q.Set("code", authCode)
	if state != "" {
		q.Set("state", state)
	}
	finalRedirectURL.RawQuery = q.Encode()

	http.Redirect(w, r, finalRedirectURL.String(), http.StatusFound)
}

// handleLoginPage 是一个非常简化的登录页面处理器
func (p *OIDCProvider) handleLoginPage(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodGet {
        // 返回一个包含原始查询参数的HTML表单
        w.Header().Set("Content-Type", "text/html")
        fmt.Fprintf(w, `
            <html><body>
                <h1>Login</h1>
                <form method="post">
                    Username: <input type="text" name="username"><br>
                    Password: <input type="password" name="password"><br>
                    <input type="hidden" name="q" value="%s">
                    <input type="submit" value="Login">
                </form>
            </body></html>
        `, html.EscapeString(r.URL.RawQuery))
        return
    }

    if r.Method == http.MethodPost {
        // 省略了用户密码验证逻辑
        // 验证成功后...
        userID := "user123" // 假设验证成功,获得用户 ID
        
        // 设置 session cookie
        http.SetCookie(w, &http.Cookie{
            Name:     "user_session",
            Value:    userID,
            Path:     "/",
            HttpOnly: true,
            MaxAge:   3600, // 1 hour
        })

        // 重定向回 /authorize,此时会因为 cookie 存在而走通流程
        originalQuery := r.FormValue("q")
        http.Redirect(w, r, "/authorize?"+originalQuery, http.StatusFound)
    }
}

这里的核心在于流程控制。请求进来后,先做无状态的参数校验。然后检查会话,如果用户未登录,则打断当前流程,将其导向登录页面,登录成功后再带回原流程。这是一个状态机转换的体现。state 参数在这里至关重要,必须原封不动地返回,以防止 CSRF 攻击。

第四步:实现令牌端点 (/token)

客户端拿到授权码后,会从后端服务器请求 /token 端点,用授权码交换 id_tokenaccess_token

// handlers.go (continued)
func (p *OIDCProvider) handleToken(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	clientID, clientSecret, ok := r.BasicAuth()
	if !ok {
		// 备选方案:从 form body 读取 client_id 和 client_secret
		clientID = r.PostFormValue("client_id")
		clientSecret = r.PostFormValue("client_secret")
	}

	grantType := r.PostFormValue("grant_type")
	code := r.PostFormValue("code")
	redirectURI := r.PostFormValue("redirect_uri")

	// 1. 验证 grant_type
	if grantType != "authorization_code" {
		p.returnTokenError(w, "unsupported_grant_type", "Only authorization_code is supported")
		return
	}

	// 2. 验证客户端凭据
	var storedSecretHash string
	err := p.db.QueryRow("SELECT secret FROM clients WHERE id = ?", clientID).Scan(&storedSecretHash)
	if err != nil { // 涵盖了 sql.ErrNoRows
		p.returnTokenError(w, "invalid_client", "Client authentication failed")
		return
	}
	// 在真实项目中, secret 应该是哈希过的,这里简化为明文对比
	if storedSecretHash != clientSecret {
		p.returnTokenError(w, "invalid_client", "Client authentication failed")
		return
	}
	
	// 3. 验证授权码
	var storedClientID, userID, storedRedirectURI, scopes string
	var expiresAt int64
	var used bool
	tx, err := p.db.Begin()
	if err != nil {
		p.logger.Error("Failed to begin transaction", "error", err)
		p.returnTokenError(w, "server_error", "")
		return
	}
	defer tx.Rollback() // 如果后续操作失败,回滚

	// 使用 FOR UPDATE 锁定行,防止并发下同一 code 被多次使用
	// 注意: SQLite 的并发模型与 Postgres/MySQL 不同, 但事务仍是关键
	err = tx.QueryRow(
		"SELECT client_id, user_id, redirect_uri, scopes, expires_at, used FROM auth_codes WHERE code = ?",
		code,
	).Scan(&storedClientID, &userID, &storedRedirectURI, &scopes, &expiresAt, &used)
	
	if err != nil { // 涵盖了 sql.ErrNoRows
		p.returnTokenError(w, "invalid_grant", "Invalid authorization code")
		return
	}

	if used || time.Now().Unix() > expiresAt || storedClientID != clientID || storedRedirectURI != redirectURI {
		p.returnTokenError(w, "invalid_grant", "Authorization code is invalid, expired, or used")
		return
	}
	
	// 4. 将授权码标记为已使用
	_, err = tx.Exec("UPDATE auth_codes SET used = 1 WHERE code = ?", code)
	if err != nil {
		p.logger.Error("Failed to mark auth code as used", "error", err)
		p.returnTokenError(w, "server_error", "")
		return
	}

	// 5. 生成 JWTs
	now := time.Now()
	idTokenClaims := jwt.MapClaims{
		"iss": p.config.Issuer,
		"sub": userID,
		"aud": clientID,
		"exp": now.Add(p.tokenTTL).Unix(),
		"iat": now.Unix(),
		"nonce": "some-nonce-value", // 应该从 /authorize 请求中获取并验证
	}
	accessTokenClaims := jwt.MapClaims{
		"iss": p.config.Issuer,
		"sub": userID,
		"aud": clientID, 
		"exp": now.Add(p.tokenTTL).Unix(),
		"iat": now.Unix(),
		"scp": scopes,
	}
	
	idToken, err := p.createSignedJWT(idTokenClaims)
	if err != nil {
		p.returnTokenError(w, "server_error", "Failed to sign ID token")
		return
	}
	accessToken, err := p.createSignedJWT(accessTokenClaims)
	if err != nil {
		p.returnTokenError(w, "server_error", "Failed to sign access token")
		return
	}
	
	// 6. 提交事务
	if err := tx.Commit(); err != nil {
		p.logger.Error("Failed to commit transaction", "error", err)
		p.returnTokenError(w, "server_error", "")
		return
	}

	// 7. 返回 Token
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Pragma", "no-cache")
	
	json.NewEncoder(w).Encode(map[string]interface{}{
		"access_token": accessToken,
		"token_type":   "Bearer",
		"expires_in":   int(p.tokenTTL.Seconds()),
		"id_token":     idToken,
	})
}

func (p *OIDCProvider) createSignedJWT(claims jwt.MapClaims) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	token.Header["kid"] = "default-key-id" // Key ID,用于 JWKS
	
	signedString, err := token.SignedString(p.signingKey)
	if err != nil {
		p.logger.Error("Failed to sign token", "error", err)
		return "", err
	}
	return signedString, nil
}

func (p *OIDCProvider) returnTokenError(w http.ResponseWriter, err, desc string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusBadRequest)
	json.NewEncoder(w).Encode(map[string]string{
		"error":             err,
		"error_description": desc,
	})
}

这里的事务处理是关键。从查询授权码到将其标记为已使用,必须在一个原子操作中完成,以防止竞争条件导致同一授权码被交换多次。在真实项目中,使用 SELECT ... FOR UPDATE 是一种常见的悲观锁策略,虽然 SQLite 的并发模型不同,但事务的原子性保证仍然是必须的。

第五步:服务发现与密钥发布

OIDC 客户端需要知道 IdP 的端点 URL(如 /authorize, /token)和用于验证 JWT 签名的公钥。这是通过两个标准端点实现的:

  1. /.well-known/openid-configuration: 服务发现端点,返回一个 JSON,描述所有 OIDC 相关端点的位置和支持的功能。
  2. /.well-known/jwks.json: JSON Web Key Set (JWKS) 端点,发布用于签名 JWT 的公钥。
sequenceDiagram
    participant Client as OIDC Client App
    participant IdP as Self-hosted OIDC Provider
    participant DB as SQLite Database
    participant User

    User->>Client: Clicks "Login"
    Client->>IdP: GET /authorize?client_id=...&redirect_uri=...
    IdP-->>DB: SELECT redirect_uris FROM clients WHERE id=...
    DB-->>IdP: Returns valid URIs
    IdP->>User: Redirects to /login page
    User->>IdP: POST /login with credentials
    IdP-->>DB: SELECT ... FROM users WHERE username=...
    DB-->>IdP: User data
    IdP->>IdP: Verifies password, creates session
    IdP-->>DB: INSERT INTO auth_codes (...) VALUES (...)
    DB-->>IdP: Confirms insertion
    IdP->>Client: Redirect to redirect_uri?code=...&state=...
    
    Note right of Client: User is back at the Client App
    
    Client->>IdP: POST /token (with code, client_id, client_secret)
    IdP-->>DB: BEGIN TRANSACTION
    IdP-->>DB: SELECT ... FROM auth_codes WHERE code=...
    DB-->>IdP: Auth code details
    IdP->>IdP: Verifies code (not used, not expired, client matches)
    IdP-->>DB: UPDATE auth_codes SET used=1 WHERE code=...
    DB-->>IdP: Confirms update
    IdP->>IdP: Generates and signs ID Token & Access Token (JWT)
    IdP-->>DB: COMMIT TRANSACTION
    IdP-->>Client: Returns JSON with tokens

实现这两个端点是直截了当的,主要是构造正确的 JSON 结构。

// handlers.go (continued)
func (p *OIDCProvider) handleDiscovery(w http.ResponseWriter, r *http.Request) {
	discovery := map[string]interface{}{
		"issuer":                                p.config.Issuer,
		"authorization_endpoint":                p.config.Issuer + "/authorize",
		"token_endpoint":                        p.config.Issuer + "/token",
		"jwks_uri":                              p.config.Issuer + "/.well-known/jwks.json",
		"response_types_supported":              []string{"code"},
		"subject_types_supported":               []string{"public"},
		"id_token_signing_alg_values_supported": []string{"RS256"},
		"scopes_supported":                      []string{"openid", "profile", "email"},
		"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(discovery)
}

func (p *OIDCProvider) handleJWKS(w http.ResponseWriter, r *http.Request) {
    // 根据 `crypto/rsa` 公钥构建 JWK
	jwk := map[string]string{
		"kty": "RSA",
		"kid": "default-key-id", // Must match the 'kid' in JWT header
		"use": "sig",
		"alg": "RS256",
		"n":   base64.RawURLEncoding.EncodeToString(p.publicKey.N.Bytes()),
		"e":   base64.RawURLEncoding.EncodeToString(big.NewInt(int64(p.publicKey.E)).Bytes()),
	}
	jwks := map[string][]map[string]string{
		"keys": {jwk},
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(jwks)
}

当前方案的局限性与未来迭代路径

这个基于 Go 和 SQLite 的自包含 OIDC Provider 方案,成功解决了轻量级场景下标准认证的部署难题。它的优势在于极致的简单:一个二进制文件,一个数据库文件,仅此而已。

但这种设计的边界也同样清晰。首先,SQLite 的写入性能在并发请求下会成为瓶颈。它适合读多写少的场景,或者总用户量和并发登录数不高的内部系统。对于大规模、高并发的公网应用,这套方案并不适用。

其次,当前实现仅涵盖了最核心的授权码流程。一个完备的 IdP 还需要支持其他授权类型(如 Client Credentials Grant 用于服务间认证)、令牌吊销、动态客户端注册、更复杂的作用域和声明管理等。Session 管理也过于简化,生产环境需要更安全的基于加密签名的 session 存储。

未来的迭代方向可以有两个:

  1. 功能增强: 在当前架构下,逐步实现令牌刷新、令牌吊销端点,并引入更健壮的会话管理机制。
  2. 架构演进: 保持核心逻辑不变,将数据访问层抽象出来,设计一个存储接口(interface)。这样,应用程序可以根据部署环境,在启动时选择 SQLite 后端或是 PostgreSQL 后端,从而让这套代码能够平滑地从轻量级场景扩展到需要更高性能和可用性的场景。

  目录