Skip to content

Commit adcd2c9

Browse files
sylrclaude
andauthored
feat(auth): backport multi-issuer JWT validation to v2 go-libs (#1980)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e032717 commit adcd2c9

3 files changed

Lines changed: 101 additions & 49 deletions

File tree

libs/go-libs/auth/auth.go

Lines changed: 29 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,22 @@
11
package auth
22

33
import (
4-
"context"
54
"fmt"
65
"net/http"
7-
"os"
86
"strings"
97

108
"github.com/formancehq/stack/libs/go-libs/collectionutils"
119
"github.com/formancehq/stack/libs/go-libs/logging"
1210
"github.com/hashicorp/go-retryablehttp"
13-
"github.com/zitadel/oidc/v2/pkg/client/rp"
1411
"github.com/zitadel/oidc/v2/pkg/oidc"
1512
"github.com/zitadel/oidc/v2/pkg/op"
1613
"go.uber.org/zap"
1714
)
1815

1916
type jwtAuth struct {
20-
logger logging.Logger
21-
httpClient *http.Client
22-
accessTokenVerifier op.AccessTokenVerifier
23-
24-
issuer string
17+
logger logging.Logger
18+
httpClient *http.Client
19+
verifiers map[string]op.AccessTokenVerifier // issuer -> verifier
2520
checkScopes bool
2621
service string
2722
}
@@ -35,17 +30,16 @@ func newOtlpHttpClient(maxRetries int) *http.Client {
3530
func newJWTAuth(
3631
logger logging.Logger,
3732
readKeySetMaxRetries int,
38-
issuer string,
33+
verifiers map[string]op.AccessTokenVerifier,
3934
service string,
4035
checkScopes bool,
4136
) *jwtAuth {
4237
return &jwtAuth{
43-
logger: logger,
44-
httpClient: newOtlpHttpClient(readKeySetMaxRetries),
45-
accessTokenVerifier: nil,
46-
issuer: issuer,
47-
checkScopes: checkScopes,
48-
service: service,
38+
logger: logger,
39+
httpClient: newOtlpHttpClient(readKeySetMaxRetries),
40+
verifiers: verifiers,
41+
checkScopes: checkScopes,
42+
service: service,
4943
}
5044
}
5145

@@ -66,13 +60,28 @@ func (ja *jwtAuth) Authenticate(w http.ResponseWriter, r *http.Request) (bool, e
6660
token := strings.TrimPrefix(authHeader, strings.ToLower(oidc.PrefixBearer))
6761
token = strings.TrimPrefix(token, oidc.PrefixBearer)
6862

69-
accessTokenVerifier, err := ja.getAccessTokenVerifier(r.Context())
70-
if err != nil {
71-
ja.logger.Error("unable to create access token verifier", zap.Error(err))
72-
return false, fmt.Errorf("unable to create access token verifier: %w", err)
63+
// Pre-parse the token to extract the issuer claim, so we can select
64+
// the correct verifier (each issuer has its own key set).
65+
var preClaims oidc.TokenClaims
66+
if _, err := oidc.ParseToken(token, &preClaims); err != nil {
67+
ja.logger.Error("unable to parse token", zap.Error(err))
68+
return false, fmt.Errorf("unable to parse token: %w", err)
7369
}
7470

75-
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](r.Context(), token, accessTokenVerifier)
71+
verifier, ok := ja.verifiers[preClaims.Issuer]
72+
if !ok {
73+
issuers := make([]string, 0, len(ja.verifiers))
74+
for iss := range ja.verifiers {
75+
issuers = append(issuers, iss)
76+
}
77+
ja.logger.Error("untrusted issuer",
78+
zap.String("got", preClaims.Issuer),
79+
zap.Strings("trusted", issuers),
80+
)
81+
return false, fmt.Errorf("issuer does not match: got: %s, trusted: %v", preClaims.Issuer, issuers)
82+
}
83+
84+
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](r.Context(), token, verifier)
7685
if err != nil {
7786
ja.logger.Error("unable to verify access token", zap.Error(err))
7887
return false, fmt.Errorf("unable to verify access token: %w", err)
@@ -97,26 +106,3 @@ func (ja *jwtAuth) Authenticate(w http.ResponseWriter, r *http.Request) (bool, e
97106

98107
return true, nil
99108
}
100-
101-
func (ja *jwtAuth) getAccessTokenVerifier(ctx context.Context) (op.AccessTokenVerifier, error) {
102-
if ja.accessTokenVerifier == nil {
103-
//discoveryConfiguration, err := client.Discover(ja.Issuer, ja.httpClient)
104-
//if err != nil {
105-
// return nil, err
106-
//}
107-
108-
// todo: ugly quick fix
109-
authServicePort := "8080"
110-
if fromEnv := os.Getenv("AUTH_SERVICE_PORT"); fromEnv != "" {
111-
authServicePort = fromEnv
112-
}
113-
keySet := rp.NewRemoteKeySet(ja.httpClient, fmt.Sprintf("http://auth:%s/keys", authServicePort))
114-
115-
ja.accessTokenVerifier = op.NewAccessTokenVerifier(
116-
os.Getenv("STACK_PUBLIC_URL")+"/api/auth",
117-
keySet,
118-
)
119-
}
120-
121-
return ja.accessTokenVerifier, nil
122-
}

libs/go-libs/auth/cli.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,42 @@ import (
99
const (
1010
AuthEnabled = "auth-enabled"
1111
AuthIssuerFlag = "auth-issuer"
12+
AuthIssuersFlag = "auth-issuers"
1213
AuthReadKeySetMaxRetriesFlag = "auth-read-key-set-max-retries"
1314
AuthCheckScopesFlag = "auth-check-scopes"
1415
AuthServiceFlag = "auth-service"
1516
)
1617

1718
func InitAuthFlags(flags *flag.FlagSet) {
1819
flags.Bool(AuthEnabled, false, "Enable auth")
19-
flags.String(AuthIssuerFlag, "", "Issuer")
20+
flags.String(AuthIssuerFlag, "", "Issuer (single issuer, for backward compatibility)")
21+
flags.StringSlice(AuthIssuersFlag, nil, "Trusted issuers (comma-separated, e.g. --auth-issuers=https://issuer1,https://issuer2)")
2022
flags.Int(AuthReadKeySetMaxRetriesFlag, 10, "ReadKeySetMaxRetries")
2123
flags.Bool(AuthCheckScopesFlag, false, "CheckScopes")
2224
flags.String(AuthServiceFlag, "", "Service")
2325
}
2426

2527
func CLIAuthModule() fx.Option {
28+
authIssuer := viper.GetString(AuthIssuerFlag)
29+
authIssuers := viper.GetStringSlice(AuthIssuersFlag)
30+
31+
// Merge --auth-issuer into --auth-issuers for backward compatibility
32+
if authIssuer != "" {
33+
found := false
34+
for _, iss := range authIssuers {
35+
if iss == authIssuer {
36+
found = true
37+
break
38+
}
39+
}
40+
if !found {
41+
authIssuers = append(authIssuers, authIssuer)
42+
}
43+
}
44+
2645
return Module(ModuleConfig{
2746
Enabled: viper.GetBool(AuthEnabled),
28-
Issuer: viper.GetString(AuthIssuerFlag),
47+
Issuers: authIssuers,
2948
ReadKeySetMaxRetries: viper.GetInt(AuthReadKeySetMaxRetriesFlag),
3049
CheckScopes: viper.GetBool(AuthCheckScopesFlag),
3150
Service: viper.GetString(AuthServiceFlag),

libs/go-libs/auth/module.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,55 @@
11
package auth
22

33
import (
4+
"errors"
5+
"net/http"
6+
"time"
7+
48
"github.com/formancehq/stack/libs/go-libs/logging"
9+
"github.com/hashicorp/go-retryablehttp"
10+
"github.com/zitadel/oidc/v2/pkg/client"
11+
"github.com/zitadel/oidc/v2/pkg/client/rp"
12+
"github.com/zitadel/oidc/v2/pkg/op"
513
"go.uber.org/fx"
614
)
715

816
type ModuleConfig struct {
917
Enabled bool
10-
Issuer string
18+
Issuers []string
1119
ReadKeySetMaxRetries int
1220
CheckScopes bool
1321
Service string
22+
23+
// Deprecated: use Issuers instead.
24+
Issuer string
25+
}
26+
27+
func (cfg ModuleConfig) resolveIssuers() []string {
28+
issuers := cfg.Issuers
29+
if cfg.Issuer != "" {
30+
found := false
31+
for _, iss := range issuers {
32+
if iss == cfg.Issuer {
33+
found = true
34+
break
35+
}
36+
}
37+
if !found {
38+
issuers = append(issuers, cfg.Issuer)
39+
}
40+
}
41+
return issuers
1442
}
1543

1644
func Module(cfg ModuleConfig) fx.Option {
1745
options := make([]fx.Option, 0)
1846

47+
issuers := cfg.resolveIssuers()
48+
49+
if cfg.Enabled && len(issuers) == 0 {
50+
return fx.Error(errors.New("auth is enabled but no issuers are configured"))
51+
}
52+
1953
options = append(options,
2054
fx.Provide(func() Auth {
2155
return NewNoAuth()
@@ -24,14 +58,27 @@ func Module(cfg ModuleConfig) fx.Option {
2458

2559
if cfg.Enabled {
2660
options = append(options,
27-
fx.Decorate(func(logger logging.Logger) Auth {
61+
fx.Decorate(func(logger logging.Logger) (Auth, error) {
62+
retryClient := retryablehttp.NewClient()
63+
retryClient.RetryMax = cfg.ReadKeySetMaxRetries
64+
discoveryHTTPClient := retryClient.StandardClient()
65+
66+
verifiers := make(map[string]op.AccessTokenVerifier, len(issuers))
67+
for _, issuer := range issuers {
68+
discovery, err := client.Discover(issuer, discoveryHTTPClient)
69+
if err != nil {
70+
return nil, err
71+
}
72+
keySet := rp.NewRemoteKeySet(&http.Client{Timeout: 10 * time.Second}, discovery.JwksURI)
73+
verifiers[issuer] = op.NewAccessTokenVerifier(issuer, keySet)
74+
}
2875
return newJWTAuth(
2976
logger,
3077
cfg.ReadKeySetMaxRetries,
31-
cfg.Issuer,
78+
verifiers,
3279
cfg.Service,
3380
cfg.CheckScopes,
34-
)
81+
), nil
3582
}),
3683
)
3784
}

0 commit comments

Comments
 (0)