Skip to content

Commit f69b989

Browse files
author
Tern
committed
feat(jwt): add external JWT validation authentication
Add support for validating JWT tokens issued by external authentication services (e.g., Authelia, Keycloak, GitHub Actions OIDC, GitLab OIDC). - Add JWKS fetching with TTL-based caching - Implement JWT signature and claim validation - Validate standard claims (iss, aud, exp, nbf) - Authenticated users get access to all repositories Assisted-by: GLM 4.7
1 parent 4b7147a commit f69b989

File tree

8 files changed

+508
-7
lines changed

8 files changed

+508
-7
lines changed

go.mod

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
module github.com/dvjn/sorcerer
22

3-
go 1.24
3+
go 1.24.0
4+
5+
toolchain go1.24.13
46

57
require (
8+
github.com/coreos/go-oidc/v3 v3.17.0
69
github.com/go-chi/chi/v5 v5.2.1
710
github.com/knadh/koanf/providers/env v1.1.0
811
github.com/knadh/koanf/providers/structs v1.0.0
912
github.com/knadh/koanf/v2 v2.2.0
1013
github.com/opencontainers/distribution-spec/specs-go v0.0.0-20250220192232-583e014d1541
1114
github.com/rs/zerolog v1.34.0
1215
github.com/tg123/go-htpasswd v1.2.4
13-
golang.org/x/crypto v0.37.0
1416
)
1517

1618
require (
1719
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect
1820
github.com/fatih/structs v1.1.0 // indirect
21+
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
1922
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
2023
github.com/knadh/koanf/maps v0.1.2 // indirect
2124
github.com/mattn/go-colorable v0.1.14 // indirect
2225
github.com/mattn/go-isatty v0.0.20 // indirect
2326
github.com/mitchellh/copystructure v1.2.0 // indirect
2427
github.com/mitchellh/reflectwalk v1.0.2 // indirect
25-
github.com/stretchr/testify v1.10.0 // indirect
28+
golang.org/x/crypto v0.37.0 // indirect
29+
golang.org/x/oauth2 v0.28.0 // indirect
2630
golang.org/x/sys v0.33.0 // indirect
2731
)

go.sum

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
22
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
3+
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
4+
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
35
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
46
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
57
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
68
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
79
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
810
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
911
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
12+
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
13+
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
1014
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
1115
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
1216
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
17+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
18+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1319
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
1420
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
1521
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
@@ -41,10 +47,10 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
4147
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
4248
github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU=
4349
github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0=
44-
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
45-
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
4650
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
4751
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
52+
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
53+
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
4854
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4955
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5056
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

internal/auth/auth.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package auth
22

33
import (
4-
_ "embed"
54
"fmt"
65
"net/http"
76

87
"github.com/dvjn/sorcerer/internal/auth/htpasswd"
8+
"github.com/dvjn/sorcerer/internal/auth/jwt"
99
"github.com/dvjn/sorcerer/internal/auth/no_auth"
1010
"github.com/dvjn/sorcerer/internal/config"
1111
"github.com/go-chi/chi/v5"
@@ -23,6 +23,8 @@ func New(c *config.AuthConfig, logger *zerolog.Logger) (Auth, error) {
2323
return no_auth.New(&c.NoAuth), nil
2424
case config.AuthModeHtpasswd:
2525
return htpasswd.NewHtpasswdAuth(&c.Htpasswd, logger)
26+
case config.AuthModeJWT:
27+
return jwt.NewJWTAuth(c.JWT.JWKSURL, c.JWT.Issuer, c.JWT.Audience, c.JWT.CacheTTL, logger)
2628
default:
2729
return nil, fmt.Errorf("unknown auth mode: %s", c.Mode)
2830
}

internal/auth/jwt/jwks.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package jwt
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"sync"
7+
"time"
8+
9+
"github.com/coreos/go-oidc/v3/oidc"
10+
"github.com/rs/zerolog"
11+
)
12+
13+
// JWKSProvider fetches and caches JWKS public keys with TTL-based caching
14+
type JWKSProvider struct {
15+
jwksURL string
16+
cacheTTL time.Duration
17+
provider *oidc.Provider
18+
mu sync.RWMutex
19+
lastFetch time.Time
20+
logger *zerolog.Logger
21+
}
22+
23+
// NewJWKSProvider creates a new JWKS provider with caching
24+
func NewJWKSProvider(jwksURL string, cacheTTL time.Duration, logger *zerolog.Logger) (*JWKSProvider, error) {
25+
if logger == nil {
26+
return nil, fmt.Errorf("logger is required")
27+
}
28+
29+
p := &JWKSProvider{
30+
jwksURL: jwksURL,
31+
cacheTTL: cacheTTL,
32+
logger: logger,
33+
}
34+
35+
// Initial fetch
36+
if err := p.refreshProvider(context.Background()); err != nil {
37+
return nil, fmt.Errorf("failed to initialize JWKS provider: %w", err)
38+
}
39+
40+
p.logger.Info().
41+
Str("jwks_url", jwksURL).
42+
Str("cache_ttl", cacheTTL.String()).
43+
Msg("jwt jwks provider initialized")
44+
45+
return p, nil
46+
}
47+
48+
// GetProvider returns the OIDC provider, refreshing cache if needed
49+
func (p *JWKSProvider) GetProvider(ctx context.Context) (*oidc.Provider, error) {
50+
p.mu.RLock()
51+
needsRefresh := time.Since(p.lastFetch) > p.cacheTTL
52+
p.mu.RUnlock()
53+
54+
if needsRefresh {
55+
if err := p.refreshProvider(ctx); err != nil {
56+
// If refresh fails, use cached provider if available
57+
p.mu.RLock()
58+
defer p.mu.RUnlock()
59+
if p.provider == nil {
60+
return nil, fmt.Errorf("no cached provider and refresh failed: %w", err)
61+
}
62+
p.logger.Warn().
63+
Err(err).
64+
Str("jwks_url", p.jwksURL).
65+
Msg("jwks refresh failed, using cached keys")
66+
}
67+
}
68+
69+
p.mu.RLock()
70+
defer p.mu.RUnlock()
71+
return p.provider, nil
72+
}
73+
74+
// refreshProvider fetches the JWKS provider from the remote URL
75+
func (p *JWKSProvider) refreshProvider(ctx context.Context) error {
76+
p.mu.Lock()
77+
defer p.mu.Unlock()
78+
79+
provider, err := oidc.NewProvider(ctx, p.jwksURL)
80+
if err != nil {
81+
return fmt.Errorf("failed to fetch JWKS from %s: %w", p.jwksURL, err)
82+
}
83+
84+
p.provider = provider
85+
p.lastFetch = time.Now()
86+
87+
p.logger.Debug().
88+
Str("jwks_url", p.jwksURL).
89+
Msg("jwks provider refreshed")
90+
91+
return nil
92+
}

internal/auth/jwt/jwt.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package jwt
2+
3+
// This file is kept for package-level exports. The main implementation
4+
// is in middleware.go which defines the JWTAuth type and its methods.
5+

0 commit comments

Comments
 (0)