feat: sdk migration in progress

This commit is contained in:
allanice001
2025-11-02 13:19:30 +00:00
commit 0d10d42442
492 changed files with 71067 additions and 0 deletions

38
internal/auth/hash.go Normal file
View File

@@ -0,0 +1,38 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/alexedwards/argon2id"
)
func SHA256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
var argonParams = &argon2id.Params{
Memory: 64 * 1024, // 64MB
Iterations: 3,
Parallelism: 2,
SaltLength: 16,
KeyLength: 32,
}
func HashSecretArgon2id(plain string) (string, error) {
return argon2id.CreateHash(plain, argonParams)
}
func VerifySecretArgon2id(encodedHash, plain string) (bool, error) {
if encodedHash == "" {
return false, errors.New("empty hash")
}
return argon2id.ComparePasswordAndHash(plain, encodedHash)
}
func NotExpired(expiresAt *time.Time) bool {
return expiresAt == nil || time.Now().Before(*expiresAt)
}

42
internal/auth/issue.go Normal file
View File

@@ -0,0 +1,42 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
// URL-safe, no padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// IssueUserAPIKey creates a single-token user API key (X-API-KEY)
func IssueUserAPIKey(db *gorm.DB, userID uuid.UUID, name string, ttl *time.Duration) (plaintext string, rec models.APIKey, err error) {
plaintext, err = randomToken(32)
if err != nil {
return "", models.APIKey{}, err
}
rec = models.APIKey{
Name: name,
Scope: "user",
UserID: &userID,
KeyHash: SHA256Hex(plaintext), // deterministic lookup
}
if ttl != nil {
ex := time.Now().Add(*ttl)
rec.ExpiresAt = &ex
}
if err = db.Create(&rec).Error; err != nil {
return "", models.APIKey{}, err
}
return plaintext, rec, nil
}

View File

@@ -0,0 +1,71 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"encoding/base64"
"fmt"
"math/big"
)
// base64url (no padding)
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// convert small int (RSA exponent) to big-endian bytes
func fromInt(i int) []byte {
var x big.Int
x.SetInt64(int64(i))
return x.Bytes()
}
// --- public accessors for JWKS ---
// KeyMeta is a minimal metadata view exposed for JWKS rendering.
type KeyMeta struct {
Alg string
}
// MetaFor returns minimal metadata (currently the alg) for a given kid.
// If not found, returns zero value (Alg == "").
func MetaFor(kid string) KeyMeta {
kc.mu.RLock()
defer kc.mu.RUnlock()
if m, ok := kc.meta[kid]; ok {
return KeyMeta{Alg: m.Alg}
}
return KeyMeta{}
}
// KcCopy invokes fn with a shallow copy of the public key map (kid -> public key instance).
// Useful to iterate without holding the lock during JSON building.
func KcCopy(fn func(map[string]interface{})) {
kc.mu.RLock()
defer kc.mu.RUnlock()
out := make(map[string]interface{}, len(kc.pub))
for kid, pk := range kc.pub {
out[kid] = pk
}
fmt.Println(out)
fn(out)
}
// PubToJWK converts a parsed public key into bare JWK parameters + kty.
// - RSA: returns n/e (base64url) and kty="RSA"
// - Ed25519: returns x (base64url) and kty="OKP"
func PubToJWK(_kid, _alg string, pub any) (map[string]string, string) {
switch k := pub.(type) {
case *rsa.PublicKey:
return map[string]string{
"n": b64url(k.N.Bytes()),
"e": b64url(fromInt(k.E)),
}, "RSA"
case ed25519.PublicKey:
return map[string]string{
"x": b64url([]byte(k)),
}, "OKP"
default:
return nil, ""
}
}

View File

@@ -0,0 +1,55 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type IssueOpts struct {
Subject string
Issuer string
Audience string
TTL time.Duration
Claims map[string]any // extra app claims
}
func IssueAccessToken(opts IssueOpts) (string, error) {
kc.mu.RLock()
defer kc.mu.RUnlock()
if kc.selPriv == nil || kc.selKid == "" || kc.selAlg == "" {
return "", errors.New("no active signing key")
}
claims := jwt.MapClaims{
"iss": opts.Issuer,
"aud": opts.Audience,
"sub": opts.Subject,
"iat": time.Now().Unix(),
"exp": time.Now().Add(opts.TTL).Unix(),
}
for k, v := range opts.Claims {
claims[k] = v
}
var method jwt.SigningMethod
switch kc.selAlg {
case "RS256":
method = jwt.SigningMethodRS256
case "RS384":
method = jwt.SigningMethodRS384
case "RS512":
method = jwt.SigningMethodRS512
case "EdDSA":
method = jwt.SigningMethodEdDSA
default:
return "", errors.New("unsupported alg")
}
token := jwt.NewWithClaims(method, claims)
token.Header["kid"] = kc.selKid
return token.SignedString(kc.selPriv)
}

