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 }