mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
feat: sdk migration in progress
This commit is contained in:
38
internal/auth/hash.go
Normal file
38
internal/auth/hash.go
Normal 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
42
internal/auth/issue.go
Normal 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
|
||||
}
|
||||
71
internal/auth/jwks_export.go
Normal file
71
internal/auth/jwks_export.go
Normal 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, ""
|
||||
}
|
||||
}
|
||||
55
internal/auth/jwt_issue.go
Normal file
55
internal/auth/jwt_issue.go
Normal 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
138
internal/auth/jwt_signer.go
Normal 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)
|
||||
}
|
||||
56
internal/auth/jwt_validate.go
Normal file
56
internal/auth/jwt_validate.go
Normal 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
105
internal/auth/refresh.go
Normal 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)
|
||||
}
|
||||
88
internal/auth/validate_keys.go
Normal file
88
internal/auth/validate_keys.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user