138
internal/auth/jwt_signer.go Normal file
View File

@@ -0,0 +1,138 @@
package auth
import (
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"sync"
"time"
"github.com/glueops/autoglue/internal/keys"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
type keyCache struct {
mu sync.RWMutex
pub map[string]interface{} // kid -> public key object
meta map[string]models.SigningKey
selKid string
selAlg string
selPriv any
}
var kc keyCache
// Refresh loads active keys into memory. Call on startup and periodically (ticker/cron).
func Refresh(db *gorm.DB, encKeyB64 string) error {
var rows []models.SigningKey
if err := db.Where("is_active = true AND (expires_at IS NULL OR expires_at > ?)", time.Now()).
Order("created_at desc").Find(&rows).Error; err != nil {
return err
}
pub := make(map[string]interface{}, len(rows))
meta := make(map[string]models.SigningKey, len(rows))
var selKid string
var selAlg string
var selPriv any
for i, r := range rows {
// parse public
block, _ := pem.Decode([]byte(r.PublicPEM))
if block == nil {
continue
}
var pubKey any
switch r.Alg {
case "RS256", "RS384", "RS512":
pubKey, _ = x509.ParsePKCS1PublicKey(block.Bytes)
if pubKey == nil {
// also allow PKIX format
if k, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
pubKey = k
}
}
case "EdDSA":
k, err := x509.ParsePKIXPublicKey(block.Bytes)
if err == nil {
if edk, ok := k.(ed25519.PublicKey); ok {
pubKey = edk
}
}
}
if pubKey == nil {
continue
}
pub[r.Kid] = pubKey
meta[r.Kid] = r
// pick first row as current signer (most recent because of order desc)
if i == 0 {
privPEM := r.PrivatePEM
// decrypt if necessary
if len(privPEM) > 10 && privPEM[:10] == "enc:aesgcm" {
pt, err := keysDecrypt(encKeyB64, privPEM)
if err != nil {
continue
}
privPEM = string(pt)
}
blockPriv, _ := pem.Decode([]byte(privPEM))
if blockPriv == nil {
continue
}
switch r.Alg {
case "RS256", "RS384", "RS512":
if k, err := x509.ParsePKCS1PrivateKey(blockPriv.Bytes); err == nil {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
} else if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(*rsa.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
case "EdDSA":
if kAny, err := x509.ParsePKCS8PrivateKey(blockPriv.Bytes); err == nil {
if k, ok := kAny.(ed25519.PrivateKey); ok {
selPriv = k
selAlg = r.Alg
selKid = r.Kid
}
}
}
}
}
kc.mu.Lock()
defer kc.mu.Unlock()
kc.pub = pub
kc.meta = meta
kc.selKid = selKid
kc.selAlg = selAlg
kc.selPriv = selPriv
return nil
}
func keysDecrypt(encKey, enc string) ([]byte, error) {
return keysDecryptImpl(encKey, enc)
}
// indirection for same package
var keysDecryptImpl = func(encKey, enc string) ([]byte, error) {
return nil, errors.New("not wired")
}
// Wire up from keys package
func init() {
keysDecryptImpl = keysDecryptShim
}
func keysDecryptShim(encKey, enc string) ([]byte, error) {
return keys.Decrypt(encKey, enc)
}

View File

@@ -0,0 +1,56 @@
package auth
import (
"github.com/glueops/autoglue/internal/config"
"github.com/glueops/autoglue/internal/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ValidateJWT verifies RS256/RS384/RS512/EdDSA tokens using the in-memory key cache.
// It honors kid when present, and falls back to any active key.
func ValidateJWT(tokenStr string, db *gorm.DB) *models.User {
cfg, _ := config.Load()
parser := jwt.NewParser(
jwt.WithIssuer(cfg.JWTIssuer),
jwt.WithAudience(cfg.JWTAudience),
jwt.WithValidMethods([]string{"RS256", "RS384", "RS512", "EdDSA"}),
)
token, err := parser.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// Resolve by kid first
kid, _ := t.Header["kid"].(string)
kc.mu.RLock()
defer kc.mu.RUnlock()
if kid != "" {
if k, ok := kc.pub[kid]; ok {
return k, nil
}
}
// Fallback: try first active key
for _, k := range kc.pub {
return k, nil
}
return nil, jwt.ErrTokenUnverifiable
})
if err != nil || !token.Valid {
return nil
}
claims, _ := token.Claims.(jwt.MapClaims)
sub, _ := claims["sub"].(string)
uid, err := uuid.Parse(sub)
if err != nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", uid).Error; err != nil {
return nil
}
return &u
}

105
internal/auth/refresh.go Normal file
View File

