feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
package oauth2
import "errors"
// JSON 登录签发授权码时与 Authorize 对齐校验。
var (
ErrInvalidClient = errors.New("oauth2: invalid client_id")
ErrInvalidRedirectURI = errors.New("oauth2: invalid redirect_uri")
ErrPKCERequired = errors.New("oauth2: invalid code_challenge or code_challenge_method")
)
+28
View File
@@ -0,0 +1,28 @@
package oauth2
import "github.com/gin-gonic/gin"
// Handler 绑定 Gin 与 Service。
type Handler struct {
svc *Service
}
// NewHandler 构造。
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
// Authorize GET /oauth/authorize
func (h *Handler) Authorize(c *gin.Context) {
h.svc.Authorize(c)
}
// Token POST /oauth/token
func (h *Handler) Token(c *gin.Context) {
h.svc.Token(c)
}
// Introspect POST /oauth/introspect
func (h *Handler) Introspect(c *gin.Context) {
h.svc.Introspect(c)
}
+64
View File
@@ -0,0 +1,64 @@
package oauth2
import "time"
// OAuthClient oauth_client
type OAuthClient struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
ClientID string `gorm:"size:64;not null;uniqueIndex"`
ClientSecretHash *string `gorm:"size:255"`
RedirectURIsJSON string `gorm:"column:redirect_uris;type:text;not null"`
IsPublic bool `gorm:"not null;default:true"`
CreatedAt time.Time `gorm:"not null"`
}
func (OAuthClient) TableName() string { return "oauth_client" }
// OAuthAuthorizationCode oauth_authorization_code
type OAuthAuthorizationCode struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
CodeHash string `gorm:"size:64;not null;uniqueIndex"`
ClientID string `gorm:"size:64;not null"`
RedirectURI string `gorm:"type:text;not null"`
UserID string `gorm:"size:36;not null"`
TenantID string `gorm:"size:36;not null"`
Scope string `gorm:"type:text;not null"`
CodeChallenge string `gorm:"size:128;not null"`
CodeChallengeMethod string `gorm:"size:16;not null"`
ExpiresAt time.Time `gorm:"not null"`
Used bool `gorm:"not null;default:false"`
CreatedAt time.Time `gorm:"not null"`
}
func (OAuthAuthorizationCode) TableName() string { return "oauth_authorization_code" }
// OAuthAccessToken oauth_access_token
type OAuthAccessToken struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
TokenHash string `gorm:"size:64;not null;uniqueIndex"`
ClientID string `gorm:"size:64;not null"`
UserID string `gorm:"size:36;not null"`
TenantID string `gorm:"size:36;not null"`
Scope string `gorm:"type:text;not null"`
ExpiresAt time.Time `gorm:"not null"`
RevokedAt *time.Time `gorm:""`
CreatedAt time.Time `gorm:"not null"`
}
func (OAuthAccessToken) TableName() string { return "oauth_access_token" }
// OAuthRefreshToken oauth_refresh_token
type OAuthRefreshToken struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
TokenHash string `gorm:"size:64;not null;uniqueIndex"`
AccessTokenID string `gorm:"size:36;not null;index"`
ClientID string `gorm:"size:64;not null"`
UserID string `gorm:"size:36;not null"`
TenantID string `gorm:"size:36;not null"`
Scope string `gorm:"type:text;not null"`
ExpiresAt time.Time `gorm:"not null"`
RevokedAt *time.Time `gorm:""`
CreatedAt time.Time `gorm:"not null"`
}
func (OAuthRefreshToken) TableName() string { return "oauth_refresh_token" }
+23
View File
@@ -0,0 +1,23 @@
package oauth2
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"strings"
)
// VerifyPKCES256 校验 code_verifier 是否与 code_challengeS256)一致。
func VerifyPKCES256(codeVerifier, codeChallenge string) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
}
sum := sha256.Sum256([]byte(codeVerifier))
expected := base64.RawURLEncoding.EncodeToString(sum[:])
return subtle.ConstantTimeCompare([]byte(expected), []byte(codeChallenge)) == 1
}
// NormalizeCodeChallengeMethod 返回小写方法名;仅支持 S256(OAuth 2.1 推荐)。
func NormalizeCodeChallengeMethod(m string) string {
return strings.TrimSpace(strings.ToLower(m))
}
+341
View File
@@ -0,0 +1,341 @@
package oauth2
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"giter.top/smart/internal/auth/session"
"giter.top/smart/pkg/config"
"giter.top/smart/pkg/security"
"github.com/gin-gonic/gin"
)
// Service OAuth2 授权码 + PKCE + opaque token。
type Service struct {
cfg *config.Config
store *Store
sess *session.Store
}
// NewService 构造。
func NewService(cfg *config.Config, store *Store, sess *session.Store) *Service {
return &Service{cfg: cfg, store: store, sess: sess}
}
func (s *Service) durations() (authCode, access, refresh time.Duration) {
authCode = s.cfg.Auth.OAuth2.AuthCodeTTL
if authCode == 0 {
authCode = 120 * time.Second
}
access = s.cfg.Auth.OAuth2.AccessTokenTTL
if access == 0 {
access = 15 * time.Minute
}
refresh = s.cfg.Auth.OAuth2.RefreshTokenTTL
if refresh == 0 {
refresh = 720 * time.Hour
}
return authCode, access, refresh
}
// IssueAuthorizationCodeAfterPasswordAuth 在已通过用户名密码校验的上下文中签发 PKCE 绑定授权码(与 Authorize 中 CreateAuthorizationCode 一致)。
func (s *Service) IssueAuthorizationCodeAfterPasswordAuth(ctx context.Context, clientID, redirectURI, userID, tenantID, scope, codeChallenge, challengeMethod string) (codePlain string, err error) {
if scope == "" {
scope = "openid"
}
method := NormalizeCodeChallengeMethod(challengeMethod)
if codeChallenge == "" || method != "s256" {
return "", ErrPKCERequired
}
cli, err := s.store.GetClientByClientID(ctx, clientID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return "", ErrInvalidClient
}
return "", err
}
uris, err := ParseRedirectURIs(cli.RedirectURIsJSON)
if err != nil || !RedirectURIMatch(uris, redirectURI) {
return "", ErrInvalidRedirectURI
}
codePlain, err = security.RandomURLSafe(32)
if err != nil {
return "", err
}
codeTTL, _, _ := s.durations()
exp := time.Now().Add(codeTTL)
if err := s.store.CreateAuthorizationCode(ctx, codePlain, clientID, redirectURI, userID, tenantID, scope, codeChallenge, "S256", exp); err != nil {
return "", err
}
return codePlain, nil
}
func (s *Service) publicAuthorizeURL(c *gin.Context) string {
base := strings.TrimRight(s.cfg.Auth.PublicBaseURL, "/")
if base == "" {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
if xf := c.GetHeader("X-Forwarded-Proto"); xf == "https" {
scheme = "https"
}
base = scheme + "://" + c.Request.Host
}
return base + "/oauth/authorize?" + c.Request.URL.RawQuery
}
// Authorize GET /oauth/authorize
func (s *Service) Authorize(c *gin.Context) {
q := c.Request.URL.Query()
if q.Get("response_type") != "code" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_response_type"})
return
}
clientID := q.Get("client_id")
redirectURI := q.Get("redirect_uri")
state := q.Get("state")
scope := q.Get("scope")
if scope == "" {
scope = "openid"
}
challenge := q.Get("code_challenge")
method := NormalizeCodeChallengeMethod(q.Get("code_challenge_method"))
if challenge == "" || method != "s256" {
s.redirectOAuthError(c, redirectURI, state, "invalid_request", "code_challenge and code_challenge_method=S256 required")
return
}
cli, err := s.store.GetClientByClientID(c.Request.Context(), clientID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_client"})
return
}
uris, err := ParseRedirectURIs(cli.RedirectURIsJSON)
if err != nil || !RedirectURIMatch(uris, redirectURI) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_redirect_uri"})
return
}
sid, err := c.Cookie(s.cfg.Auth.Session.CookieName)
if err != nil || sid == "" {
login := strings.TrimRight(s.cfg.Auth.OAuth2.FrontendLoginURL, "?")
ret := s.publicAuthorizeURL(c)
u, _ := url.Parse(login)
q2 := u.Query()
q2.Set("return_to", ret)
u.RawQuery = q2.Encode()
c.Redirect(http.StatusFound, u.String())
return
}
userID, tenantID, err := s.sess.Get(c.Request.Context(), sid)
if err != nil {
login := strings.TrimRight(s.cfg.Auth.OAuth2.FrontendLoginURL, "?")
ret := s.publicAuthorizeURL(c)
u, _ := url.Parse(login)
q2 := u.Query()
q2.Set("return_to", ret)
u.RawQuery = q2.Encode()
c.Redirect(http.StatusFound, u.String())
return
}
codePlain, err := security.RandomURLSafe(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
codeTTL, _, _ := s.durations()
exp := time.Now().Add(codeTTL)
if err := s.store.CreateAuthorizationCode(c.Request.Context(), codePlain, clientID, redirectURI, userID, tenantID, scope, challenge, "S256", exp); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
redir, err := url.Parse(redirectURI)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_redirect_uri"})
return
}
rq := redir.Query()
rq.Set("code", codePlain)
if state != "" {
rq.Set("state", state)
}
redir.RawQuery = rq.Encode()
c.Redirect(http.StatusFound, redir.String())
}
func (s *Service) redirectOAuthError(c *gin.Context, redirectURI, state, errCode, desc string) {
if redirectURI == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": errCode, "error_description": desc})
return
}
u, e := url.Parse(redirectURI)
if e != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": errCode})
return
}
q := u.Query()
q.Set("error", errCode)
q.Set("error_description", desc)
if state != "" {
q.Set("state", state)
}
u.RawQuery = q.Encode()
c.Redirect(http.StatusFound, u.String())
}
// Token POST /oauth/token
func (s *Service) Token(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
gt := c.PostForm("grant_type")
switch gt {
case "authorization_code":
s.tokenAuthorizationCode(c)
case "refresh_token":
s.tokenRefresh(c)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported_grant_type"})
}
}
func (s *Service) tokenAuthorizationCode(c *gin.Context) {
code := c.PostForm("code")
redirectURI := c.PostForm("redirect_uri")
clientID := c.PostForm("client_id")
verifier := c.PostForm("code_verifier")
if code == "" || redirectURI == "" || clientID == "" || verifier == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
row, err := s.store.ConsumeAuthorizationCode(c.Request.Context(), code, clientID, redirectURI)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant"})
return
}
if !VerifyPKCES256(verifier, row.CodeChallenge) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant", "error_description": "pkce verification failed"})
return
}
_, accessTTL, refreshTTL := s.durations()
accessPlain, err := security.RandomURLSafe(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
refreshPlain, err := security.RandomURLSafe(48)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
if err := s.store.IssueAccessAndRefresh(c.Request.Context(), accessPlain, refreshPlain, clientID, row.UserID, row.TenantID, row.Scope, accessTTL, refreshTTL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
slog.Info("oauth2_token_issued", "grant_type", "authorization_code", "client_id", clientID, "user_id", row.UserID, "tenant_id", row.TenantID, "client_ip", c.ClientIP())
s.jsonAccessToken(c, accessPlain, refreshPlain, accessTTL)
}
func (s *Service) tokenRefresh(c *gin.Context) {
refresh := c.PostForm("refresh_token")
clientID := c.PostForm("client_id")
if refresh == "" || clientID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
_, accessTTL, refreshTTL := s.durations()
newAccess, err := security.RandomURLSafe(32)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
newRefresh, err := security.RandomURLSafe(48)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "server_error"})
return
}
if err := s.store.RotateByRefreshToken(c.Request.Context(), clientID, refresh, newAccess, newRefresh, accessTTL, refreshTTL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_grant"})
return
}
slog.Info("oauth2_token_issued", "grant_type", "refresh_token", "client_id", clientID, "client_ip", c.ClientIP())
s.jsonAccessToken(c, newAccess, newRefresh, accessTTL)
}
func (s *Service) jsonAccessToken(c *gin.Context, access, refresh string, accessTTL time.Duration) {
c.JSON(http.StatusOK, gin.H{
"access_token": access,
"token_type": "Bearer",
"expires_in": int(accessTTL.Seconds()),
"refresh_token": refresh,
})
}
// Introspect POST /oauth/introspectRFC 7662),与 opaque 查表语义一致。
func (s *Service) Introspect(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"active": false})
return
}
tok := c.PostForm("token")
hint := strings.TrimSpace(c.PostForm("token_type_hint"))
if tok == "" {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
ctx := c.Request.Context()
tryRefreshFirst := hint == "refresh_token"
if tryRefreshFirst {
if row, err := s.store.LookupRefreshTokenRow(ctx, tok); err == nil {
slog.Info("oauth2_introspect", "active", true, "token_type", "refresh_token", "client_id", row.ClientID, "sub", row.UserID)
c.JSON(http.StatusOK, gin.H{
"active": true,
"scope": row.Scope,
"client_id": row.ClientID,
"token_type": "refresh_token",
"sub": row.UserID,
"exp": row.ExpiresAt.Unix(),
})
return
}
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
if row, err := s.store.LookupAccessTokenRow(ctx, tok); err == nil {
slog.Info("oauth2_introspect", "active", true, "token_type", "access_token", "client_id", row.ClientID, "sub", row.UserID)
c.JSON(http.StatusOK, gin.H{
"active": true,
"scope": row.Scope,
"client_id": row.ClientID,
"token_type": "access_token",
"sub": row.UserID,
"exp": row.ExpiresAt.Unix(),
})
return
}
if row, err := s.store.LookupRefreshTokenRow(ctx, tok); err == nil {
slog.Info("oauth2_introspect", "active", true, "token_type", "refresh_token", "client_id", row.ClientID, "sub", row.UserID)
c.JSON(http.StatusOK, gin.H{
"active": true,
"scope": row.Scope,
"client_id": row.ClientID,
"token_type": "refresh_token",
"sub": row.UserID,
"exp": row.ExpiresAt.Unix(),
})
return
}
c.JSON(http.StatusOK, gin.H{"active": false})
}
+265
View File
@@ -0,0 +1,265 @@
package oauth2
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"time"
"giter.top/smart/pkg/utils/id"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ErrNotFound 未找到记录。
var ErrNotFound = errors.New("oauth2: not found")
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// Store OAuth 持久化。
type Store struct {
db *gorm.DB
}
// NewStore 创建 Store。
func NewStore(db *gorm.DB) *Store {
return &Store{db: db}
}
// GetClientByClientID 按 client_id 查客户端。
func (st *Store) GetClientByClientID(ctx context.Context, clientID string) (*OAuthClient, error) {
var row OAuthClient
err := st.db.WithContext(ctx).Where("client_id = ?", clientID).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &row, nil
}
// ParseRedirectURIs 解析 redirect_uris JSON 数组。
func ParseRedirectURIs(raw string) ([]string, error) {
var uris []string
if raw == "" {
return nil, errors.New("empty redirect_uris")
}
if err := json.Unmarshal([]byte(raw), &uris); err != nil {
return nil, err
}
return uris, nil
}
// RedirectURIMatch OAuth 2.1 精确匹配。
func RedirectURIMatch(allowed []string, u string) bool {
for _, x := range allowed {
if x == u {
return true
}
}
return false
}
// CreateAuthorizationCode 写入授权码(code 明文仅返回给调用方,库存哈希)。
func (st *Store) CreateAuthorizationCode(ctx context.Context, codePlain string, clientID, redirectURI, userID, tenantID, scope, challenge, method string, expiresAt time.Time) error {
row := OAuthAuthorizationCode{
ID: id.New(),
CodeHash: hashToken(codePlain),
ClientID: clientID,
RedirectURI: redirectURI,
UserID: userID,
TenantID: tenantID,
Scope: scope,
CodeChallenge: challenge,
CodeChallengeMethod: method,
ExpiresAt: expiresAt,
Used: false,
CreatedAt: time.Now(),
}
return st.db.WithContext(ctx).Create(&row).Error
}
// ConsumeAuthorizationCode 校验并一次性消费授权码,返回行数据供发 token。
func (st *Store) ConsumeAuthorizationCode(ctx context.Context, codePlain, clientID, redirectURI string) (*OAuthAuthorizationCode, error) {
h := hashToken(codePlain)
var out *OAuthAuthorizationCode
err := st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row OAuthAuthorizationCode
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("code_hash = ?", h).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if row.Used {
return ErrNotFound
}
if time.Now().After(row.ExpiresAt) {
return ErrNotFound
}
if row.ClientID != clientID || row.RedirectURI != redirectURI {
return ErrNotFound
}
if err := tx.Model(&OAuthAuthorizationCode{}).Where("id = ?", row.ID).Update("used", true).Error; err != nil {
return err
}
out = &row
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// TokenPrincipal opaque access token 解析结果。
type TokenPrincipal struct {
UserID string
TenantID string
Scope string
}
// LookupAccessToken 按明文 access token 查有效记录。
func (st *Store) LookupAccessToken(ctx context.Context, raw string) (*TokenPrincipal, error) {
h := hashToken(raw)
var row OAuthAccessToken
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if time.Now().After(row.ExpiresAt) {
return nil, ErrNotFound
}
return &TokenPrincipal{UserID: row.UserID, TenantID: row.TenantID, Scope: row.Scope}, nil
}
// LookupAccessTokenRow 按明文查 access token 行(自省用)。
func (st *Store) LookupAccessTokenRow(ctx context.Context, raw string) (*OAuthAccessToken, error) {
h := hashToken(raw)
var row OAuthAccessToken
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if time.Now().After(row.ExpiresAt) {
return nil, ErrNotFound
}
return &row, nil
}
// LookupRefreshTokenRow 按明文查 refresh token 行(自省用)。
func (st *Store) LookupRefreshTokenRow(ctx context.Context, raw string) (*OAuthRefreshToken, error) {
h := hashToken(raw)
var row OAuthRefreshToken
err := st.db.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
if time.Now().After(row.ExpiresAt) {
return nil, ErrNotFound
}
return &row, nil
}
// IssueAccessAndRefresh 写入 access + refreshopaque 明文仅调用方返回给客户端)。
func (st *Store) IssueAccessAndRefresh(ctx context.Context, accessPlain, refreshPlain, clientID, userID, tenantID, scope string, accessTTL, refreshTTL time.Duration) error {
now := time.Now()
accessID := id.New()
refreshID := id.New()
at := OAuthAccessToken{
ID: accessID,
TokenHash: hashToken(accessPlain),
ClientID: clientID,
UserID: userID,
TenantID: tenantID,
Scope: scope,
ExpiresAt: now.Add(accessTTL),
CreatedAt: now,
}
rt := OAuthRefreshToken{
ID: refreshID,
TokenHash: hashToken(refreshPlain),
AccessTokenID: accessID,
ClientID: clientID,
UserID: userID,
TenantID: tenantID,
Scope: scope,
ExpiresAt: now.Add(refreshTTL),
CreatedAt: now,
}
return st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&at).Error; err != nil {
return err
}
return tx.Create(&rt).Error
})
}
// RotateByRefreshToken 使用 refresh 换发新 access+refresh,旧令牌作废;client_id 须与注册一致。
func (st *Store) RotateByRefreshToken(ctx context.Context, clientID, refreshPlain, newAccessPlain, newRefreshPlain string, accessTTL, refreshTTL time.Duration) error {
h := hashToken(refreshPlain)
return st.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row OAuthRefreshToken
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND revoked_at IS NULL", h).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if row.ClientID != clientID {
return ErrNotFound
}
if time.Now().After(row.ExpiresAt) {
return ErrNotFound
}
now := time.Now()
if err := tx.Model(&OAuthRefreshToken{}).Where("id = ?", row.ID).Update("revoked_at", now).Error; err != nil {
return err
}
if err := tx.Model(&OAuthAccessToken{}).Where("id = ?", row.AccessTokenID).Update("revoked_at", now).Error; err != nil {
return err
}
newAID := id.New()
newRID := id.New()
at := OAuthAccessToken{
ID: newAID,
TokenHash: hashToken(newAccessPlain),
ClientID: row.ClientID,
UserID: row.UserID,
TenantID: row.TenantID,
Scope: row.Scope,
ExpiresAt: now.Add(accessTTL),
CreatedAt: now,
}
rt := OAuthRefreshToken{
ID: newRID,
TokenHash: hashToken(newRefreshPlain),
AccessTokenID: newAID,
ClientID: row.ClientID,
UserID: row.UserID,
TenantID: row.TenantID,
Scope: row.Scope,
ExpiresAt: now.Add(refreshTTL),
CreatedAt: now,
}
if err := tx.Create(&at).Error; err != nil {
return err
}
return tx.Create(&rt).Error
})
}