Files
alterminal/internal/oidc/jwt.go
T
2026-10-03 12:37:38 +08:00

227 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package oidc
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/jwk"
)
// jwtHeader 為 JWT 的 protected header(RFC 7515 §4.1;alg 固定 RS256,
// kid 對應 JWKS 的金鑰識別碼,typ 標示為 JWT)。
type jwtHeader struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
// AccessTokenClaims 為 Access Token(JWT profile,RFC 9068)的 claims:
// 自包含、不落庫,資源端點(/userinfo)以 JWKS 驗證簽章與效期。Sub 為
// 使用者 ID 的字串形式(OIDC Core §2 的 sub claim),Aud 為 client_id。
type AccessTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
Scope string `json:"scope,omitempty"`
ClientID string `json:"client_id,omitempty"`
}
// idTokenClaims 為 ID Token 的 claims(OIDC Core §2)。Name/Email 等
// 個人資料 claim 僅在授權 scope 含對應值時加入;Nonce 回填授權請求的
// 原值供 RP 綁結(OIDC Core §3.1.3.7.4),AuthTime 為使用者本次
// Session 的建立時間(§2 的 auth_time claim,單位秒)。
type idTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time,omitempty"`
Nonce string `json:"nonce,omitempty"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
}
// ErrInvalidToken 表示 Access Token 無效(格式、簽章、金鑰或效期不符)。
var ErrInvalidToken = errors.New("access token 無效")
// signJWT 以金鑰簽發 RS256 JWT:header 與 claims 各自 JSON 序列化為
// 無填充 base64url,再對兩段連結值以 RSASSA-PKCS1-v1_5 + SHA-256
// 簽章(RFC 7518 §3.3),輸出 header.payload.signature 三段。
func signJWT(key *jwk.SigningKey, claims any) (string, error) {
priv, err := key.PrivateKey()
if err != nil {
return "", fmt.Errorf("parse signing key: %w", err)
}
header, err := json.Marshal(jwtHeader{Alg: jwk.AlgRS256, Kid: key.Kid, Typ: "JWT"})
if err != nil {
return "", fmt.Errorf("marshal jwt header: %w", err)
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal jwt claims: %w", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign jwt: %w", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// currentSigningKey 取最新的使用中簽章金鑰供簽發(輪替時新金鑰在前)。
func currentSigningKey(db *gorm.DB) (*jwk.SigningKey, error) {
var k jwk.SigningKey
if err := db.Where("retired_at IS NULL").Order("created_at DESC").First(&k).Error; err != nil {
return nil, fmt.Errorf("query signing key: %w", err)
}
return &k, nil
}
// subject 為使用者的 sub claim 值:使用者 ID 的十進位字串(OIDC Core
// §2 要求 sub 在 issuer 範圍內穩定且唯一)。
func subject(userID uint) string {
return fmt.Sprintf("%d", userID)
}
// IssueAccessToken 簽發 Access Token(效期 15 分鐘)。
func IssueAccessToken(db *gorm.DB, issuer string, userID uint, app *application.Application, scope string) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
return signJWT(key, AccessTokenClaims{
Iss: issuer,
Sub: subject(userID),
Aud: app.ClientID,
Exp: now.Add(accessTokenTTL).Unix(),
Iat: now.Unix(),
Scope: scope,
ClientID: app.ClientID,
})
}
// IssueIDToken 簽發 ID Token(效期 15 分鐘)。authTime 為使用者
// Session 的建立時間;nonce 為授權請求攜帶的原值(無則空)。個人資料
// claim 依授權 scope 決定(profile:name、preferred_username;email:
// email、email_verified——OIDC Core §5.4)。
func IssueIDToken(db *gorm.DB, issuer string, u *auth.User, app *application.Application, scope, nonce string, authTime time.Time) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
claims := idTokenClaims{
Iss: issuer,
Sub: subject(u.ID),
Aud: app.ClientID,
Exp: now.Add(idTokenTTL).Unix(),
Iat: now.Unix(),
AuthTime: authTime.Unix(),
Nonce: nonce,
}
if scopeHas(scope, "profile") {
claims.Name = u.Name
claims.PreferredUsername = u.Username
}
if scopeHas(scope, "email") {
claims.Email = u.Email
verified := u.EmailVerified
claims.EmailVerified = &verified
}
return signJWT(key, claims)
}
// scopeHas 回傳 scope 集合是否包含 s。
func scopeHas(scope, s string) bool {
for _, f := range strings.Fields(scope) {
if f == s {
return true
}
}
return false
}
// verifyJWTSignature 驗證 JWT 的外層結構與簽章:拆解三段、僅接受 RS256
// 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid 對應的簽章
// 金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰),回傳解碼後的 claims
// JSON,供 VerifyAccessToken 與 verifyIDTokenHint 共用。任何一項不符即回
// 包裹 ErrInvalidToken 的錯誤,不洩漏細節。
func verifyJWTSignature(db *gorm.DB, token string) ([]byte, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構")
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
var h jwtHeader
if err := json.Unmarshal(headerJSON, &h); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
if h.Alg != jwk.AlgRS256 || h.Kid == "" {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "alg")
}
var key jwk.SigningKey
if err := db.Where("kid = ?", h.Kid).First(&key).Error; err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
priv, err := key.PrivateKey()
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, digest[:], sig); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
return payloadJSON, nil
}
// VerifyAccessToken 驗證 Access Token 並回傳其 claims:驗章同
// verifyJWTSignature,另比對 issuer 與效期(OIDC Core §3.1.3.7 的 iss/
// exp 驗證項)。任何一項不符即回 ErrInvalidToken,不洩漏細節。
func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) {
payloadJSON, err := verifyJWTSignature(db, token)
if err != nil {
return nil, err
}
var claims AccessTokenClaims
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
if claims.Iss != issuer {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "issuer")
}
if claims.Exp <= time.Now().Unix() {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "exp")
}
return &claims, nil
}