@@ -0,0 +1,105 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
"github.com/glueops/autoglue/internal/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
// random opaque token (returned to client once)
func generateOpaqueToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
type RefreshPair struct {
Plain string
Record models.RefreshToken
}
// Issue a new refresh token (new family if familyID == nil)
func IssueRefreshToken(db *gorm.DB, userID uuid.UUID, ttl time.Duration, familyID *uuid.UUID) (RefreshPair, error) {
plain, err := generateOpaqueToken(32)
if err != nil {
return RefreshPair{}, err
}
hash, err := HashSecretArgon2id(plain)
if err != nil {
return RefreshPair{}, err
}
fid := uuid.New()
if familyID != nil {
fid = *familyID
}
rec := models.RefreshToken{
UserID: userID,
FamilyID: fid,
TokenHash: hash,
ExpiresAt: time.Now().Add(ttl),
}
if err := db.Create(&rec).Error; err != nil {
return RefreshPair{}, err
}
return RefreshPair{Plain: plain, Record: rec}, nil
}
// ValidateRefreshToken refresh token; returns record if valid & not revoked/expired
func ValidateRefreshToken(db *gorm.DB, plain string) (*models.RefreshToken, error) {
if plain == "" {
return nil, errors.New("empty")
}
// var rec models.RefreshToken
// We can't query by hash w/ Argon; scan candidates by expiry window. Keep small TTL (e.g. 30d).
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Find(&[]models.RefreshToken{}).Error; err != nil {
return nil, err
}
// Better: add a prefix column to narrow scan; omitted for brevity.
// Pragmatic approach: single SELECT per token:
// Add a TokenHashSHA256 column for deterministic lookup if you want O(1). (Optional)
// Minimal: iterate limited set; for simplicity we fetch by created window:
var recs []models.RefreshToken
if err := db.Where("expires_at > ? AND revoked_at IS NULL", time.Now()).
Order("created_at desc").Limit(500).Find(&recs).Error; err != nil {
return nil, err
}
for _, r := range recs {
ok, _ := VerifySecretArgon2id(r.TokenHash, plain)
if ok {
return &r, nil
}
}
return nil, errors.New("invalid")
}
// RevokeFamily revokes all tokens in a family (logout everywhere)
func RevokeFamily(db *gorm.DB, familyID uuid.UUID) error {
now := time.Now()
return db.Model(&models.RefreshToken{}).
Where("family_id = ? AND revoked_at IS NULL", familyID).
Update("revoked_at", &now).Error
}
// RotateRefreshToken replaces one token with a fresh one within the same family
func RotateRefreshToken(db *gorm.DB, used *models.RefreshToken, ttl time.Duration) (RefreshPair, error) {
// revoke the used token (one-time use)
now := time.Now()
if err := db.Model(&models.RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", used.ID).
Update("revoked_at", &now).Error; err != nil {
return RefreshPair{}, err
}
return IssueRefreshToken(db, used.UserID, ttl, &used.FamilyID)
}

View File

@@ -0,0 +1,88 @@
package auth
import (
"time"
"github.com/glueops/autoglue/internal/models"
"gorm.io/gorm"
)
// ValidateAPIKey validates a single-token user API key sent via X-API-KEY.
func ValidateAPIKey(rawKey string, db *gorm.DB) *models.User {
if rawKey == "" {
return nil
}
digest := SHA256Hex(rawKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
if k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
// Optional: touch last_used_at here if you've added it on the model.
return &u
}
// ValidateAppKeyPair validates a user key/secret pair via X-APP-KEY / X-APP-SECRET.
func ValidateAppKeyPair(appKey, secret string, db *gorm.DB) *models.User {
if appKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(appKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "user", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.UserID == nil {
return nil
}
var u models.User
if err := db.First(&u, "id = ? AND is_disabled = false", *k.UserID).Error; err != nil {
return nil
}
return &u
}
// ValidateOrgKeyPair validates an org key/secret via X-ORG-KEY / X-ORG-SECRET.
func ValidateOrgKeyPair(orgKey, secret string, db *gorm.DB) *models.Organization {
if orgKey == "" || secret == "" {
return nil
}
digest := SHA256Hex(orgKey)
var k models.APIKey
if err := db.
Where("key_hash = ? AND scope = ? AND (expires_at IS NULL OR expires_at > ?)", digest, "org", time.Now()).
First(&k).Error; err != nil {
return nil
}
ok, _ := VerifySecretArgon2id(zeroIfNil(k.SecretHash), secret)
if !ok || k.OrgID == nil {
return nil
}
var o models.Organization
if err := db.First(&o, "id = ?", *k.OrgID).Error; err != nil {
return nil
}
return &o
}
// local helper; avoids nil-deref when comparing secrets
func zeroIfNil(s *string) string {
if s == nil {
return ""
}
return *s
}