From e51f9ea374d21060c734e061b8ebdf9cf6c9644e Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 20 Jul 2026 21:29:46 +0200 Subject: [PATCH 1/9] feat(dashboard-api): workspace-admin API skeleton with service JWT auth Add /admin/v1 workspace-admin endpoints (project, member, limits, user purge) to the dashboard OpenAPI contract with 501 stub handlers. Introduce a generic pkg/auth/jwks package (issuer config, OIDC discovery, JWKS-backed JWT verification) extracted from the oidc package, which is now a thin identity-resolution layer. The new AdminJWTAuth security scheme verifies EdDSA service JWTs configured via ADMIN_AUTH_CONFIG, sharing the auth.ProviderConfig shape with AUTH_PROVIDER_CONFIG. --- docs/ARCHITECTURE.md | 6 +- packages/auth/pkg/auth/admin_jwt.go | 75 +++ packages/auth/pkg/auth/admin_jwt_test.go | 122 +++++ .../auth/pkg/auth/{oidc => jwks}/audience.go | 2 +- .../pkg/auth/{oidc => jwks}/audience_test.go | 2 +- .../auth/pkg/auth/{oidc => jwks}/config.go | 2 +- .../pkg/auth/{oidc => jwks}/config_test.go | 2 +- .../pkg/auth/{oidc => jwks}/testserver.go | 7 +- packages/auth/pkg/auth/jwks/verifier.go | 268 ++++++++++ packages/auth/pkg/auth/middleware.go | 23 + packages/auth/pkg/auth/oidc/oidc.go | 225 +-------- packages/auth/pkg/auth/oidc/oidc_test.go | 33 +- .../auth/pkg/auth/provider_config_parse.go | 11 +- packages/auth/pkg/auth/service.go | 6 +- packages/auth/pkg/auth/verifier.go | 17 +- packages/auth/pkg/auth/verifier_test.go | 18 +- .../dashboard-api/internal/api/api.gen.go | 475 ++++++++++++++---- packages/dashboard-api/internal/cfg/model.go | 1 + .../dashboard-api/internal/cfg/model_test.go | 14 +- .../internal/handlers/workspace_admin.go | 40 ++ .../dashboard-api/internal/identity/issuer.go | 6 +- .../internal/identity/issuer_test.go | 24 +- packages/dashboard-api/main.go | 12 +- spec/openapi-dashboard.yml | 244 +++++++++ 24 files changed, 1258 insertions(+), 377 deletions(-) create mode 100644 packages/auth/pkg/auth/admin_jwt.go create mode 100644 packages/auth/pkg/auth/admin_jwt_test.go rename packages/auth/pkg/auth/{oidc => jwks}/audience.go (99%) rename packages/auth/pkg/auth/{oidc => jwks}/audience_test.go (99%) rename packages/auth/pkg/auth/{oidc => jwks}/config.go (99%) rename packages/auth/pkg/auth/{oidc => jwks}/config_test.go (99%) rename packages/auth/pkg/auth/{oidc => jwks}/testserver.go (86%) create mode 100644 packages/auth/pkg/auth/jwks/verifier.go create mode 100644 packages/dashboard-api/internal/handlers/workspace_admin.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 953e9d2b67..da3a32f8bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,7 +185,11 @@ the API's `ResumeSandbox` gRPC and retries — paused sandboxes wake transparent A separate REST service (port 3010, spec `spec/openapi-dashboard.yml`) consumed by the web dashboard, not the SDK: team management/provisioning, template tags, build listings, admin -bootstrap. Talks to Postgres and ClickHouse; never talks to orchestrators. +bootstrap. Its workspace-agnostic `/admin/v1` operations are defined in the same dashboard +OpenAPI contract and registered on the existing router. Their `AdminJWTAuth` OpenAPI security scheme +accepts only short-lived EdDSA service JWTs verified against the workspace-api JWKS (resolved via +OIDC discovery), configured through the JSON `ADMIN_AUTH_CONFIG` value — the same config shape +as `AUTH_PROVIDER_CONFIG`. Talks to Postgres and ClickHouse; never talks to orchestrators. ### Docker reverse proxy (`packages/docker-reverse-proxy`) diff --git a/packages/auth/pkg/auth/admin_jwt.go b/packages/auth/pkg/auth/admin_jwt.go new file mode 100644 index 0000000000..1811ec14c3 --- /dev/null +++ b/packages/auth/pkg/auth/admin_jwt.go @@ -0,0 +1,75 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" +) + +// adminJWTClockSkew is the leeway applied to time-based claims of admin +// service JWTs. +const adminJWTClockSkew = 30 * time.Second + +// AdminJWTVerifier verifies admin service JWTs against one or more configured +// issuers and returns the first successful verification. +type AdminJWTVerifier struct { + verifiers []*jwks.Verifier +} + +// NewAdminJWTVerifier builds the verifier for the AdminJWTAuth security +// scheme from the same ProviderConfig shape used for AUTH_PROVIDER_CONFIG: +// short-lived EdDSA-signed service tokens. It returns nil when the config has +// no issuers, leaving the scheme unconfigured. +func NewAdminJWTVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminJWTVerifier, error) { + normalized := config.normalize() + if err := normalized.validate(); err != nil { + return nil, err + } + if !normalized.enabled() { + return nil, nil + } + + verifiers := make([]*jwks.Verifier, 0, len(normalized.JWT)) + for i, entry := range normalized.JWT { + verifier, err := jwks.NewVerifier(ctx, entry, httpClient, + jwks.WithParserOptions( + jwt.WithLeeway(adminJWTClockSkew), + jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), + ), + ) + if err != nil { + return nil, fmt.Errorf("admin JWT jwt[%d]: %w", i, err) + } + verifiers = append(verifiers, verifier) + } + + return &AdminJWTVerifier{verifiers: verifiers}, nil +} + +// Verify iterates over the configured issuers and returns the claims of the +// first successful verification. +func (v *AdminJWTVerifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { + if v == nil || len(v.verifiers) == 0 { + return nil, errors.New("admin JWT verifier is not configured") + } + + errs := make([]error, 0, len(v.verifiers)) + for _, verifier := range v.verifiers { + claims, err := verifier.Verify(ctx, tokenString) + if err != nil { + errs = append(errs, err) + + continue + } + + return claims, nil + } + + return nil, fmt.Errorf("failed to verify admin JWT: %w", errors.Join(errs...)) +} diff --git a/packages/auth/pkg/auth/admin_jwt_test.go b/packages/auth/pkg/auth/admin_jwt_test.go new file mode 100644 index 0000000000..645fb22cba --- /dev/null +++ b/packages/auth/pkg/auth/admin_jwt_test.go @@ -0,0 +1,122 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" +) + +const ( + adminTestKeyID = "workspace-key" + adminTestAudience = "fx1" +) + +func newAdminTestVerifier(t *testing.T) (*AdminJWTVerifier, ed25519.PrivateKey, string) { + t.Helper() + + const issuer = "https://workspace.example.com" + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + server := jwks.NewTestServer(t, publicKey, adminTestKeyID, jose.EdDSA, issuer) + + verifier, err := NewAdminJWTVerifier(t.Context(), ProviderConfig{ + JWT: []jwks.Config{{ + Issuer: jwks.Issuer{ + URL: issuer, + DiscoveryURL: server.URL + "/.well-known/openid-configuration", + Audiences: []string{adminTestAudience}, + }, + }}, + }, server.Client()) + require.NoError(t, err) + + return verifier, privateKey, issuer +} + +func signAdminToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = adminTestKeyID + signed, err := token.SignedString(privateKey) + require.NoError(t, err) + + return signed +} + +func TestAdminJWTVerifier(t *testing.T) { + t.Parallel() + + verifier, privateKey, issuer := newAdminTestVerifier(t) + + baseClaims := func() jwt.MapClaims { + return jwt.MapClaims{ + "iss": issuer, + "aud": adminTestAudience, + "exp": time.Now().Add(5 * time.Minute).Unix(), + } + } + + t.Run("valid token", func(t *testing.T) { + t.Parallel() + + _, err := verifier.Verify(t.Context(), signAdminToken(t, privateKey, baseClaims())) + require.NoError(t, err) + }) + + t.Run("expired token", func(t *testing.T) { + t.Parallel() + + claims := baseClaims() + claims["exp"] = time.Now().Add(-5 * time.Minute).Unix() + _, err := verifier.Verify(t.Context(), signAdminToken(t, privateKey, claims)) + require.Error(t, err) + }) + + t.Run("wrong audience", func(t *testing.T) { + t.Parallel() + + claims := baseClaims() + claims["aud"] = "other" + _, err := verifier.Verify(t.Context(), signAdminToken(t, privateKey, claims)) + require.Error(t, err) + }) +} + +func TestAdminJWTVerifierDisabled(t *testing.T) { + t.Parallel() + + verifier, err := NewAdminJWTVerifier(t.Context(), ProviderConfig{}, nil) + require.NoError(t, err) + require.Nil(t, verifier) + + _, err = verifier.Verify(t.Context(), "any-token") + require.ErrorContains(t, err, "not configured") +} + +func TestAdminJWTVerifierRejectsNonEdDSA(t *testing.T) { + t.Parallel() + + verifier, _, issuer := newAdminTestVerifier(t) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iss": issuer, + "aud": adminTestAudience, + "exp": time.Now().Add(5 * time.Minute).Unix(), + }) + token.Header["kid"] = adminTestKeyID + signed, err := token.SignedString([]byte("shared-secret")) + require.NoError(t, err) + + _, err = verifier.Verify(t.Context(), signed) + require.Error(t, err) +} diff --git a/packages/auth/pkg/auth/oidc/audience.go b/packages/auth/pkg/auth/jwks/audience.go similarity index 99% rename from packages/auth/pkg/auth/oidc/audience.go rename to packages/auth/pkg/auth/jwks/audience.go index 9da4e96da5..4c6a089a3f 100644 --- a/packages/auth/pkg/auth/oidc/audience.go +++ b/packages/auth/pkg/auth/jwks/audience.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "errors" diff --git a/packages/auth/pkg/auth/oidc/audience_test.go b/packages/auth/pkg/auth/jwks/audience_test.go similarity index 99% rename from packages/auth/pkg/auth/oidc/audience_test.go rename to packages/auth/pkg/auth/jwks/audience_test.go index 090b176eeb..5e481cc72d 100644 --- a/packages/auth/pkg/auth/oidc/audience_test.go +++ b/packages/auth/pkg/auth/jwks/audience_test.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "testing" diff --git a/packages/auth/pkg/auth/oidc/config.go b/packages/auth/pkg/auth/jwks/config.go similarity index 99% rename from packages/auth/pkg/auth/oidc/config.go rename to packages/auth/pkg/auth/jwks/config.go index c6d7e64614..c3d631f8b0 100644 --- a/packages/auth/pkg/auth/oidc/config.go +++ b/packages/auth/pkg/auth/jwks/config.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "encoding/json" diff --git a/packages/auth/pkg/auth/oidc/config_test.go b/packages/auth/pkg/auth/jwks/config_test.go similarity index 99% rename from packages/auth/pkg/auth/oidc/config_test.go rename to packages/auth/pkg/auth/jwks/config_test.go index 61a5bf14fa..1f3fe6bd8b 100644 --- a/packages/auth/pkg/auth/oidc/config_test.go +++ b/packages/auth/pkg/auth/jwks/config_test.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "testing" diff --git a/packages/auth/pkg/auth/oidc/testserver.go b/packages/auth/pkg/auth/jwks/testserver.go similarity index 86% rename from packages/auth/pkg/auth/oidc/testserver.go rename to packages/auth/pkg/auth/jwks/testserver.go index d97ab8ff0b..e50f2c9d6c 100644 --- a/packages/auth/pkg/auth/oidc/testserver.go +++ b/packages/auth/pkg/auth/jwks/testserver.go @@ -1,7 +1,6 @@ -package oidc +package jwks import ( - "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" @@ -17,7 +16,7 @@ import ( // // This helper is exported so tests in sibling packages can construct an OIDC // fixture without duplicating the boilerplate. -func NewTestServer(t *testing.T, publicKey *rsa.PublicKey, keyID string, discoveryIssuer string) *httptest.Server { +func NewTestServer(t *testing.T, publicKey any, keyID string, algorithm jose.SignatureAlgorithm, discoveryIssuer string) *httptest.Server { t.Helper() mux := http.NewServeMux() @@ -39,7 +38,7 @@ func NewTestServer(t *testing.T, publicKey *rsa.PublicKey, keyID string, discove { Key: publicKey, KeyID: keyID, - Algorithm: string(jose.RS256), + Algorithm: string(algorithm), Use: "sig", }, }}) diff --git a/packages/auth/pkg/auth/jwks/verifier.go b/packages/auth/pkg/auth/jwks/verifier.go new file mode 100644 index 0000000000..f589aad9ce --- /dev/null +++ b/packages/auth/pkg/auth/jwks/verifier.go @@ -0,0 +1,268 @@ +package jwks + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" +) + +// httpTimeout is the timeout used for discovery and JWKS HTTP requests. +const httpTimeout = 10 * time.Second + +// Option customizes a Verifier beyond the issuer configuration. +type Option func(*Verifier) + +// WithParserOptions appends jwt parser options (e.g. jwt.WithValidMethods, +// jwt.WithLeeway) to the verifier's defaults. +func WithParserOptions(options ...jwt.ParserOption) Option { + return func(v *Verifier) { + v.parserOptions = append(v.parserOptions, options...) + } +} + +// Verifier verifies JWTs against the JWKS of a single OIDC issuer. +type Verifier struct { + keyfunc keyfunc.Keyfunc + audiences []string + parserOptions []jwt.ParserOption +} + +// discoveryDocument is a minimal subset of the OIDC discovery document +// (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). +type discoveryDocument struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` +} + +// NewVerifier constructs a Verifier from the supplied Config. It performs the +// OIDC discovery fetch synchronously and fails fast on configuration or +// network errors. +func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, options ...Option) (*Verifier, error) { + if httpClient == nil { + return nil, errors.New("JWKS HTTP client is required") + } + + entry = entry.Normalized() + if err := entry.Validate(); err != nil { + return nil, err + } + + discoveryURL := entry.discoveryURL() + if err := validateHTTPSURL(discoveryURL, "discoveryURL"); err != nil { + return nil, err + } + + doc, err := fetchDiscoveryDocument(ctx, httpClient, discoveryURL) + if err != nil { + return nil, fmt.Errorf("fetch OIDC discovery document at %s: %w", discoveryURL, err) + } + + if doc.Issuer != entry.Issuer.URL { + return nil, fmt.Errorf("discovery document issuer %q does not match configured issuer %q", doc.Issuer, entry.Issuer.URL) + } + + if err := validateHTTPSURL(doc.JWKSURI, "discovery jwks_uri"); err != nil { + return nil, err + } + + storage, err := jwkset.NewStorageFromHTTP(doc.JWKSURI, jwkset.HTTPClientStorageOptions{ + Client: httpClient, + Ctx: ctx, + HTTPTimeout: httpTimeout, + RefreshInterval: entry.CacheDuration, + }) + if err != nil { + return nil, fmt.Errorf("create JWKS storage: %w", err) + } + + keyFunc, err := keyfunc.New(keyfunc.Options{ + Ctx: ctx, + Storage: storage, + }) + if err != nil { + return nil, fmt.Errorf("create JWKS keyfunc: %w", err) + } + + verifier := &Verifier{ + keyfunc: keyFunc, + audiences: entry.Issuer.Audiences, + parserOptions: []jwt.ParserOption{ + jwt.WithExpirationRequired(), + jwt.WithIssuer(entry.Issuer.URL), + }, + } + for _, option := range options { + option(verifier) + } + + return verifier, nil +} + +// Verify parses and validates the supplied token string and returns its +// claims. +func (v *Verifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { + if v == nil || v.keyfunc == nil { + return nil, errors.New("JWKS verifier is not configured") + } + + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { + return v.keyfunc.KeyfuncCtx(ctx)(token) + }, v.parserOptions...) + if err != nil { + return nil, fmt.Errorf("failed to verify token: %w", err) + } + if !token.Valid { + return nil, errors.New("token is invalid") + } + + if err := validateAudience(claims, v.audiences); err != nil { + return nil, fmt.Errorf("failed to verify token: %w", err) + } + + return claims, nil +} + +func fetchDiscoveryDocument(ctx context.Context, httpClient *http.Client, discoveryURL string) (*discoveryDocument, error) { + fetchCtx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("create discovery request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute discovery request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + + return nil, fmt.Errorf("discovery endpoint returned status %d: %s", resp.StatusCode, string(body)) + } + + var doc discoveryDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("decode discovery document: %w", err) + } + + if doc.Issuer == "" { + return nil, errors.New("discovery document is missing issuer") + } + + if doc.JWKSURI == "" { + return nil, errors.New("discovery document is missing jwks_uri") + } + + return &doc, nil +} + +// validateHTTPSURL applies the same checks as validateURL but returns at the +// first failure. It is intended for runtime validation of URLs derived from +// the OIDC discovery document where one error is enough to fail fast. +func validateHTTPSURL(rawURL string, field string) error { + if errs := validateURL(rawURL, field); len(errs) > 0 { + return errs[0] + } + + return nil +} + +// validateURL enforces the same constraints Kubernetes requires of issuer +// URLs: +// - parseable via url.Parse +// - https scheme (with one exception, see isLoopbackHost below) +// - no userinfo (username/password) +// - no query string +// - no fragment +// +// Loopback exception: an http:// URL is accepted when its host resolves to +// a loopback address (`localhost`, `127.0.0.1`, `[::1]`). Any non-loopback host +// still requires https. +// +// The field name is included in error messages to help operators locate the +// offending value. All applicable errors are returned together. +func validateURL(rawURL string, field string) []error { + var errs []error + + u, err := url.Parse(rawURL) + if err != nil { + return []error{fmt.Errorf("invalid %s: %w", field, err)} + } + if u.Scheme != "https" && (u.Scheme != "http" || !isLoopbackHost(u.Hostname())) { + errs = append(errs, fmt.Errorf("invalid %s scheme %q (must be https, or http for loopback hosts)", field, u.Scheme)) + } + if u.User != nil { + errs = append(errs, fmt.Errorf("invalid %s: must not contain a username or password", field)) + } + if len(u.RawQuery) > 0 { + errs = append(errs, fmt.Errorf("invalid %s: must not contain a query", field)) + } + if len(u.Fragment) > 0 { + errs = append(errs, fmt.Errorf("invalid %s: must not contain a fragment", field)) + } + + return errs +} + +// isLoopbackHost reports whether the given URL host is a loopback address. +// It accepts: +// - the literal name "localhost" (case-insensitive) +// - any IPv4 address in 127.0.0.0/8 +// - the IPv6 loopback ::1 (Hostname() strips the brackets, so we receive "::1") +// +// We deliberately *do not* resolve the name via DNS: that would make +// validation depend on host resolver state and turn this into a TOCTOU +// surface. Matching string-and-literal-IP only is enough for the local-dev +// use case and keeps the check pure. +func isLoopbackHost(host string) bool { + if host == "" { + return false + } + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + + return ip != nil && ip.IsLoopback() +} + +// validateIssuerURL enforces presence + URL constraints on the issuer URL. +func validateIssuerURL(issuerURL string) []error { + if issuerURL == "" { + return []error{errors.New("issuer.url is required")} + } + + return validateURL(issuerURL, "issuer.url") +} + +// validateDiscoveryURL enforces URL constraints on the optional discovery +// URL plus the requirement that it differ from the issuer URL when set. +func validateDiscoveryURL(issuerURL, discoveryURL string) []error { + if discoveryURL == "" { + return nil + } + + var errs []error + if issuerURL != "" && strings.TrimRight(issuerURL, "/") == strings.TrimRight(discoveryURL, "/") { + errs = append(errs, errors.New("issuer.discoveryURL must be different from issuer.url")) + } + errs = append(errs, validateURL(discoveryURL, "issuer.discoveryURL")...) + + return errs +} diff --git a/packages/auth/pkg/auth/middleware.go b/packages/auth/pkg/auth/middleware.go index 98a1819516..561658a7eb 100644 --- a/packages/auth/pkg/auth/middleware.go +++ b/packages/auth/pkg/auth/middleware.go @@ -185,6 +185,29 @@ func NewAuthProviderTeamAuthenticator(validationFunc func(ctx context.Context, g } } +// NewAdminJWTAuthenticator creates an authenticator for the AdminJWTAuth security scheme. +func NewAdminJWTAuthenticator(verifier *AdminJWTVerifier) Authenticator { + return &commonAuthenticator[struct{}]{ + schemeName: "AdminJWTAuth", + header: headerKey{ + name: HeaderAuthorization, + removePrefix: PrefixBearer, + }, + validationFunc: func(ctx context.Context, _ *gin.Context, token string) (struct{}, *APIError) { + if _, err := verifier.Verify(ctx, token); err != nil { + return struct{}{}, &APIError{ + Code: http.StatusUnauthorized, + Err: err, + ClientMsg: "Invalid service token.", + } + } + + return struct{}{}, nil + }, + errorMessage: "Invalid service token.", + } +} + // NewAdminApiKeyAuthenticator creates an authenticator for the AdminApiKeyAuth security scheme (X-Admin-Token header). func NewAdminApiKeyAuthenticator(adminToken string) Authenticator { return newAdminApiKeyAuthenticator("AdminApiKeyAuth", adminToken) diff --git a/packages/auth/pkg/auth/oidc/oidc.go b/packages/auth/pkg/auth/oidc/oidc.go index b6ba417b90..13af3dad94 100644 --- a/packages/auth/pkg/auth/oidc/oidc.go +++ b/packages/auth/pkg/auth/oidc/oidc.go @@ -2,25 +2,15 @@ package oidc import ( "context" - "encoding/json" "errors" "fmt" - "io" - "net" "net/http" - "net/url" - "strings" - "time" - "github.com/MicahParks/jwkset" - "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" -) -// oidcHTTPTimeout is the timeout used for OIDC discovery and JWKS HTTP -// requests. -const oidcHTTPTimeout = 10 * time.Second + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" +) // ErrIdentityNotFound is returned by Verify when the token is valid but no // matching row exists in public.user_identities for (iss, sub). @@ -33,80 +23,28 @@ type IdentityLookup interface { GetUserIdentity(ctx context.Context, iss, sub string) (uuid.UUID, error) } -// Verifier verifies JWTs against a single OIDC issuer. +// Verifier verifies JWTs against a single OIDC issuer and resolves the +// internal user for the token's identity. type Verifier struct { - keyfunc keyfunc.Keyfunc - audiences []string - parserOptions []jwt.ParserOption - identities IdentityLookup -} - -// discoveryDocument is a minimal subset of the OIDC discovery document -// (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). -type discoveryDocument struct { - Issuer string `json:"issuer"` - JWKSURI string `json:"jwks_uri"` + tokens *jwks.Verifier + identities IdentityLookup } // NewVerifier constructs a Verifier from the supplied Config. It performs the // OIDC discovery fetch synchronously and fails fast on configuration or // network errors. -func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, identities IdentityLookup) (*Verifier, error) { - if httpClient == nil { - return nil, errors.New("OIDC JWKS HTTP client is required") - } - +func NewVerifier(ctx context.Context, entry jwks.Config, httpClient *http.Client, identities IdentityLookup) (*Verifier, error) { if identities == nil { return nil, errors.New("OIDC identity lookup is required") } - if entry.Issuer.URL == "" { - return nil, errors.New("issuer URL is required") - } - - discoveryURL := entry.discoveryURL() - if err := validateHTTPSURL(discoveryURL, "discoveryURL"); err != nil { - return nil, err - } - - doc, err := fetchDiscoveryDocument(ctx, httpClient, discoveryURL) + tokens, err := jwks.NewVerifier(ctx, entry, httpClient) if err != nil { - return nil, fmt.Errorf("fetch OIDC discovery document at %s: %w", discoveryURL, err) - } - - if doc.Issuer != entry.Issuer.URL { - return nil, fmt.Errorf("discovery document issuer %q does not match configured issuer %q", doc.Issuer, entry.Issuer.URL) - } - - if err := validateHTTPSURL(doc.JWKSURI, "discovery jwks_uri"); err != nil { return nil, err } - storage, err := jwkset.NewStorageFromHTTP(doc.JWKSURI, jwkset.HTTPClientStorageOptions{ - Client: httpClient, - Ctx: ctx, - HTTPTimeout: oidcHTTPTimeout, - RefreshInterval: entry.CacheDuration, - }) - if err != nil { - return nil, fmt.Errorf("create OIDC JWKS storage: %w", err) - } - - keyFunc, err := keyfunc.New(keyfunc.Options{ - Ctx: ctx, - Storage: storage, - }) - if err != nil { - return nil, fmt.Errorf("create OIDC JWKS keyfunc: %w", err) - } - return &Verifier{ - keyfunc: keyFunc, - audiences: entry.Issuer.Audiences, - parserOptions: []jwt.ParserOption{ - jwt.WithExpirationRequired(), - jwt.WithIssuer(entry.Issuer.URL), - }, + tokens: tokens, identities: identities, }, nil } @@ -116,20 +54,10 @@ func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, ide // IdentityLookup. When the token is valid but no matching identity exists, // the returned error wraps ErrIdentityNotFound. func (v *Verifier) Verify(ctx context.Context, tokenString string) (uuid.UUID, jwt.MapClaims, error) { - claims := jwt.MapClaims{} - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { - return v.keyfunc.KeyfuncCtx(ctx)(token) - }, v.parserOptions...) + claims, err := v.tokens.Verify(ctx, tokenString) if err != nil { return uuid.Nil, nil, fmt.Errorf("failed to verify auth provider token: %w", err) } - if !token.Valid { - return uuid.Nil, nil, errors.New("auth provider token is invalid") - } - - if err := validateAudience(claims, v.audiences); err != nil { - return uuid.Nil, nil, fmt.Errorf("failed to verify auth provider token: %w", err) - } iss, ok := claimString(claims, "iss") if !ok { @@ -175,136 +103,3 @@ func claimString(claims jwt.MapClaims, name string) (string, bool) { return "", false } } - -func fetchDiscoveryDocument(ctx context.Context, httpClient *http.Client, discoveryURL string) (*discoveryDocument, error) { - fetchCtx, cancel := context.WithTimeout(ctx, oidcHTTPTimeout) - defer cancel() - - req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, discoveryURL, nil) - if err != nil { - return nil, fmt.Errorf("create discovery request: %w", err) - } - req.Header.Set("Accept", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("execute discovery request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - - return nil, fmt.Errorf("discovery endpoint returned status %d: %s", resp.StatusCode, string(body)) - } - - var doc discoveryDocument - if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { - return nil, fmt.Errorf("decode discovery document: %w", err) - } - - if doc.Issuer == "" { - return nil, errors.New("discovery document is missing issuer") - } - - if doc.JWKSURI == "" { - return nil, errors.New("discovery document is missing jwks_uri") - } - - return &doc, nil -} - -// validateHTTPSURL applies the same checks as validateURL but returns at the -// first failure. It is intended for runtime validation of URLs derived from -// the OIDC discovery document where one error is enough to fail fast. -func validateHTTPSURL(rawURL string, field string) error { - if errs := validateURL(rawURL, field); len(errs) > 0 { - return errs[0] - } - - return nil -} - -// validateURL enforces the same constraints Kubernetes requires of issuer -// URLs: -// - parseable via url.Parse -// - https scheme (with one exception, see isLoopbackHost below) -// - no userinfo (username/password) -// - no query string -// - no fragment -// -// Loopback exception: an http:// URL is accepted when its host resolves to -// a loopback address (`localhost`, `127.0.0.1`, `[::1]`). Any non-loopback host -// still requires https. -// -// The field name is included in error messages to help operators locate the -// offending value. All applicable errors are returned together. -func validateURL(rawURL string, field string) []error { - var errs []error - - u, err := url.Parse(rawURL) - if err != nil { - return []error{fmt.Errorf("invalid %s: %w", field, err)} - } - if u.Scheme != "https" && (u.Scheme != "http" || !isLoopbackHost(u.Hostname())) { - errs = append(errs, fmt.Errorf("invalid %s scheme %q (must be https, or http for loopback hosts)", field, u.Scheme)) - } - if u.User != nil { - errs = append(errs, fmt.Errorf("invalid %s: must not contain a username or password", field)) - } - if len(u.RawQuery) > 0 { - errs = append(errs, fmt.Errorf("invalid %s: must not contain a query", field)) - } - if len(u.Fragment) > 0 { - errs = append(errs, fmt.Errorf("invalid %s: must not contain a fragment", field)) - } - - return errs -} - -// isLoopbackHost reports whether the given URL host is a loopback address. -// It accepts: -// - the literal name "localhost" (case-insensitive) -// - any IPv4 address in 127.0.0.0/8 -// - the IPv6 loopback ::1 (Hostname() strips the brackets, so we receive "::1") -// -// We deliberately *do not* resolve the name via DNS: that would make -// validation depend on host resolver state and turn this into a TOCTOU -// surface. Matching string-and-literal-IP only is enough for the local-dev -// use case and keeps the check pure. -func isLoopbackHost(host string) bool { - if host == "" { - return false - } - if strings.EqualFold(host, "localhost") { - return true - } - ip := net.ParseIP(host) - - return ip != nil && ip.IsLoopback() -} - -// validateIssuerURL enforces presence + URL constraints on the issuer URL. -func validateIssuerURL(issuerURL string) []error { - if issuerURL == "" { - return []error{errors.New("issuer.url is required")} - } - - return validateURL(issuerURL, "issuer.url") -} - -// validateDiscoveryURL enforces URL constraints on the optional discovery -// URL plus the requirement that it differ from the issuer URL when set. -func validateDiscoveryURL(issuerURL, discoveryURL string) []error { - if discoveryURL == "" { - return nil - } - - var errs []error - if issuerURL != "" && strings.TrimRight(issuerURL, "/") == strings.TrimRight(discoveryURL, "/") { - errs = append(errs, errors.New("issuer.discoveryURL must be different from issuer.url")) - } - errs = append(errs, validateURL(discoveryURL, "issuer.discoveryURL")...) - - return errs -} diff --git a/packages/auth/pkg/auth/oidc/oidc_test.go b/packages/auth/pkg/auth/oidc/oidc_test.go index 254c8e7495..4fe0d3f98b 100644 --- a/packages/auth/pkg/auth/oidc/oidc_test.go +++ b/packages/auth/pkg/auth/oidc/oidc_test.go @@ -8,9 +8,12 @@ import ( "testing" "time" + jose "github.com/go-jose/go-jose/v4" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" ) const testIssuerURL = "https://issuer.example.com" @@ -41,13 +44,13 @@ func TestVerifier_Verify(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := NewTestServer(t, &privateKey.PublicKey, keyID, testIssuerURL) + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, testIssuerURL) internalUserID := uuid.New() lookup := &stubIdentityLookup{userID: internalUserID} - verifier, err := NewVerifier(t.Context(), Config{ - Issuer: Issuer{ + verifier, err := NewVerifier(t.Context(), jwks.Config{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -82,12 +85,12 @@ func TestVerifier_IdentityNotFound(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := NewTestServer(t, &privateKey.PublicKey, keyID, testIssuerURL) + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, testIssuerURL) lookup := &stubIdentityLookup{err: ErrIdentityNotFound} - verifier, err := NewVerifier(t.Context(), Config{ - Issuer: Issuer{ + verifier, err := NewVerifier(t.Context(), jwks.Config{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -119,13 +122,13 @@ func TestVerifier_IdentityLookupError(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := NewTestServer(t, &privateKey.PublicKey, keyID, testIssuerURL) + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, testIssuerURL) lookupErr := errors.New("boom") lookup := &stubIdentityLookup{err: lookupErr} - verifier, err := NewVerifier(t.Context(), Config{ - Issuer: Issuer{ + verifier, err := NewVerifier(t.Context(), jwks.Config{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -158,11 +161,11 @@ func TestVerifier_RejectsWrongAudience(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := NewTestServer(t, &privateKey.PublicKey, keyID, testIssuerURL) + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, testIssuerURL) lookup := &stubIdentityLookup{userID: uuid.New()} - verifier, err := NewVerifier(t.Context(), Config{ - Issuer: Issuer{ + verifier, err := NewVerifier(t.Context(), jwks.Config{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -195,10 +198,10 @@ func TestNewVerifier_DiscoveryIssuerMismatch(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := NewTestServer(t, &privateKey.PublicKey, keyID, "https://different-issuer.example.com") + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, "https://different-issuer.example.com") - _, err = NewVerifier(t.Context(), Config{ - Issuer: Issuer{ + _, err = NewVerifier(t.Context(), jwks.Config{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", }, diff --git a/packages/auth/pkg/auth/provider_config_parse.go b/packages/auth/pkg/auth/provider_config_parse.go index d8086a753e..3e8f96cd40 100644 --- a/packages/auth/pkg/auth/provider_config_parse.go +++ b/packages/auth/pkg/auth/provider_config_parse.go @@ -6,10 +6,11 @@ import ( "strings" ) -// ParseProviderConfig parses an AUTH_PROVIDER_CONFIG env value into a -// ProviderConfig. Empty input and the literal string "null" (with surrounding -// whitespace) both produce a zero-value ProviderConfig with no error, so that -// Terraform `jsonencode(null)` values and unset env vars behave the same. +// ParseProviderConfig parses a provider-config env value (AUTH_PROVIDER_CONFIG, +// ADMIN_AUTH_CONFIG) into a ProviderConfig. Empty input and the literal string +// "null" (with surrounding whitespace) both produce a zero-value ProviderConfig +// with no error, so that Terraform `jsonencode(null)` values and unset env vars +// behave the same. func ParseProviderConfig(v string) (ProviderConfig, error) { var config ProviderConfig trimmed := strings.TrimSpace(v) @@ -18,7 +19,7 @@ func ParseProviderConfig(v string) (ProviderConfig, error) { } if err := json.Unmarshal([]byte(v), &config); err != nil { - return ProviderConfig{}, fmt.Errorf("parse AUTH_PROVIDER_CONFIG: %w", err) + return ProviderConfig{}, fmt.Errorf("parse auth provider config: %w", err) } return config, nil diff --git a/packages/auth/pkg/auth/service.go b/packages/auth/pkg/auth/service.go index bc1cfae719..447fb0c347 100644 --- a/packages/auth/pkg/auth/service.go +++ b/packages/auth/pkg/auth/service.go @@ -44,7 +44,7 @@ type Service interface { type authService struct { store authStore teamCache *authCache - authProviderVerifier *Verifier + authProviderVerifier *ProviderVerifier } // Compile-time assertion that *authService satisfies the Service interface. @@ -77,7 +77,7 @@ func NewAuthService( // OIDC bootstrap writes identity rows on the primary immediately before the // next authenticated request; using the read replica here races replication lag. identityLookup := newAuthIdentityLookup(authDB.Write) - v, err := NewVerifier(ctx, providerConfig, httpClient, identityLookup) + v, err := NewProviderVerifier(ctx, providerConfig, httpClient, identityLookup) if err != nil { return nil, fmt.Errorf("initializing auth provider JWT verifier: %w", err) } @@ -183,7 +183,7 @@ func (s *authService) ValidateAuthProviderToken(ctx context.Context, ginCtx *gin return s.validateJWTWithProvider(ctx, ginCtx, s.authProviderVerifier, token, "auth provider") } -func (s *authService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *Verifier, token string, tokenSource string) (uuid.UUID, *APIError) { +func (s *authService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *ProviderVerifier, token string, tokenSource string) (uuid.UUID, *APIError) { userID, _, err := v.Verify(ctx, token) if err != nil { return uuid.UUID{}, &APIError{ diff --git a/packages/auth/pkg/auth/verifier.go b/packages/auth/pkg/auth/verifier.go index b2a7e8c6a7..35042570b5 100644 --- a/packages/auth/pkg/auth/verifier.go +++ b/packages/auth/pkg/auth/verifier.go @@ -9,12 +9,13 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" ) // ProviderConfig describes external auth provider verification. type ProviderConfig struct { - JWT []oidc.Config `json:"jwt"` + JWT []jwks.Config `json:"jwt"` } // enabled returns true when at least one auth provider entry is configured. @@ -24,7 +25,7 @@ func (c ProviderConfig) enabled() bool { // normalize applies defaults across both arrays and returns a copy. func (c ProviderConfig) normalize() ProviderConfig { - jwts := make([]oidc.Config, len(c.JWT)) + jwts := make([]jwks.Config, len(c.JWT)) for i, entry := range c.JWT { jwts[i] = entry.Normalized() } @@ -51,17 +52,17 @@ type strategy interface { // Verifier aggregates one or more OIDC JWT verification strategies and returns // the first that succeeds. -type Verifier struct { +type ProviderVerifier struct { strategies []strategy } -// NewVerifier constructs a *Verifier from the given ProviderConfig. +// NewVerifier constructs a *ProviderVerifier from the given ProviderConfig. // // When the provided config has no JWT issuers, NewVerifier returns (nil, nil). // This is a valid configuration: the caller can pass the nil Verifier to // authService, and any token verification attempt will be denied at runtime by // Verifier.Verify / Service.ValidateAuthProviderToken. -func NewVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *http.Client, identities oidc.IdentityLookup) (*Verifier, error) { +func NewProviderVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *http.Client, identities oidc.IdentityLookup) (*ProviderVerifier, error) { normalized := config.normalize() if err := normalized.validate(); err != nil { return nil, err @@ -77,7 +78,7 @@ func NewVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *htt } for i, entry := range normalized.JWT { - s, err := oidc.NewVerifier(ctx, entry, oidcHTTPClient, identities) + s, err := oidc.NewProviderVerifier(ctx, entry, oidcHTTPClient, identities) if err != nil { return nil, fmt.Errorf("auth provider jwt[%d]: %w", i, err) } @@ -88,14 +89,14 @@ func NewVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *htt return nil, errors.New("auth provider verifier has no configured signing verifier") } - return &Verifier{ + return &ProviderVerifier{ strategies: strategies, }, nil } // Verify iterates over the configured strategies and returns the first that // successfully verifies the token and resolves a non-nil internal user UUID. -func (v *Verifier) Verify(ctx context.Context, tokenString string) (uuid.UUID, jwt.MapClaims, error) { +func (v *ProviderVerifier) Verify(ctx context.Context, tokenString string) (uuid.UUID, jwt.MapClaims, error) { if v == nil { return uuid.Nil, nil, errors.New("auth provider verifier is not configured") } diff --git a/packages/auth/pkg/auth/verifier_test.go b/packages/auth/pkg/auth/verifier_test.go index e9647c9512..7d59c0a0a6 100644 --- a/packages/auth/pkg/auth/verifier_test.go +++ b/packages/auth/pkg/auth/verifier_test.go @@ -11,10 +11,12 @@ import ( "testing" "time" + jose "github.com/go-jose/go-jose/v4" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" ) @@ -76,7 +78,7 @@ func TestVerifier_VerifyJWT(t *testing.T) { require.NoError(t, err) const keyID = "test-key" - server := oidc.NewTestServer(t, &privateKey.PublicKey, keyID, testIssuerURL) + server := jwks.NewTestServer(t, &privateKey.PublicKey, keyID, jose.RS256, testIssuerURL) lookup := newStubIdentityLookup() const jwksSub = "external-subject" @@ -84,9 +86,9 @@ func TestVerifier_VerifyJWT(t *testing.T) { lookup.set(testIssuerURL, jwksSub, jwksUserID) verifier, err := NewVerifier(t.Context(), ProviderConfig{ - JWT: []oidc.Config{ + JWT: []jwks.Config{ { - Issuer: oidc.Issuer{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -127,8 +129,8 @@ func TestVerifier_VerifyMultipleJWTIssuers(t *testing.T) { issuer2URL = "https://issuer-two.example.com" ) - server1 := oidc.NewTestServer(t, &privateKey1.PublicKey, keyID1, issuer1URL) - server2 := oidc.NewTestServer(t, &privateKey2.PublicKey, keyID2, issuer2URL) + server1 := jwks.NewTestServer(t, &privateKey1.PublicKey, keyID1, jose.RS256, issuer1URL) + server2 := jwks.NewTestServer(t, &privateKey2.PublicKey, keyID2, jose.RS256, issuer2URL) lookup := newStubIdentityLookup() const tokenSub = "external-subject" @@ -136,16 +138,16 @@ func TestVerifier_VerifyMultipleJWTIssuers(t *testing.T) { lookup.set(issuer2URL, tokenSub, userID) verifier, err := NewVerifier(t.Context(), ProviderConfig{ - JWT: []oidc.Config{ + JWT: []jwks.Config{ { - Issuer: oidc.Issuer{ + Issuer: jwks.Issuer{ URL: issuer1URL, DiscoveryURL: server1.URL + "/.well-known/openid-configuration", Audiences: []string{"app-1"}, }, }, { - Issuer: oidc.Issuer{ + Issuer: jwks.Issuer{ URL: issuer2URL, DiscoveryURL: server2.URL + "/.well-known/openid-configuration", Audiences: []string{"app-2"}, diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index fbc8000a0b..06e58eb6ae 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -22,10 +22,32 @@ import ( const ( AdminApiKeyAuthScopes adminApiKeyAuthContextKey = "AdminApiKeyAuth.Scopes" + AdminJWTAuthScopes adminJWTAuthContextKey = "AdminJWTAuth.Scopes" AuthProviderBearerAuthScopes authProviderBearerAuthContextKey = "AuthProviderBearerAuth.Scopes" AuthProviderTeamAuthScopes authProviderTeamAuthContextKey = "AuthProviderTeamAuth.Scopes" ) +// Defines values for AdminControlPlaneProjectType. +const ( + Development AdminControlPlaneProjectType = "development" + Production AdminControlPlaneProjectType = "production" + Staging AdminControlPlaneProjectType = "staging" +) + +// Valid indicates whether the value is a known member of the AdminControlPlaneProjectType enum. +func (e AdminControlPlaneProjectType) Valid() bool { + switch e { + case Development: + return true + case Production: + return true + case Staging: + return true + default: + return false + } +} + // Defines values for BuildStatus. const ( Building BuildStatus = "building" @@ -182,6 +204,40 @@ type AdminAuthProviderUserBootstrapRequest struct { SignupUserAgent *string `json:"signup_user_agent,omitempty"` } +// AdminControlPlaneMemberUpsertRequest defines model for AdminControlPlaneMemberUpsertRequest. +type AdminControlPlaneMemberUpsertRequest struct { + AddedBy *openapi_types.UUID `json:"added_by,omitempty"` +} + +// AdminControlPlaneProject defines model for AdminControlPlaneProject. +type AdminControlPlaneProject struct { + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + ProjectType AdminControlPlaneProjectType `json:"project_type"` + Slug string `json:"slug"` +} + +// AdminControlPlaneProjectLimits defines model for AdminControlPlaneProjectLimits. +type AdminControlPlaneProjectLimits struct { + ConcurrentSandboxes int32 `json:"concurrent_sandboxes"` + ConcurrentTemplateBuilds int32 `json:"concurrent_template_builds"` + DiskMb int64 `json:"disk_mb"` + EventsTtlDays int32 `json:"events_ttl_days"` + MaxRamMb int64 `json:"max_ram_mb"` + MaxSandboxLengthHours int32 `json:"max_sandbox_length_hours"` + MaxVcpu int32 `json:"max_vcpu"` +} + +// AdminControlPlaneProjectType defines model for AdminControlPlaneProjectType. +type AdminControlPlaneProjectType string + +// AdminControlPlaneProjectUpsertRequest defines model for AdminControlPlaneProjectUpsertRequest. +type AdminControlPlaneProjectUpsertRequest struct { + Name string `json:"name"` + ProjectType AdminControlPlaneProjectType `json:"project_type"` + Slug string `json:"slug"` +} + // AdminTeamBootstrapRequest defines model for AdminTeamBootstrapRequest. type AdminTeamBootstrapRequest struct { // Email Billing/contact email for the team. @@ -346,7 +402,7 @@ type SandboxRecord struct { // Domain Base domain where the sandbox traffic is accessible Domain *string `json:"domain,omitempty"` - // EventsRetentionExpired Whether the sandbox ended more than the team's events retention window ago, so its events data is no longer available. Best-effort - computed from the team's current retention limit, which may differ from the retention stamped on events written before a limit change. + // EventsRetentionExpired Whether the sandbox ended more than the team's events retention window ago, so its events data is no longer available EventsRetentionExpired bool `json:"eventsRetentionExpired"` // MemoryMB Memory for the sandbox in MiB @@ -668,12 +724,18 @@ type N409 = Error // N500 defines model for 500. type N500 = Error +// N501 defines model for 501. +type N501 = Error + // N502 defines model for 502. type N502 = Error // adminApiKeyAuthContextKey is the context key for AdminApiKeyAuth security scheme type adminApiKeyAuthContextKey string +// adminJWTAuthContextKey is the context key for AdminJWTAuth security scheme +type adminJWTAuthContextKey string + // authProviderBearerAuthContextKey is the context key for AuthProviderBearerAuth security scheme type authProviderBearerAuthContextKey string @@ -776,6 +838,15 @@ type PostAdminUserProfilesResolveJSONRequestBody = AdminAuthProviderProfilesReso // PostAdminUsersBootstrapJSONRequestBody defines body for PostAdminUsersBootstrap for application/json ContentType. type PostAdminUsersBootstrapJSONRequestBody = AdminAuthProviderUserBootstrapRequest +// UpsertProjectJSONRequestBody defines body for UpsertProject for application/json ContentType. +type UpsertProjectJSONRequestBody = AdminControlPlaneProjectUpsertRequest + +// UpsertProjectLimitsJSONRequestBody defines body for UpsertProjectLimits for application/json ContentType. +type UpsertProjectLimitsJSONRequestBody = AdminControlPlaneProjectLimits + +// UpsertProjectMemberJSONRequestBody defines body for UpsertProjectMember for application/json ContentType. +type UpsertProjectMemberJSONRequestBody = AdminControlPlaneMemberUpsertRequest + // PostTeamsJSONRequestBody defines body for PostTeams for application/json ContentType. type PostTeamsJSONRequestBody = CreateTeamRequest @@ -805,6 +876,24 @@ type ServerInterface interface { // Delete user // (DELETE /admin/users/{userId}) DeleteAdminUsersUserId(c *gin.Context, userId UserId) + // Delete a project and its control-plane state. + // (DELETE /admin/v1/projects/{teamID}) + DeleteProject(c *gin.Context, teamID TeamID) + // Create or reconcile a project. + // (PUT /admin/v1/projects/{teamID}) + UpsertProject(c *gin.Context, teamID TeamID) + // Reconcile effective limits for a project. + // (PUT /admin/v1/projects/{teamID}/limits) + UpsertProjectLimits(c *gin.Context, teamID TeamID) + // Remove a project member. + // (DELETE /admin/v1/projects/{teamID}/members/{userId}) + DeleteProjectMember(c *gin.Context, teamID TeamID, userId UserId) + // Reconcile an opaque user UUID as a project member. + // (PUT /admin/v1/projects/{teamID}/members/{userId}) + UpsertProjectMember(c *gin.Context, teamID TeamID, userId UserId) + // Purge shard-local membership and access-token state for an opaque user UUID. + // (DELETE /admin/v1/users/{userId}) + PurgeUser(c *gin.Context, userId UserId) // List team builds // (GET /builds) GetBuilds(c *gin.Context, params GetBuildsParams) @@ -987,6 +1076,186 @@ func (siw *ServerInterfaceWrapper) DeleteAdminUsersUserId(c *gin.Context) { siw.Handler.DeleteAdminUsersUserId(c, userId) } +// DeleteProject operation middleware +func (siw *ServerInterfaceWrapper) DeleteProject(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteProject(c, teamID) +} + +// UpsertProject operation middleware +func (siw *ServerInterfaceWrapper) UpsertProject(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpsertProject(c, teamID) +} + +// UpsertProjectLimits operation middleware +func (siw *ServerInterfaceWrapper) UpsertProjectLimits(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpsertProjectLimits(c, teamID) +} + +// DeleteProjectMember operation middleware +func (siw *ServerInterfaceWrapper) DeleteProjectMember(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "userId" ------------- + var userId UserId + + err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.DeleteProjectMember(c, teamID, userId) +} + +// UpsertProjectMember operation middleware +func (siw *ServerInterfaceWrapper) UpsertProjectMember(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "teamID" ------------- + var teamID TeamID + + err = runtime.BindStyledParameterWithOptions("simple", "teamID", c.Param("teamID"), &teamID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter teamID: %w", err), http.StatusBadRequest) + return + } + + // ------------- Path parameter "userId" ------------- + var userId UserId + + err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.UpsertProjectMember(c, teamID, userId) +} + +// PurgeUser operation middleware +func (siw *ServerInterfaceWrapper) PurgeUser(c *gin.Context) { + + var err error + _ = err + + // ------------- Path parameter "userId" ------------- + var userId UserId + + err = runtime.BindStyledParameterWithOptions("simple", "userId", c.Param("userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter userId: %w", err), http.StatusBadRequest) + return + } + + c.Set(string(AdminJWTAuthScopes), []string{}) + + for _, middleware := range siw.HandlerMiddlewares { + middleware(c) + if c.IsAborted() { + return + } + } + + siw.Handler.PurgeUser(c, userId) +} + // GetBuilds operation middleware func (siw *ServerInterfaceWrapper) GetBuilds(c *gin.Context) { @@ -1666,6 +1935,12 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options router.GET(options.BaseURL+"/admin/user-profiles/:userId", wrapper.GetAdminUserProfilesUserId) router.POST(options.BaseURL+"/admin/users/bootstrap", wrapper.PostAdminUsersBootstrap) router.DELETE(options.BaseURL+"/admin/users/:userId", wrapper.DeleteAdminUsersUserId) + router.DELETE(options.BaseURL+"/admin/v1/projects/:teamID", wrapper.DeleteProject) + router.PUT(options.BaseURL+"/admin/v1/projects/:teamID", wrapper.UpsertProject) + router.PUT(options.BaseURL+"/admin/v1/projects/:teamID/limits", wrapper.UpsertProjectLimits) + router.DELETE(options.BaseURL+"/admin/v1/projects/:teamID/members/:userId", wrapper.DeleteProjectMember) + router.PUT(options.BaseURL+"/admin/v1/projects/:teamID/members/:userId", wrapper.UpsertProjectMember) + router.DELETE(options.BaseURL+"/admin/v1/users/:userId", wrapper.PurgeUser) router.GET(options.BaseURL+"/builds", wrapper.GetBuilds) router.GET(options.BaseURL+"/builds/statuses", wrapper.GetBuildsStatuses) router.GET(options.BaseURL+"/builds/:build_id", wrapper.GetBuildsBuildId) @@ -1692,100 +1967,110 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index d2ae20996c..2308515581 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -16,6 +16,7 @@ type Config struct { ClickhouseConnectionStrings []string `env:"CLICKHOUSE_CONNECTION_STRINGS" envSeparator:";"` AdminToken string `env:"ADMIN_TOKEN,required,notEmpty"` AuthProvider auth.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` + AdminAuth auth.ProviderConfig `env:"ADMIN_AUTH_CONFIG"` AuthDBConnectionString string `env:"AUTH_DB_CONNECTION_STRING"` AuthDBReadReplicaConnectionString string `env:"AUTH_DB_READ_REPLICA_CONNECTION_STRING"` diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 7faeeedb5c..6ad785f08d 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" ) func setBaseEnv(t *testing.T) { @@ -41,7 +41,7 @@ func TestParseAuthProviderConfig(t *testing.T) { entry := config.AuthProvider.JWT[0] require.Equal(t, "https://auth.example.com", entry.Issuer.URL) require.Equal(t, []string{"dashboard-api", "other"}, entry.Issuer.Audiences) - require.Equal(t, oidc.AudienceMatchAny, entry.Issuer.AudienceMatchPolicy) + require.Equal(t, jwks.AudienceMatchAny, entry.Issuer.AudienceMatchPolicy) require.Equal(t, 30*time.Minute, entry.CacheDuration) } @@ -107,6 +107,16 @@ func TestParseOryWithAuthProviderConfig(t *testing.T) { require.Equal(t, "https://auth.mycompany.com", config.AuthProvider.JWT[0].Issuer.URL) } +func TestParseAdminAuthConfig(t *testing.T) { + setBaseEnv(t) + t.Setenv("ADMIN_AUTH_CONFIG", `{"jwt":[{"issuer":{"url":"https://workspace.example.com","audiences":["fx1"]}}]}`) + + config, err := Parse() + require.NoError(t, err) + require.Len(t, config.AdminAuth.JWT, 1) + require.Equal(t, "https://workspace.example.com", config.AdminAuth.JWT[0].Issuer.URL) +} + func TestParseFailureCondition(t *testing.T) { tests := []struct { name string diff --git a/packages/dashboard-api/internal/handlers/workspace_admin.go b/packages/dashboard-api/internal/handlers/workspace_admin.go new file mode 100644 index 0000000000..2d225dcf6b --- /dev/null +++ b/packages/dashboard-api/internal/handlers/workspace_admin.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" +) + +func (s *APIStore) UpsertProject(c *gin.Context, _ api.TeamID) { + sendNotImplemented(c) +} + +func (s *APIStore) DeleteProject(c *gin.Context, _ api.TeamID) { + sendNotImplemented(c) +} + +func (s *APIStore) UpsertProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { + sendNotImplemented(c) +} + +func (s *APIStore) DeleteProjectMember(c *gin.Context, _ api.TeamID, _ api.UserId) { + sendNotImplemented(c) +} + +func (s *APIStore) UpsertProjectLimits(c *gin.Context, _ api.TeamID) { + sendNotImplemented(c) +} + +func (s *APIStore) PurgeUser(c *gin.Context, _ api.UserId) { + sendNotImplemented(c) +} + +func sendNotImplemented(c *gin.Context) { + c.JSON(http.StatusNotImplemented, api.Error{ + Code: http.StatusNotImplemented, + Message: "operation is not implemented", + }) +} diff --git a/packages/dashboard-api/internal/identity/issuer.go b/packages/dashboard-api/internal/identity/issuer.go index f5c43efdf0..b99cb81eaa 100644 --- a/packages/dashboard-api/internal/identity/issuer.go +++ b/packages/dashboard-api/internal/identity/issuer.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" ) // ResolveOryIssuer picks the Ory issuer URL from the auth provider's JWT @@ -14,7 +14,7 @@ import ( // When exactly one JWT entry is configured, its issuer is used without // requiring a host match. // When no JWT entries are configured, it falls back to the SDK URL. -func ResolveOryIssuer(sdkURL string, jwtConfigs []oidc.Config) (string, error) { +func ResolveOryIssuer(sdkURL string, jwtConfigs []jwks.Config) (string, error) { sdkURL = strings.TrimSpace(sdkURL) issuers := uniqueIssuerURLs(jwtConfigs) @@ -48,7 +48,7 @@ func ResolveOryIssuer(sdkURL string, jwtConfigs []oidc.Config) (string, error) { return "", fmt.Errorf("no JWT issuer in AUTH_PROVIDER_CONFIG matches ORY_SDK_URL host %q", sdkHost) } -func uniqueIssuerURLs(jwtConfigs []oidc.Config) []string { +func uniqueIssuerURLs(jwtConfigs []jwks.Config) []string { seen := make(map[string]struct{}, len(jwtConfigs)) issuers := make([]string, 0, len(jwtConfigs)) for _, jwt := range jwtConfigs { diff --git a/packages/dashboard-api/internal/identity/issuer_test.go b/packages/dashboard-api/internal/identity/issuer_test.go index d87f62f00d..3cb9418fe4 100644 --- a/packages/dashboard-api/internal/identity/issuer_test.go +++ b/packages/dashboard-api/internal/identity/issuer_test.go @@ -5,14 +5,14 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" ) func TestResolveOryIssuer_SingleJWT(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []oidc.Config{ - {Issuer: oidc.Issuer{URL: "https://auth.example.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ + {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, }) require.NoError(t, err) require.Equal(t, "https://auth.example.com", issuer) @@ -21,9 +21,9 @@ func TestResolveOryIssuer_SingleJWT(t *testing.T) { func TestResolveOryIssuer_MultipleJWTMatchesSDKHost(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []oidc.Config{ - {Issuer: oidc.Issuer{URL: "https://auth-a.mycompany.com"}}, - {Issuer: oidc.Issuer{URL: "https://tenant.projects.oryapis.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ + {Issuer: jwks.Issuer{URL: "https://auth-a.mycompany.com"}}, + {Issuer: jwks.Issuer{URL: "https://tenant.projects.oryapis.com"}}, }) require.NoError(t, err) require.Equal(t, "https://tenant.projects.oryapis.com", issuer) @@ -32,9 +32,9 @@ func TestResolveOryIssuer_MultipleJWTMatchesSDKHost(t *testing.T) { func TestResolveOryIssuer_MultipleJWTNoMatch(t *testing.T) { t.Parallel() - _, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []oidc.Config{ - {Issuer: oidc.Issuer{URL: "https://auth-a.mycompany.com"}}, - {Issuer: oidc.Issuer{URL: "https://auth-b.mycompany.com"}}, + _, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ + {Issuer: jwks.Issuer{URL: "https://auth-a.mycompany.com"}}, + {Issuer: jwks.Issuer{URL: "https://auth-b.mycompany.com"}}, }) require.Error(t, err) require.Contains(t, err.Error(), "no JWT issuer") @@ -59,9 +59,9 @@ func TestResolveOryIssuer_NoJWTConfigsAndNoSDKURL(t *testing.T) { func TestResolveOryIssuer_DeduplicatesSameIssuer(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []oidc.Config{ - {Issuer: oidc.Issuer{URL: "https://auth.example.com"}}, - {Issuer: oidc.Issuer{URL: "https://auth.example.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ + {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, + {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, }) require.NoError(t, err) require.Equal(t, "https://auth.example.com", issuer) diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 5f19023e00..91e4a37f69 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -247,9 +247,17 @@ func run() int { } swagger.Servers = nil + adminVerifier, err := sharedauth.NewAdminJWTVerifier(ctx, config.AdminAuth, authClient) + if err != nil { + l.Error(ctx, "initializing admin JWT verifier", zap.Error(err)) + + return 1 + } + authenticationFunc := sharedauth.CreateAuthenticationFunc( []sharedauth.Authenticator{ sharedauth.NewAdminApiKeyAuthenticator(config.AdminToken), + sharedauth.NewAdminJWTAuthenticator(adminVerifier), sharedauth.NewAuthProviderBearerAuthenticator(apiStore.GetUserIDFromAuthProviderToken), sharedauth.NewAuthProviderTeamAuthenticator(apiStore.GetTeamFromAuthProviderToken), }, @@ -297,7 +305,7 @@ func newHTTPServer( tel *telemetry.Client, swagger *openapi3.T, authenticationFunc openapi3filter.AuthenticationFunc, - apiStore *handlers.APIStore, + store api.ServerInterface, ) *http.Server { r := gin.New() r.Use(gin.Recovery()) @@ -368,7 +376,7 @@ func newHTTPServer( r.Use(dashboardmiddleware.EnforceBlockedTeam()) - api.RegisterHandlers(r, apiStore) + api.RegisterHandlers(r, store) s := &http.Server{ Handler: r, diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index ae3578c10c..b6e7c10996 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -10,6 +10,10 @@ components: type: apiKey in: header name: X-Admin-Token + AdminJWTAuth: + type: http + scheme: bearer + bearerFormat: JWT # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name # so Bearer is validated before Team. AuthProviderBearerAuth: @@ -277,6 +281,12 @@ components: application/json: schema: $ref: "#/components/schemas/Error" + "501": + description: Operation is not implemented + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "502": description: Upstream error content: @@ -1148,11 +1158,90 @@ components: slug: type: string + AdminControlPlaneProjectType: + type: string + enum: [development, staging, production] + + AdminControlPlaneProjectUpsertRequest: + type: object + required: [name, slug, project_type] + properties: + name: + type: string + minLength: 1 + maxLength: 255 + slug: + type: string + minLength: 1 + maxLength: 63 + pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" + project_type: + $ref: "#/components/schemas/AdminControlPlaneProjectType" + + AdminControlPlaneProject: + allOf: + - $ref: "#/components/schemas/AdminControlPlaneProjectUpsertRequest" + - type: object + required: [id] + properties: + id: + type: string + format: uuid + + AdminControlPlaneMemberUpsertRequest: + type: object + properties: + added_by: + type: string + format: uuid + + AdminControlPlaneProjectLimits: + type: object + required: + - concurrent_sandboxes + - max_sandbox_length_hours + - max_vcpu + - max_ram_mb + - disk_mb + - concurrent_template_builds + - events_ttl_days + properties: + concurrent_sandboxes: + type: integer + format: int32 + minimum: 1 + max_sandbox_length_hours: + type: integer + format: int32 + minimum: 1 + max_vcpu: + type: integer + format: int32 + minimum: 1 + max_ram_mb: + type: integer + format: int64 + minimum: 1 + disk_mb: + type: integer + format: int64 + minimum: 1 + concurrent_template_builds: + type: integer + format: int32 + minimum: 1 + events_ttl_days: + type: integer + format: int32 + minimum: 1 + tags: - name: builds - name: sandboxes - name: teams - name: templates + - name: workspace-admin + description: Workspace control-plane admin operations authenticated with service JWTs. paths: /health: @@ -1786,3 +1875,158 @@ paths: $ref: "#/components/responses/404" "500": $ref: "#/components/responses/500" + + /admin/v1/projects/{teamID}: + parameters: + - $ref: "#/components/parameters/teamID" + put: + operationId: upsertProject + summary: Create or reconcile a project. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminControlPlaneProjectUpsertRequest" + responses: + "200": + description: Existing project reconciled. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminControlPlaneProject" + "201": + description: Project created. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminControlPlaneProject" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "409": + $ref: "#/components/responses/409" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + delete: + operationId: deleteProject + summary: Delete a project and its control-plane state. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + responses: + "204": + description: Project state is absent. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /admin/v1/projects/{teamID}/members/{userId}: + parameters: + - $ref: "#/components/parameters/teamID" + - $ref: "#/components/parameters/userId" + put: + operationId: upsertProjectMember + summary: Reconcile an opaque user UUID as a project member. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AdminControlPlaneMemberUpsertRequest" + responses: + "204": + description: Membership is present. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + delete: + operationId: deleteProjectMember + summary: Remove a project member. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + responses: + "204": + description: Membership is absent. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /admin/v1/projects/{teamID}/limits: + parameters: + - $ref: "#/components/parameters/teamID" + put: + operationId: upsertProjectLimits + summary: Reconcile effective limits for a project. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminControlPlaneProjectLimits" + responses: + "204": + description: Effective limits are synchronized. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "404": + $ref: "#/components/responses/404" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" + + /admin/v1/users/{userId}: + parameters: + - $ref: "#/components/parameters/userId" + delete: + operationId: purgeUser + summary: Purge shard-local membership and access-token state for an opaque user UUID. + tags: [workspace-admin] + security: + - AdminJWTAuth: [] + responses: + "204": + description: User-owned shard state is absent. + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + "501": + $ref: "#/components/responses/501" From 91ee87a30c88903b5ecb679d3f04b79fd2844c72 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 20 Jul 2026 21:56:12 +0200 Subject: [PATCH 2/9] refactor(auth): extract token/verifier handling into pkg/token - Move jwks, oidc, provider config parsing, admin verifier, and provider verifier from packages/auth/pkg/auth to packages/auth/pkg/token. - Rename Verifier/AdminJWTVerifier types to Provider/AdminVerifier. - Update auth package service, middleware, and identity lookup to consume the new token package. - Update api and dashboard-api config/model imports to use token package. --- packages/api/internal/cfg/model.go | 8 +++--- packages/auth/pkg/auth/identity_lookup.go | 2 +- .../auth/pkg/auth/identity_lookup_test.go | 2 +- packages/auth/pkg/auth/middleware.go | 3 ++- packages/auth/pkg/auth/service.go | 9 ++++--- .../pkg/{auth/admin_jwt.go => token/admin.go} | 16 ++++++------ .../admin_jwt_test.go => token/admin_test.go} | 16 ++++++------ .../auth/pkg/{auth => token}/jwks/audience.go | 0 .../pkg/{auth => token}/jwks/audience_test.go | 0 .../auth/pkg/{auth => token}/jwks/config.go | 0 .../pkg/{auth => token}/jwks/config_test.go | 0 .../pkg/{auth => token}/jwks/testserver.go | 0 .../auth/pkg/{auth => token}/jwks/verifier.go | 0 .../auth/pkg/{auth => token}/oidc/oidc.go | 2 +- .../pkg/{auth => token}/oidc/oidc_test.go | 2 +- .../{auth/verifier.go => token/provider.go} | 25 ++++++++++--------- .../{auth => token}/provider_config_parse.go | 2 +- .../provider_test.go} | 12 ++++----- packages/dashboard-api/internal/cfg/model.go | 20 +++++++-------- .../dashboard-api/internal/cfg/model_test.go | 2 +- .../dashboard-api/internal/identity/issuer.go | 2 +- .../internal/identity/issuer_test.go | 2 +- packages/dashboard-api/main.go | 3 ++- 23 files changed, 66 insertions(+), 62 deletions(-) rename packages/auth/pkg/{auth/admin_jwt.go => token/admin.go} (75%) rename packages/auth/pkg/{auth/admin_jwt_test.go => token/admin_test.go} (85%) rename packages/auth/pkg/{auth => token}/jwks/audience.go (100%) rename packages/auth/pkg/{auth => token}/jwks/audience_test.go (100%) rename packages/auth/pkg/{auth => token}/jwks/config.go (100%) rename packages/auth/pkg/{auth => token}/jwks/config_test.go (100%) rename packages/auth/pkg/{auth => token}/jwks/testserver.go (100%) rename packages/auth/pkg/{auth => token}/jwks/verifier.go (100%) rename packages/auth/pkg/{auth => token}/oidc/oidc.go (98%) rename packages/auth/pkg/{auth => token}/oidc/oidc_test.go (99%) rename packages/auth/pkg/{auth/verifier.go => token/provider.go} (81%) rename packages/auth/pkg/{auth => token}/provider_config_parse.go (98%) rename packages/auth/pkg/{auth/verifier_test.go => token/provider_test.go} (92%) diff --git a/packages/api/internal/cfg/model.go b/packages/api/internal/cfg/model.go index ddd3a894b3..2008559d54 100644 --- a/packages/api/internal/cfg/model.go +++ b/packages/api/internal/cfg/model.go @@ -12,7 +12,7 @@ import ( "github.com/caarlos0/env/v11" "github.com/golang-jwt/jwt/v5" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/auth/pkg/token" ) const ( @@ -102,7 +102,7 @@ type Config struct { VolumesToken VolumesTokenConfig - AuthProvider auth.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` + AuthProvider token.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` DefaultPersistentVolumeType string `env:"DEFAULT_PERSISTENT_VOLUME_TYPE"` @@ -198,8 +198,8 @@ var ( ErrUnknownKeyType = errors.New("unknown JWT signing key type") parserFuncs = map[reflect.Type]env.ParserFunc{ - reflect.TypeFor[auth.ProviderConfig](): func(v string) (any, error) { - return auth.ParseProviderConfig(v) + reflect.TypeFor[token.ProviderConfig](): func(v string) (any, error) { + return token.ParseProviderConfig(v) }, reflect.TypeFor[JWTSigningKey](): func(v string) (any, error) { keyPieces := strings.SplitN(v, ":", 2) diff --git a/packages/auth/pkg/auth/identity_lookup.go b/packages/auth/pkg/auth/identity_lookup.go index 2cc0dd93fd..33014b77f0 100644 --- a/packages/auth/pkg/auth/identity_lookup.go +++ b/packages/auth/pkg/auth/identity_lookup.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/shared/pkg/cache" diff --git a/packages/auth/pkg/auth/identity_lookup_test.go b/packages/auth/pkg/auth/identity_lookup_test.go index e72fe53c28..395a5db6c7 100644 --- a/packages/auth/pkg/auth/identity_lookup_test.go +++ b/packages/auth/pkg/auth/identity_lookup_test.go @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" ) type countingIdentityLookup struct { diff --git a/packages/auth/pkg/auth/middleware.go b/packages/auth/pkg/auth/middleware.go index 561658a7eb..45c17ba4cc 100644 --- a/packages/auth/pkg/auth/middleware.go +++ b/packages/auth/pkg/auth/middleware.go @@ -14,6 +14,7 @@ import ( middleware "github.com/oapi-codegen/gin-middleware" "go.opentelemetry.io/otel/attribute" + "github.com/e2b-dev/infra/packages/auth/pkg/token" "github.com/e2b-dev/infra/packages/auth/pkg/types" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -186,7 +187,7 @@ func NewAuthProviderTeamAuthenticator(validationFunc func(ctx context.Context, g } // NewAdminJWTAuthenticator creates an authenticator for the AdminJWTAuth security scheme. -func NewAdminJWTAuthenticator(verifier *AdminJWTVerifier) Authenticator { +func NewAdminJWTAuthenticator(verifier *token.AdminVerifier) Authenticator { return &commonAuthenticator[struct{}]{ schemeName: "AdminJWTAuth", header: headerKey{ diff --git a/packages/auth/pkg/auth/service.go b/packages/auth/pkg/auth/service.go index 447fb0c347..9d1952aab4 100644 --- a/packages/auth/pkg/auth/service.go +++ b/packages/auth/pkg/auth/service.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/redis/go-redis/v9" + "github.com/e2b-dev/infra/packages/auth/pkg/token" "github.com/e2b-dev/infra/packages/auth/pkg/types" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" "github.com/e2b-dev/infra/packages/shared/pkg/keys" @@ -44,7 +45,7 @@ type Service interface { type authService struct { store authStore teamCache *authCache - authProviderVerifier *ProviderVerifier + authProviderVerifier *token.ProviderVerifier } // Compile-time assertion that *authService satisfies the Service interface. @@ -59,7 +60,7 @@ func NewAuthService( ctx context.Context, redisClient redis.UniversalClient, authDB *authdb.Client, - providerConfig ProviderConfig, + providerConfig token.ProviderConfig, httpClient *http.Client, ) (*authService, error) { if redisClient == nil { @@ -77,7 +78,7 @@ func NewAuthService( // OIDC bootstrap writes identity rows on the primary immediately before the // next authenticated request; using the read replica here races replication lag. identityLookup := newAuthIdentityLookup(authDB.Write) - v, err := NewProviderVerifier(ctx, providerConfig, httpClient, identityLookup) + v, err := token.NewProviderVerifier(ctx, providerConfig, httpClient, identityLookup) if err != nil { return nil, fmt.Errorf("initializing auth provider JWT verifier: %w", err) } @@ -183,7 +184,7 @@ func (s *authService) ValidateAuthProviderToken(ctx context.Context, ginCtx *gin return s.validateJWTWithProvider(ctx, ginCtx, s.authProviderVerifier, token, "auth provider") } -func (s *authService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *ProviderVerifier, token string, tokenSource string) (uuid.UUID, *APIError) { +func (s *authService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *token.ProviderVerifier, token string, tokenSource string) (uuid.UUID, *APIError) { userID, _, err := v.Verify(ctx, token) if err != nil { return uuid.UUID{}, &APIError{ diff --git a/packages/auth/pkg/auth/admin_jwt.go b/packages/auth/pkg/token/admin.go similarity index 75% rename from packages/auth/pkg/auth/admin_jwt.go rename to packages/auth/pkg/token/admin.go index 1811ec14c3..bef05fa21a 100644 --- a/packages/auth/pkg/auth/admin_jwt.go +++ b/packages/auth/pkg/token/admin.go @@ -1,4 +1,4 @@ -package auth +package token import ( "context" @@ -9,24 +9,24 @@ import ( "github.com/golang-jwt/jwt/v5" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) // adminJWTClockSkew is the leeway applied to time-based claims of admin // service JWTs. const adminJWTClockSkew = 30 * time.Second -// AdminJWTVerifier verifies admin service JWTs against one or more configured +// AdminVerifier verifies admin service JWTs against one or more configured // issuers and returns the first successful verification. -type AdminJWTVerifier struct { +type AdminVerifier struct { verifiers []*jwks.Verifier } -// NewAdminJWTVerifier builds the verifier for the AdminJWTAuth security +// NewAdminVerifier builds the verifier for the AdminJWTAuth security // scheme from the same ProviderConfig shape used for AUTH_PROVIDER_CONFIG: // short-lived EdDSA-signed service tokens. It returns nil when the config has // no issuers, leaving the scheme unconfigured. -func NewAdminJWTVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminJWTVerifier, error) { +func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminVerifier, error) { normalized := config.normalize() if err := normalized.validate(); err != nil { return nil, err @@ -49,12 +49,12 @@ func NewAdminJWTVerifier(ctx context.Context, config ProviderConfig, httpClient verifiers = append(verifiers, verifier) } - return &AdminJWTVerifier{verifiers: verifiers}, nil + return &AdminVerifier{verifiers: verifiers}, nil } // Verify iterates over the configured issuers and returns the claims of the // first successful verification. -func (v *AdminJWTVerifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { +func (v *AdminVerifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { if v == nil || len(v.verifiers) == 0 { return nil, errors.New("admin JWT verifier is not configured") } diff --git a/packages/auth/pkg/auth/admin_jwt_test.go b/packages/auth/pkg/token/admin_test.go similarity index 85% rename from packages/auth/pkg/auth/admin_jwt_test.go rename to packages/auth/pkg/token/admin_test.go index 645fb22cba..0a1ad2ea19 100644 --- a/packages/auth/pkg/auth/admin_jwt_test.go +++ b/packages/auth/pkg/token/admin_test.go @@ -1,4 +1,4 @@ -package auth +package token import ( "crypto/ed25519" @@ -10,7 +10,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) const ( @@ -18,7 +18,7 @@ const ( adminTestAudience = "fx1" ) -func newAdminTestVerifier(t *testing.T) (*AdminJWTVerifier, ed25519.PrivateKey, string) { +func newAdminTestVerifier(t *testing.T) (*AdminVerifier, ed25519.PrivateKey, string) { t.Helper() const issuer = "https://workspace.example.com" @@ -28,7 +28,7 @@ func newAdminTestVerifier(t *testing.T) (*AdminJWTVerifier, ed25519.PrivateKey, server := jwks.NewTestServer(t, publicKey, adminTestKeyID, jose.EdDSA, issuer) - verifier, err := NewAdminJWTVerifier(t.Context(), ProviderConfig{ + verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{ JWT: []jwks.Config{{ Issuer: jwks.Issuer{ URL: issuer, @@ -53,7 +53,7 @@ func signAdminToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.MapC return signed } -func TestAdminJWTVerifier(t *testing.T) { +func TestAdminVerifier(t *testing.T) { t.Parallel() verifier, privateKey, issuer := newAdminTestVerifier(t) @@ -92,10 +92,10 @@ func TestAdminJWTVerifier(t *testing.T) { }) } -func TestAdminJWTVerifierDisabled(t *testing.T) { +func TestAdminVerifierDisabled(t *testing.T) { t.Parallel() - verifier, err := NewAdminJWTVerifier(t.Context(), ProviderConfig{}, nil) + verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{}, nil) require.NoError(t, err) require.Nil(t, verifier) @@ -103,7 +103,7 @@ func TestAdminJWTVerifierDisabled(t *testing.T) { require.ErrorContains(t, err, "not configured") } -func TestAdminJWTVerifierRejectsNonEdDSA(t *testing.T) { +func TestAdminVerifierRejectsNonEdDSA(t *testing.T) { t.Parallel() verifier, _, issuer := newAdminTestVerifier(t) diff --git a/packages/auth/pkg/auth/jwks/audience.go b/packages/auth/pkg/token/jwks/audience.go similarity index 100% rename from packages/auth/pkg/auth/jwks/audience.go rename to packages/auth/pkg/token/jwks/audience.go diff --git a/packages/auth/pkg/auth/jwks/audience_test.go b/packages/auth/pkg/token/jwks/audience_test.go similarity index 100% rename from packages/auth/pkg/auth/jwks/audience_test.go rename to packages/auth/pkg/token/jwks/audience_test.go diff --git a/packages/auth/pkg/auth/jwks/config.go b/packages/auth/pkg/token/jwks/config.go similarity index 100% rename from packages/auth/pkg/auth/jwks/config.go rename to packages/auth/pkg/token/jwks/config.go diff --git a/packages/auth/pkg/auth/jwks/config_test.go b/packages/auth/pkg/token/jwks/config_test.go similarity index 100% rename from packages/auth/pkg/auth/jwks/config_test.go rename to packages/auth/pkg/token/jwks/config_test.go diff --git a/packages/auth/pkg/auth/jwks/testserver.go b/packages/auth/pkg/token/jwks/testserver.go similarity index 100% rename from packages/auth/pkg/auth/jwks/testserver.go rename to packages/auth/pkg/token/jwks/testserver.go diff --git a/packages/auth/pkg/auth/jwks/verifier.go b/packages/auth/pkg/token/jwks/verifier.go similarity index 100% rename from packages/auth/pkg/auth/jwks/verifier.go rename to packages/auth/pkg/token/jwks/verifier.go diff --git a/packages/auth/pkg/auth/oidc/oidc.go b/packages/auth/pkg/token/oidc/oidc.go similarity index 98% rename from packages/auth/pkg/auth/oidc/oidc.go rename to packages/auth/pkg/token/oidc/oidc.go index 13af3dad94..0124a4ee84 100644 --- a/packages/auth/pkg/auth/oidc/oidc.go +++ b/packages/auth/pkg/token/oidc/oidc.go @@ -9,7 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) // ErrIdentityNotFound is returned by Verify when the token is valid but no diff --git a/packages/auth/pkg/auth/oidc/oidc_test.go b/packages/auth/pkg/token/oidc/oidc_test.go similarity index 99% rename from packages/auth/pkg/auth/oidc/oidc_test.go rename to packages/auth/pkg/token/oidc/oidc_test.go index 4fe0d3f98b..c0b2037e54 100644 --- a/packages/auth/pkg/auth/oidc/oidc_test.go +++ b/packages/auth/pkg/token/oidc/oidc_test.go @@ -13,7 +13,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) const testIssuerURL = "https://issuer.example.com" diff --git a/packages/auth/pkg/auth/verifier.go b/packages/auth/pkg/token/provider.go similarity index 81% rename from packages/auth/pkg/auth/verifier.go rename to packages/auth/pkg/token/provider.go index 35042570b5..b1909a4eb2 100644 --- a/packages/auth/pkg/auth/verifier.go +++ b/packages/auth/pkg/token/provider.go @@ -1,4 +1,4 @@ -package auth +package token import ( "context" @@ -9,8 +9,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" ) // ProviderConfig describes external auth provider verification. @@ -45,23 +45,24 @@ func (c ProviderConfig) validate() error { } // strategy is the interface satisfied by per-provider JWT verifiers used by -// Verifier. +// ProviderVerifier. type strategy interface { Verify(ctx context.Context, tokenString string) (uuid.UUID, jwt.MapClaims, error) } -// Verifier aggregates one or more OIDC JWT verification strategies and returns -// the first that succeeds. +// ProviderVerifier aggregates one or more OIDC JWT verification strategies and +// returns the first that succeeds. type ProviderVerifier struct { strategies []strategy } -// NewVerifier constructs a *ProviderVerifier from the given ProviderConfig. +// NewProviderVerifier constructs a *ProviderVerifier from the given +// ProviderConfig. // -// When the provided config has no JWT issuers, NewVerifier returns (nil, nil). -// This is a valid configuration: the caller can pass the nil Verifier to -// authService, and any token verification attempt will be denied at runtime by -// Verifier.Verify / Service.ValidateAuthProviderToken. +// When the provided config has no JWT issuers, NewProviderVerifier returns +// (nil, nil). This is a valid configuration: the caller can pass the nil +// ProviderVerifier along, and any token verification attempt will be denied at +// runtime by ProviderVerifier.Verify. func NewProviderVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *http.Client, identities oidc.IdentityLookup) (*ProviderVerifier, error) { normalized := config.normalize() if err := normalized.validate(); err != nil { @@ -78,7 +79,7 @@ func NewProviderVerifier(ctx context.Context, config ProviderConfig, oidcHTTPCli } for i, entry := range normalized.JWT { - s, err := oidc.NewProviderVerifier(ctx, entry, oidcHTTPClient, identities) + s, err := oidc.NewVerifier(ctx, entry, oidcHTTPClient, identities) if err != nil { return nil, fmt.Errorf("auth provider jwt[%d]: %w", i, err) } diff --git a/packages/auth/pkg/auth/provider_config_parse.go b/packages/auth/pkg/token/provider_config_parse.go similarity index 98% rename from packages/auth/pkg/auth/provider_config_parse.go rename to packages/auth/pkg/token/provider_config_parse.go index 3e8f96cd40..1d8aae1d35 100644 --- a/packages/auth/pkg/auth/provider_config_parse.go +++ b/packages/auth/pkg/token/provider_config_parse.go @@ -1,4 +1,4 @@ -package auth +package token import ( "encoding/json" diff --git a/packages/auth/pkg/auth/verifier_test.go b/packages/auth/pkg/token/provider_test.go similarity index 92% rename from packages/auth/pkg/auth/verifier_test.go rename to packages/auth/pkg/token/provider_test.go index 7d59c0a0a6..ef0720f7e9 100644 --- a/packages/auth/pkg/auth/verifier_test.go +++ b/packages/auth/pkg/token/provider_test.go @@ -1,4 +1,4 @@ -package auth +package token import ( "context" @@ -16,8 +16,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/oidc" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" ) const testIssuerURL = "https://issuer.example.com" @@ -66,7 +66,7 @@ func httpClientForServers(servers ...*httptest.Server) *http.Client { func TestNewVerifier_DisabledConfigReturnsNil(t *testing.T) { t.Parallel() - verifier, err := NewVerifier(t.Context(), ProviderConfig{}, nil, nil) + verifier, err := NewProviderVerifier(t.Context(), ProviderConfig{}, nil, nil) require.NoError(t, err) require.Nil(t, verifier) } @@ -85,7 +85,7 @@ func TestVerifier_VerifyJWT(t *testing.T) { jwksUserID := uuid.New() lookup.set(testIssuerURL, jwksSub, jwksUserID) - verifier, err := NewVerifier(t.Context(), ProviderConfig{ + verifier, err := NewProviderVerifier(t.Context(), ProviderConfig{ JWT: []jwks.Config{ { Issuer: jwks.Issuer{ @@ -137,7 +137,7 @@ func TestVerifier_VerifyMultipleJWTIssuers(t *testing.T) { userID := uuid.New() lookup.set(issuer2URL, tokenSub, userID) - verifier, err := NewVerifier(t.Context(), ProviderConfig{ + verifier, err := NewProviderVerifier(t.Context(), ProviderConfig{ JWT: []jwks.Config{ { Issuer: jwks.Issuer{ diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 2308515581..7d6f02adc0 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -6,17 +6,17 @@ import ( "github.com/caarlos0/env/v11" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/auth/pkg/token" ) type Config struct { - Port int `env:"PORT" envDefault:"3010"` - PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING,required,notEmpty"` - ClickhouseConnectionString string `env:"CLICKHOUSE_CONNECTION_STRING"` - ClickhouseConnectionStrings []string `env:"CLICKHOUSE_CONNECTION_STRINGS" envSeparator:";"` - AdminToken string `env:"ADMIN_TOKEN,required,notEmpty"` - AuthProvider auth.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` - AdminAuth auth.ProviderConfig `env:"ADMIN_AUTH_CONFIG"` + Port int `env:"PORT" envDefault:"3010"` + PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING,required,notEmpty"` + ClickhouseConnectionString string `env:"CLICKHOUSE_CONNECTION_STRING"` + ClickhouseConnectionStrings []string `env:"CLICKHOUSE_CONNECTION_STRINGS" envSeparator:";"` + AdminToken string `env:"ADMIN_TOKEN,required,notEmpty"` + AuthProvider token.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` + AdminAuth token.ProviderConfig `env:"ADMIN_AUTH_CONFIG"` AuthDBConnectionString string `env:"AUTH_DB_CONNECTION_STRING"` AuthDBReadReplicaConnectionString string `env:"AUTH_DB_READ_REPLICA_CONNECTION_STRING"` @@ -75,8 +75,8 @@ func Parse() (Config, error) { var config Config err := env.ParseWithOptions(&config, env.Options{ FuncMap: map[reflect.Type]env.ParserFunc{ - reflect.TypeFor[auth.ProviderConfig](): func(v string) (any, error) { - return auth.ParseProviderConfig(v) + reflect.TypeFor[token.ProviderConfig](): func(v string) (any, error) { + return token.ParseProviderConfig(v) }, }, }) diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index 6ad785f08d..a639ce401a 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) func setBaseEnv(t *testing.T) { diff --git a/packages/dashboard-api/internal/identity/issuer.go b/packages/dashboard-api/internal/identity/issuer.go index b99cb81eaa..c46179957e 100644 --- a/packages/dashboard-api/internal/identity/issuer.go +++ b/packages/dashboard-api/internal/identity/issuer.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) // ResolveOryIssuer picks the Ory issuer URL from the auth provider's JWT diff --git a/packages/dashboard-api/internal/identity/issuer_test.go b/packages/dashboard-api/internal/identity/issuer_test.go index 3cb9418fe4..70d9cf09ea 100644 --- a/packages/dashboard-api/internal/identity/issuer_test.go +++ b/packages/dashboard-api/internal/identity/issuer_test.go @@ -5,7 +5,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/jwks" + "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" ) func TestResolveOryIssuer_SingleJWT(t *testing.T) { diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 91e4a37f69..c58ebf63cf 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -24,6 +24,7 @@ import ( "go.uber.org/zap/zapcore" sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" + "github.com/e2b-dev/infra/packages/auth/pkg/token" clickhouse "github.com/e2b-dev/infra/packages/clickhouse/pkg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" @@ -247,7 +248,7 @@ func run() int { } swagger.Servers = nil - adminVerifier, err := sharedauth.NewAdminJWTVerifier(ctx, config.AdminAuth, authClient) + adminVerifier, err := token.NewAdminVerifier(ctx, config.AdminAuth, authClient) if err != nil { l.Error(ctx, "initializing admin JWT verifier", zap.Error(err)) From d9a6b0e355121167c60b428bb41f727421c14d78 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 10:37:52 +0200 Subject: [PATCH 3/9] feat(auth): configurable issuer signing algorithm and admin verifier startup warning - Add optional algorithm enum (EdDSA/ES256) to jwks.Issuer. - Validate supported algorithms during config validation. - Enforce the configured algorithm in jwks.Verifier. - Default admin JWT issuers to EdDSA when no algorithm is set. - Log a startup warning when dashboard-api ADMIN_AUTH_CONFIG is empty while /admin/v1 routes are compiled in. - Add tests for algorithm validation and ES256 admin JWT verification. --- packages/auth/pkg/token/admin.go | 9 ++--- packages/auth/pkg/token/admin_test.go | 37 +++++++++++++++++++++ packages/auth/pkg/token/jwks/config.go | 28 ++++++++++++++++ packages/auth/pkg/token/jwks/config_test.go | 31 +++++++++++++++++ packages/auth/pkg/token/jwks/verifier.go | 17 ++++++---- packages/dashboard-api/main.go | 4 +++ 6 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/auth/pkg/token/admin.go b/packages/auth/pkg/token/admin.go index bef05fa21a..f6a54f037b 100644 --- a/packages/auth/pkg/token/admin.go +++ b/packages/auth/pkg/token/admin.go @@ -37,11 +37,12 @@ func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *ht verifiers := make([]*jwks.Verifier, 0, len(normalized.JWT)) for i, entry := range normalized.JWT { + if entry.Issuer.Algorithm == "" { + entry.Issuer.Algorithm = jwks.SigningAlgorithmEdDSA + } + verifier, err := jwks.NewVerifier(ctx, entry, httpClient, - jwks.WithParserOptions( - jwt.WithLeeway(adminJWTClockSkew), - jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), - ), + jwks.WithParserOptions(jwt.WithLeeway(adminJWTClockSkew)), ) if err != nil { return nil, fmt.Errorf("admin JWT jwt[%d]: %w", i, err) diff --git a/packages/auth/pkg/token/admin_test.go b/packages/auth/pkg/token/admin_test.go index 0a1ad2ea19..7555b3013a 100644 --- a/packages/auth/pkg/token/admin_test.go +++ b/packages/auth/pkg/token/admin_test.go @@ -1,7 +1,9 @@ package token import ( + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "crypto/rand" "testing" "time" @@ -120,3 +122,38 @@ func TestAdminVerifierRejectsNonEdDSA(t *testing.T) { _, err = verifier.Verify(t.Context(), signed) require.Error(t, err) } + +func TestAdminVerifierES256(t *testing.T) { + t.Parallel() + + const issuer = "https://workspace.example.com" + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + server := jwks.NewTestServer(t, &privateKey.PublicKey, adminTestKeyID, jose.ES256, issuer) + + verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{ + JWT: []jwks.Config{{ + Issuer: jwks.Issuer{ + URL: issuer, + DiscoveryURL: server.URL + "/.well-known/openid-configuration", + Audiences: []string{adminTestAudience}, + Algorithm: jwks.SigningAlgorithmES256, + }, + }}, + }, server.Client()) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": issuer, + "aud": adminTestAudience, + "exp": time.Now().Add(5 * time.Minute).Unix(), + }) + token.Header["kid"] = adminTestKeyID + signed, err := token.SignedString(privateKey) + require.NoError(t, err) + + _, err = verifier.Verify(t.Context(), signed) + require.NoError(t, err) +} diff --git a/packages/auth/pkg/token/jwks/config.go b/packages/auth/pkg/token/jwks/config.go index c3d631f8b0..4edbc3b59f 100644 --- a/packages/auth/pkg/token/jwks/config.go +++ b/packages/auth/pkg/token/jwks/config.go @@ -23,12 +23,24 @@ type Config struct { CacheDuration time.Duration `json:"cacheDuration"` } +// SigningAlgorithm is the JWS signing algorithm an issuer uses. +// Supported values are "EdDSA" and "ES256". +type SigningAlgorithm string + +const ( + // SigningAlgorithmEdDSA is EdDSA (Ed25519). + SigningAlgorithmEdDSA SigningAlgorithm = "EdDSA" + // SigningAlgorithmES256 is ECDSA using P-256 and SHA-256. + SigningAlgorithmES256 SigningAlgorithm = "ES256" +) + // Issuer describes an OIDC issuer endpoint plus audience policy. type Issuer struct { URL string `json:"url"` DiscoveryURL string `json:"discoveryURL"` Audiences []string `json:"audiences"` AudienceMatchPolicy AudienceMatchPolicy `json:"audienceMatchPolicy"` + Algorithm SigningAlgorithm `json:"algorithm"` } // UnmarshalJSON parses `cacheDuration` from a Go duration string. @@ -62,6 +74,7 @@ func (e *Config) Normalized() Config { out := *e out.Issuer.URL = strings.TrimSpace(out.Issuer.URL) out.Issuer.DiscoveryURL = strings.TrimSpace(out.Issuer.DiscoveryURL) + out.Issuer.Algorithm = SigningAlgorithm(strings.TrimSpace(string(out.Issuer.Algorithm))) if out.CacheDuration <= 0 { out.CacheDuration = defaultCacheDuration @@ -84,9 +97,24 @@ func (e *Config) Validate() error { errs = append(errs, fmt.Errorf("issuer: %w", err)) } + if err := validateSigningAlgorithm(e.Issuer.Algorithm); err != nil { + errs = append(errs, fmt.Errorf("issuer: %w", err)) + } + return errors.Join(errs...) } +// validateSigningAlgorithm ensures the configured algorithm is empty or one of +// the supported JWS algorithms. +func validateSigningAlgorithm(alg SigningAlgorithm) error { + switch alg { + case "", SigningAlgorithmEdDSA, SigningAlgorithmES256: + return nil + default: + return fmt.Errorf("algorithm %q is not supported; must be %q or %q", alg, SigningAlgorithmEdDSA, SigningAlgorithmES256) + } +} + // discoveryURL returns the configured discoveryURL or the default derived // from the issuer URL. func (e *Config) discoveryURL() string { diff --git a/packages/auth/pkg/token/jwks/config_test.go b/packages/auth/pkg/token/jwks/config_test.go index 1f3fe6bd8b..b4e426d20e 100644 --- a/packages/auth/pkg/token/jwks/config_test.go +++ b/packages/auth/pkg/token/jwks/config_test.go @@ -163,6 +163,37 @@ func TestEntry_Validate(t *testing.T) { }, wantErr: "audienceMatchPolicy must be empty or", }, + { + name: "valid EdDSA algorithm", + entry: Config{ + Issuer: Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"a"}, + Algorithm: SigningAlgorithmEdDSA, + }, + }, + }, + { + name: "valid ES256 algorithm", + entry: Config{ + Issuer: Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"a"}, + Algorithm: SigningAlgorithmES256, + }, + }, + }, + { + name: "invalid algorithm", + entry: Config{ + Issuer: Issuer{ + URL: "https://issuer.example.com", + Audiences: []string{"a"}, + Algorithm: "RS256", + }, + }, + wantErr: "algorithm", + }, } for _, tt := range tests { diff --git a/packages/auth/pkg/token/jwks/verifier.go b/packages/auth/pkg/token/jwks/verifier.go index f589aad9ce..d845a5dfcb 100644 --- a/packages/auth/pkg/token/jwks/verifier.go +++ b/packages/auth/pkg/token/jwks/verifier.go @@ -94,13 +94,18 @@ func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, opt return nil, fmt.Errorf("create JWKS keyfunc: %w", err) } + parserOptions := []jwt.ParserOption{ + jwt.WithExpirationRequired(), + jwt.WithIssuer(entry.Issuer.URL), + } + if alg := entry.Issuer.Algorithm; alg != "" { + parserOptions = append(parserOptions, jwt.WithValidMethods([]string{string(alg)})) + } + verifier := &Verifier{ - keyfunc: keyFunc, - audiences: entry.Issuer.Audiences, - parserOptions: []jwt.ParserOption{ - jwt.WithExpirationRequired(), - jwt.WithIssuer(entry.Issuer.URL), - }, + keyfunc: keyFunc, + audiences: entry.Issuer.Audiences, + parserOptions: parserOptions, } for _, option := range options { option(verifier) diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index c58ebf63cf..8918efce44 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -255,6 +255,10 @@ func run() int { return 1 } + if adminVerifier == nil { + l.Warn(ctx, "ADMIN_AUTH_CONFIG is not configured; /admin/v1 endpoints will reject requests with 401") + } + authenticationFunc := sharedauth.CreateAuthenticationFunc( []sharedauth.Authenticator{ sharedauth.NewAdminApiKeyAuthenticator(config.AdminToken), From a92108a957bbe8298eb003c5b54cff572418230c Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 11:09:18 +0200 Subject: [PATCH 4/9] fix(auth): skip OIDC discovery for admin JWTs --- packages/auth/pkg/token/admin.go | 5 +-- packages/auth/pkg/token/admin_test.go | 23 +++++------- packages/auth/pkg/token/jwks/config.go | 1 + packages/auth/pkg/token/jwks/testserver.go | 6 ++-- packages/auth/pkg/token/jwks/verifier.go | 42 ++++++++++++++++++---- 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/packages/auth/pkg/token/admin.go b/packages/auth/pkg/token/admin.go index f6a54f037b..86fb42948b 100644 --- a/packages/auth/pkg/token/admin.go +++ b/packages/auth/pkg/token/admin.go @@ -28,9 +28,6 @@ type AdminVerifier struct { // no issuers, leaving the scheme unconfigured. func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminVerifier, error) { normalized := config.normalize() - if err := normalized.validate(); err != nil { - return nil, err - } if !normalized.enabled() { return nil, nil } @@ -41,7 +38,7 @@ func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *ht entry.Issuer.Algorithm = jwks.SigningAlgorithmEdDSA } - verifier, err := jwks.NewVerifier(ctx, entry, httpClient, + verifier, err := jwks.NewVerifierFromIssuerJWKS(ctx, entry, httpClient, jwks.WithParserOptions(jwt.WithLeeway(adminJWTClockSkew)), ) if err != nil { diff --git a/packages/auth/pkg/token/admin_test.go b/packages/auth/pkg/token/admin_test.go index 7555b3013a..397c7f6f93 100644 --- a/packages/auth/pkg/token/admin_test.go +++ b/packages/auth/pkg/token/admin_test.go @@ -23,25 +23,22 @@ const ( func newAdminTestVerifier(t *testing.T) (*AdminVerifier, ed25519.PrivateKey, string) { t.Helper() - const issuer = "https://workspace.example.com" - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) - server := jwks.NewTestServer(t, publicKey, adminTestKeyID, jose.EdDSA, issuer) + server := jwks.NewTestServer(t, publicKey, adminTestKeyID, jose.EdDSA, "https://unexpected.example.com") verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{ JWT: []jwks.Config{{ Issuer: jwks.Issuer{ - URL: issuer, - DiscoveryURL: server.URL + "/.well-known/openid-configuration", - Audiences: []string{adminTestAudience}, + URL: server.URL, + Audiences: []string{adminTestAudience}, }, }}, }, server.Client()) require.NoError(t, err) - return verifier, privateKey, issuer + return verifier, privateKey, server.URL } func signAdminToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.MapClaims) string { @@ -126,20 +123,18 @@ func TestAdminVerifierRejectsNonEdDSA(t *testing.T) { func TestAdminVerifierES256(t *testing.T) { t.Parallel() - const issuer = "https://workspace.example.com" - privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - server := jwks.NewTestServer(t, &privateKey.PublicKey, adminTestKeyID, jose.ES256, issuer) + server := jwks.NewTestServer(t, &privateKey.PublicKey, adminTestKeyID, jose.ES256, "https://unexpected.example.com") + issuer := server.URL verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{ JWT: []jwks.Config{{ Issuer: jwks.Issuer{ - URL: issuer, - DiscoveryURL: server.URL + "/.well-known/openid-configuration", - Audiences: []string{adminTestAudience}, - Algorithm: jwks.SigningAlgorithmES256, + URL: issuer, + Audiences: []string{adminTestAudience}, + Algorithm: jwks.SigningAlgorithmES256, }, }}, }, server.Client()) diff --git a/packages/auth/pkg/token/jwks/config.go b/packages/auth/pkg/token/jwks/config.go index 4edbc3b59f..eb528802ed 100644 --- a/packages/auth/pkg/token/jwks/config.go +++ b/packages/auth/pkg/token/jwks/config.go @@ -15,6 +15,7 @@ const ( // defaultDiscoveryPath is the relative path appended to the issuer URL // to derive the discovery URL when one is not explicitly configured. defaultDiscoveryPath = "/.well-known/openid-configuration" + defaultJWKSPath = "/.well-known/jwks.json" ) // Config describes a single OIDC issuer. diff --git a/packages/auth/pkg/token/jwks/testserver.go b/packages/auth/pkg/token/jwks/testserver.go index e50f2c9d6c..f366ab266d 100644 --- a/packages/auth/pkg/token/jwks/testserver.go +++ b/packages/auth/pkg/token/jwks/testserver.go @@ -33,7 +33,7 @@ func NewTestServer(t *testing.T, publicKey any, keyID string, algorithm jose.Sig } }) - mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + jwksHandler := func(w http.ResponseWriter, _ *http.Request) { err := json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{ { Key: publicKey, @@ -45,7 +45,9 @@ func NewTestServer(t *testing.T, publicKey any, keyID string, algorithm jose.Sig if err != nil { t.Errorf("encode JWKS response: %v", err) } - }) + } + mux.HandleFunc("/jwks", jwksHandler) + mux.HandleFunc("/.well-known/jwks.json", jwksHandler) return server } diff --git a/packages/auth/pkg/token/jwks/verifier.go b/packages/auth/pkg/token/jwks/verifier.go index d845a5dfcb..dde8ae0aa8 100644 --- a/packages/auth/pkg/token/jwks/verifier.go +++ b/packages/auth/pkg/token/jwks/verifier.go @@ -49,12 +49,8 @@ type discoveryDocument struct { // OIDC discovery fetch synchronously and fails fast on configuration or // network errors. func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, options ...Option) (*Verifier, error) { - if httpClient == nil { - return nil, errors.New("JWKS HTTP client is required") - } - - entry = entry.Normalized() - if err := entry.Validate(); err != nil { + entry, err := validateConfig(entry, httpClient) + if err != nil { return nil, err } @@ -76,7 +72,39 @@ func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, opt return nil, err } - storage, err := jwkset.NewStorageFromHTTP(doc.JWKSURI, jwkset.HTTPClientStorageOptions{ + return newVerifier(ctx, entry, doc.JWKSURI, httpClient, options...) +} + +func NewVerifierFromIssuerJWKS(ctx context.Context, entry Config, httpClient *http.Client, options ...Option) (*Verifier, error) { + entry.Issuer.DiscoveryURL = "" + entry, err := validateConfig(entry, httpClient) + if err != nil { + return nil, err + } + + jwksURL := strings.TrimRight(entry.Issuer.URL, "/") + defaultJWKSPath + if err := validateHTTPSURL(jwksURL, "jwksURL"); err != nil { + return nil, err + } + + return newVerifier(ctx, entry, jwksURL, httpClient, options...) +} + +func validateConfig(entry Config, httpClient *http.Client) (Config, error) { + if httpClient == nil { + return Config{}, errors.New("JWKS HTTP client is required") + } + + entry = entry.Normalized() + if err := entry.Validate(); err != nil { + return Config{}, err + } + + return entry, nil +} + +func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient *http.Client, options ...Option) (*Verifier, error) { + storage, err := jwkset.NewStorageFromHTTP(jwksURL, jwkset.HTTPClientStorageOptions{ Client: httpClient, Ctx: ctx, HTTPTimeout: httpTimeout, From c6089f50659a40b2344236ce8827d296eb2a1dc3 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 11:21:14 +0200 Subject: [PATCH 5/9] fix(auth): derive signing algorithms from JWKS --- packages/auth/pkg/token/admin.go | 8 +--- packages/auth/pkg/token/admin_test.go | 21 +++++++++- packages/auth/pkg/token/jwks/config.go | 28 ------------- packages/auth/pkg/token/jwks/config_test.go | 31 --------------- packages/auth/pkg/token/jwks/verifier.go | 44 +++++++++++++++++++-- 5 files changed, 63 insertions(+), 69 deletions(-) diff --git a/packages/auth/pkg/token/admin.go b/packages/auth/pkg/token/admin.go index 86fb42948b..43a172be69 100644 --- a/packages/auth/pkg/token/admin.go +++ b/packages/auth/pkg/token/admin.go @@ -24,8 +24,8 @@ type AdminVerifier struct { // NewAdminVerifier builds the verifier for the AdminJWTAuth security // scheme from the same ProviderConfig shape used for AUTH_PROVIDER_CONFIG: -// short-lived EdDSA-signed service tokens. It returns nil when the config has -// no issuers, leaving the scheme unconfigured. +// short-lived service tokens whose signing methods are declared by JWKS keys. +// It returns nil when the config has no issuers, leaving the scheme unconfigured. func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminVerifier, error) { normalized := config.normalize() if !normalized.enabled() { @@ -34,10 +34,6 @@ func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *ht verifiers := make([]*jwks.Verifier, 0, len(normalized.JWT)) for i, entry := range normalized.JWT { - if entry.Issuer.Algorithm == "" { - entry.Issuer.Algorithm = jwks.SigningAlgorithmEdDSA - } - verifier, err := jwks.NewVerifierFromIssuerJWKS(ctx, entry, httpClient, jwks.WithParserOptions(jwt.WithLeeway(adminJWTClockSkew)), ) diff --git a/packages/auth/pkg/token/admin_test.go b/packages/auth/pkg/token/admin_test.go index 397c7f6f93..4cd7892400 100644 --- a/packages/auth/pkg/token/admin_test.go +++ b/packages/auth/pkg/token/admin_test.go @@ -134,7 +134,6 @@ func TestAdminVerifierES256(t *testing.T) { Issuer: jwks.Issuer{ URL: issuer, Audiences: []string{adminTestAudience}, - Algorithm: jwks.SigningAlgorithmES256, }, }}, }, server.Client()) @@ -152,3 +151,23 @@ func TestAdminVerifierES256(t *testing.T) { _, err = verifier.Verify(t.Context(), signed) require.NoError(t, err) } + +func TestAdminVerifierRejectsJWKSKeyWithoutAlgorithm(t *testing.T) { + t.Parallel() + + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + server := jwks.NewTestServer(t, publicKey, adminTestKeyID, "", "https://unexpected.example.com") + + verifier, err := NewAdminVerifier(t.Context(), ProviderConfig{ + JWT: []jwks.Config{{ + Issuer: jwks.Issuer{ + URL: server.URL, + Audiences: []string{adminTestAudience}, + }, + }}, + }, server.Client()) + require.Nil(t, verifier) + require.ErrorContains(t, err, "missing alg") +} diff --git a/packages/auth/pkg/token/jwks/config.go b/packages/auth/pkg/token/jwks/config.go index eb528802ed..8e464bca0a 100644 --- a/packages/auth/pkg/token/jwks/config.go +++ b/packages/auth/pkg/token/jwks/config.go @@ -24,24 +24,12 @@ type Config struct { CacheDuration time.Duration `json:"cacheDuration"` } -// SigningAlgorithm is the JWS signing algorithm an issuer uses. -// Supported values are "EdDSA" and "ES256". -type SigningAlgorithm string - -const ( - // SigningAlgorithmEdDSA is EdDSA (Ed25519). - SigningAlgorithmEdDSA SigningAlgorithm = "EdDSA" - // SigningAlgorithmES256 is ECDSA using P-256 and SHA-256. - SigningAlgorithmES256 SigningAlgorithm = "ES256" -) - // Issuer describes an OIDC issuer endpoint plus audience policy. type Issuer struct { URL string `json:"url"` DiscoveryURL string `json:"discoveryURL"` Audiences []string `json:"audiences"` AudienceMatchPolicy AudienceMatchPolicy `json:"audienceMatchPolicy"` - Algorithm SigningAlgorithm `json:"algorithm"` } // UnmarshalJSON parses `cacheDuration` from a Go duration string. @@ -75,7 +63,6 @@ func (e *Config) Normalized() Config { out := *e out.Issuer.URL = strings.TrimSpace(out.Issuer.URL) out.Issuer.DiscoveryURL = strings.TrimSpace(out.Issuer.DiscoveryURL) - out.Issuer.Algorithm = SigningAlgorithm(strings.TrimSpace(string(out.Issuer.Algorithm))) if out.CacheDuration <= 0 { out.CacheDuration = defaultCacheDuration @@ -98,24 +85,9 @@ func (e *Config) Validate() error { errs = append(errs, fmt.Errorf("issuer: %w", err)) } - if err := validateSigningAlgorithm(e.Issuer.Algorithm); err != nil { - errs = append(errs, fmt.Errorf("issuer: %w", err)) - } - return errors.Join(errs...) } -// validateSigningAlgorithm ensures the configured algorithm is empty or one of -// the supported JWS algorithms. -func validateSigningAlgorithm(alg SigningAlgorithm) error { - switch alg { - case "", SigningAlgorithmEdDSA, SigningAlgorithmES256: - return nil - default: - return fmt.Errorf("algorithm %q is not supported; must be %q or %q", alg, SigningAlgorithmEdDSA, SigningAlgorithmES256) - } -} - // discoveryURL returns the configured discoveryURL or the default derived // from the issuer URL. func (e *Config) discoveryURL() string { diff --git a/packages/auth/pkg/token/jwks/config_test.go b/packages/auth/pkg/token/jwks/config_test.go index b4e426d20e..1f3fe6bd8b 100644 --- a/packages/auth/pkg/token/jwks/config_test.go +++ b/packages/auth/pkg/token/jwks/config_test.go @@ -163,37 +163,6 @@ func TestEntry_Validate(t *testing.T) { }, wantErr: "audienceMatchPolicy must be empty or", }, - { - name: "valid EdDSA algorithm", - entry: Config{ - Issuer: Issuer{ - URL: "https://issuer.example.com", - Audiences: []string{"a"}, - Algorithm: SigningAlgorithmEdDSA, - }, - }, - }, - { - name: "valid ES256 algorithm", - entry: Config{ - Issuer: Issuer{ - URL: "https://issuer.example.com", - Audiences: []string{"a"}, - Algorithm: SigningAlgorithmES256, - }, - }, - }, - { - name: "invalid algorithm", - entry: Config{ - Issuer: Issuer{ - URL: "https://issuer.example.com", - Audiences: []string{"a"}, - Algorithm: "RS256", - }, - }, - wantErr: "algorithm", - }, } for _, tt := range tests { diff --git a/packages/auth/pkg/token/jwks/verifier.go b/packages/auth/pkg/token/jwks/verifier.go index dde8ae0aa8..e3599bdf6e 100644 --- a/packages/auth/pkg/token/jwks/verifier.go +++ b/packages/auth/pkg/token/jwks/verifier.go @@ -114,6 +114,11 @@ func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient * return nil, fmt.Errorf("create JWKS storage: %w", err) } + validMethods, err := validMethodsFromStorage(ctx, storage) + if err != nil { + return nil, fmt.Errorf("validate JWKS signing algorithms: %w", err) + } + keyFunc, err := keyfunc.New(keyfunc.Options{ Ctx: ctx, Storage: storage, @@ -125,9 +130,7 @@ func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient * parserOptions := []jwt.ParserOption{ jwt.WithExpirationRequired(), jwt.WithIssuer(entry.Issuer.URL), - } - if alg := entry.Issuer.Algorithm; alg != "" { - parserOptions = append(parserOptions, jwt.WithValidMethods([]string{string(alg)})) + jwt.WithValidMethods(validMethods), } verifier := &Verifier{ @@ -142,6 +145,41 @@ func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient * return verifier, nil } +func validMethodsFromStorage(ctx context.Context, storage jwkset.Storage) ([]string, error) { + keys, err := storage.KeyReadAll(ctx) + if err != nil { + return nil, fmt.Errorf("read JWKS keys: %w", err) + } + if len(keys) == 0 { + return nil, errors.New("JWKS contains no supported keys") + } + + methods := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + metadata := key.Marshal() + algorithm := metadata.ALG.String() + if algorithm == "" { + return nil, fmt.Errorf("JWKS key %q is missing alg", metadata.KID) + } + if _, ok := seen[algorithm]; ok { + continue + } + + method := jwt.GetSigningMethod(algorithm) + switch method.(type) { + case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS, *jwt.SigningMethodECDSA, *jwt.SigningMethodEd25519: + default: + return nil, fmt.Errorf("JWKS key %q uses unsupported signing algorithm %q", metadata.KID, algorithm) + } + + seen[algorithm] = struct{}{} + methods = append(methods, algorithm) + } + + return methods, nil +} + // Verify parses and validates the supplied token string and returns its // claims. func (v *Verifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { From e97e6386e65b1e48f8fa32e6452d5c0e06819761 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 11:54:16 +0200 Subject: [PATCH 6/9] refactor(auth): expose facade over internal packages --- docs/ARCHITECTURE.md | 8 +- packages/api/internal/cfg/model.go | 8 +- packages/auth/internal/authcontext/context.go | 68 ++++ .../auth/internal/middleware/middleware.go | 290 +++++++++++++++++ .../middleware}/middleware_test.go | 9 +- .../{pkg/auth => internal/service}/cache.go | 2 +- .../service}/identity_lookup.go | 4 +- .../service}/identity_lookup_test.go | 4 +- packages/auth/internal/service/service.go | 291 ++++++++++++++++++ .../service/store.go} | 9 +- packages/auth/internal/team/error.go | 17 + .../team/middleware.go} | 5 +- .../team/middleware_test.go} | 9 +- .../team_state.go => internal/team/state.go} | 10 +- .../team/state_test.go} | 6 +- .../auth/{pkg => internal}/token/admin.go | 2 +- .../{pkg => internal}/token/admin_test.go | 2 +- .../{pkg => internal}/token/jwks/audience.go | 0 .../token/jwks/audience_test.go | 0 .../{pkg => internal}/token/jwks/config.go | 0 .../token/jwks/config_test.go | 0 .../token/jwks/testserver.go | 0 .../{pkg => internal}/token/jwks/verifier.go | 0 .../auth/{pkg => internal}/token/oidc/oidc.go | 2 +- .../{pkg => internal}/token/oidc/oidc_test.go | 2 +- .../auth/{pkg => internal}/token/provider.go | 4 +- .../token/provider_config_parse.go | 2 +- .../{pkg => internal}/token/provider_test.go | 4 +- packages/auth/pkg/auth/error.go | 21 +- packages/auth/pkg/auth/gin.go | 51 +-- packages/auth/pkg/auth/middleware.go | 259 ++-------------- packages/auth/pkg/auth/service.go | 273 +--------------- packages/auth/pkg/auth/team.go | 31 ++ packages/auth/pkg/auth/testing.go | 5 +- packages/auth/pkg/auth/token.go | 29 ++ packages/dashboard-api/internal/cfg/model.go | 20 +- .../dashboard-api/internal/cfg/model_test.go | 12 +- .../dashboard-api/internal/identity/issuer.go | 6 +- .../internal/identity/issuer_test.go | 24 +- packages/dashboard-api/main.go | 5 +- 40 files changed, 847 insertions(+), 647 deletions(-) create mode 100644 packages/auth/internal/authcontext/context.go create mode 100644 packages/auth/internal/middleware/middleware.go rename packages/auth/{pkg/auth => internal/middleware}/middleware_test.go (87%) rename packages/auth/{pkg/auth => internal/service}/cache.go (98%) rename packages/auth/{pkg/auth => internal/service}/identity_lookup.go (97%) rename packages/auth/{pkg/auth => internal/service}/identity_lookup_test.go (97%) create mode 100644 packages/auth/internal/service/service.go rename packages/auth/{pkg/auth/auth_store.go => internal/service/store.go} (90%) create mode 100644 packages/auth/internal/team/error.go rename packages/auth/{pkg/auth/team_middleware.go => internal/team/middleware.go} (93%) rename packages/auth/{pkg/auth/team_middleware_test.go => internal/team/middleware_test.go} (97%) rename packages/auth/{pkg/auth/team_state.go => internal/team/state.go} (79%) rename packages/auth/{pkg/auth/team_state_test.go => internal/team/state_test.go} (96%) rename packages/auth/{pkg => internal}/token/admin.go (96%) rename packages/auth/{pkg => internal}/token/admin_test.go (98%) rename packages/auth/{pkg => internal}/token/jwks/audience.go (100%) rename packages/auth/{pkg => internal}/token/jwks/audience_test.go (100%) rename packages/auth/{pkg => internal}/token/jwks/config.go (100%) rename packages/auth/{pkg => internal}/token/jwks/config_test.go (100%) rename packages/auth/{pkg => internal}/token/jwks/testserver.go (100%) rename packages/auth/{pkg => internal}/token/jwks/verifier.go (100%) rename packages/auth/{pkg => internal}/token/oidc/oidc.go (97%) rename packages/auth/{pkg => internal}/token/oidc/oidc_test.go (98%) rename packages/auth/{pkg => internal}/token/provider.go (96%) rename packages/auth/{pkg => internal}/token/provider_config_parse.go (88%) rename packages/auth/{pkg => internal}/token/provider_test.go (97%) create mode 100644 packages/auth/pkg/auth/team.go create mode 100644 packages/auth/pkg/auth/token.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index da3a32f8bb..06a5fcf2b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -187,9 +187,11 @@ A separate REST service (port 3010, spec `spec/openapi-dashboard.yml`) consumed dashboard, not the SDK: team management/provisioning, template tags, build listings, admin bootstrap. Its workspace-agnostic `/admin/v1` operations are defined in the same dashboard OpenAPI contract and registered on the existing router. Their `AdminJWTAuth` OpenAPI security scheme -accepts only short-lived EdDSA service JWTs verified against the workspace-api JWKS (resolved via -OIDC discovery), configured through the JSON `ADMIN_AUTH_CONFIG` value — the same config shape -as `AUTH_PROVIDER_CONFIG`. Talks to Postgres and ClickHouse; never talks to orchestrators. +accepts only short-lived service JWTs verified against the workspace-api +`/.well-known/jwks.json` endpoint, with accepted signing methods derived from each JWK's required +`alg` metadata. Issuers and audiences are configured through the JSON `ADMIN_AUTH_PROVIDER_CONFIG` value — +the same config shape as `AUTH_PROVIDER_CONFIG`. Talks to Postgres and ClickHouse; never talks to +orchestrators. ### Docker reverse proxy (`packages/docker-reverse-proxy`) diff --git a/packages/api/internal/cfg/model.go b/packages/api/internal/cfg/model.go index 2008559d54..034ad145a3 100644 --- a/packages/api/internal/cfg/model.go +++ b/packages/api/internal/cfg/model.go @@ -12,7 +12,7 @@ import ( "github.com/caarlos0/env/v11" "github.com/golang-jwt/jwt/v5" - "github.com/e2b-dev/infra/packages/auth/pkg/token" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) const ( @@ -102,7 +102,7 @@ type Config struct { VolumesToken VolumesTokenConfig - AuthProvider token.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` + AuthProvider sharedauth.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` DefaultPersistentVolumeType string `env:"DEFAULT_PERSISTENT_VOLUME_TYPE"` @@ -198,8 +198,8 @@ var ( ErrUnknownKeyType = errors.New("unknown JWT signing key type") parserFuncs = map[reflect.Type]env.ParserFunc{ - reflect.TypeFor[token.ProviderConfig](): func(v string) (any, error) { - return token.ParseProviderConfig(v) + reflect.TypeFor[sharedauth.ProviderConfig](): func(v string) (any, error) { + return sharedauth.ParseProviderConfig(v) }, reflect.TypeFor[JWTSigningKey](): func(v string) (any, error) { keyPieces := strings.SplitN(v, ":", 2) diff --git a/packages/auth/internal/authcontext/context.go b/packages/auth/internal/authcontext/context.go new file mode 100644 index 0000000000..7c31e7cf42 --- /dev/null +++ b/packages/auth/internal/authcontext/context.go @@ -0,0 +1,68 @@ +package authcontext + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/auth/pkg/types" +) + +const ( + teamContextKey = "team" + userIDContextKey = "user_id" +) + +func SetUserID(c *gin.Context, userID uuid.UUID) { + setInGinContext(c, userIDContextKey, userID) +} + +func GetUserID(c *gin.Context) (uuid.UUID, bool) { + return getFromGinContextSafely[uuid.UUID](c, userIDContextKey) +} + +func MustGetUserID(c *gin.Context) uuid.UUID { + userID, ok := GetUserID(c) + if !ok { + panic("user id not found in context") + } + + return userID +} + +func SetTeamInfo(c *gin.Context, t *types.Team) { + setInGinContext(c, teamContextKey, t) +} + +func MustGetTeamInfo(c *gin.Context) *types.Team { + team, ok := GetTeamInfo(c) + if !ok { + panic("team not found in context") + } + + return team +} + +func MustGetTeamID(c *gin.Context) uuid.UUID { + return MustGetTeamInfo(c).Team.ID +} + +func GetTeamInfo(c *gin.Context) (*types.Team, bool) { + return getFromGinContextSafely[*types.Team](c, teamContextKey) +} + +func setInGinContext(c *gin.Context, key string, value any) { + c.Set(key, value) +} + +func getFromGinContextSafely[T any](c *gin.Context, contextKey string) (T, bool) { + var t T + + val, ok := c.Get(contextKey) + if !ok { + return t, false + } + + t, ok = val.(T) + + return t, ok +} diff --git a/packages/auth/internal/middleware/middleware.go b/packages/auth/internal/middleware/middleware.go new file mode 100644 index 0000000000..a8f9e959cb --- /dev/null +++ b/packages/auth/internal/middleware/middleware.go @@ -0,0 +1,290 @@ +package middleware + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + middleware "github.com/oapi-codegen/gin-middleware" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" + internalauthteam "github.com/e2b-dev/infra/packages/auth/internal/team" + "github.com/e2b-dev/infra/packages/auth/internal/token" + "github.com/e2b-dev/infra/packages/auth/pkg/types" + "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +const ( + HeaderAPIKey = "X-API-Key" + HeaderAuthorization = "Authorization" + HeaderTeamID = "X-Team-ID" + HeaderAdminToken = "X-Admin-Token" + PrefixAPIKey = "e2b_" + PrefixAccessToken = "sk_e2b_" + PrefixBearer = "Bearer " +) + +type APIError = apierrors.APIError + +var tracer = otel.Tracer("github.com/e2b-dev/infra/packages/auth/internal/middleware") + +var ( + ErrNoAuthHeader = errors.New("authorization header is missing") + ErrInvalidAuthHeader = errors.New("authorization header is malformed") +) + +// headerKey describes how to extract an authentication token from an HTTP request header. +type headerKey struct { + name string + prefix string + removePrefix string +} + +// Authenticator is implemented by types that can authenticate requests against a security scheme. +type Authenticator interface { + Authenticate(ctx context.Context, ginCtx *gin.Context, input *openapi3filter.AuthenticationInput) error + SecuritySchemeName() string +} + +// commonAuthenticator implements Authenticator using a header-based token with a pluggable validation function. +type commonAuthenticator[T any] struct { + schemeName string + header headerKey + validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (T, *APIError) + setContextFunc func(ginCtx *gin.Context, value T) + errorMessage string +} + +// getHeaderKeysFromRequest extracts the token from the request header. +func (a *commonAuthenticator[T]) getHeaderKeysFromRequest(req *http.Request) (string, error) { + key := req.Header.Get(a.header.name) + if key == "" { + return "", ErrNoAuthHeader + } + + if a.header.removePrefix != "" { + key = strings.TrimSpace(strings.TrimPrefix(key, a.header.removePrefix)) + } + + if a.header.prefix != "" && !strings.HasPrefix(key, a.header.prefix) { + return "", ErrInvalidAuthHeader + } + + return key, nil +} + +// Authenticate validates the request against the security scheme. +func (a *commonAuthenticator[T]) Authenticate(ctx context.Context, ginCtx *gin.Context, input *openapi3filter.AuthenticationInput) error { + key, err := a.getHeaderKeysFromRequest(input.RequestValidationInput.Request) + if err != nil { + telemetry.ReportEvent(ctx, "auth scheme skipped", + attribute.String("auth.scheme", a.schemeName), + attribute.String("auth.reason", err.Error()), + ) + + // stamp 401 so the ErrorHandler's max(writer, 400) resolves to 401 + // when every security group fails. without this, auth failures become 400s. + ginCtx.Status(http.StatusUnauthorized) + + return err + } + + telemetry.ReportEvent(ctx, "api key extracted") + + result, validationError := a.validationFunc(ctx, ginCtx, key) + if validationError != nil { + telemetry.ReportError(ctx, + "validation error", + validationError.Err, + attribute.String("error.message", a.errorMessage), + attribute.Int("http.status_code", validationError.Code), + attribute.String("http.status_text", http.StatusText(validationError.Code)), + ) + + ginCtx.Status(validationError.Code) + + var forbiddenError *internalauthteam.ForbiddenError + if errors.As(validationError.Err, &forbiddenError) { + return validationError.Err + } + + return fmt.Errorf("%s\n%s", a.errorMessage, validationError.ClientMsg) + } + + telemetry.ReportEvent(ctx, "api key validated") + + if a.setContextFunc != nil { + a.setContextFunc(ginCtx, result) + } + + return nil +} + +// SecuritySchemeName returns the name of the security scheme this authenticator handles. +func (a *commonAuthenticator[T]) SecuritySchemeName() string { + return a.schemeName +} + +func adminValidationFunction(adminToken string) func(ctx context.Context, ginCtx *gin.Context, token string) (struct{}, *APIError) { + return func(_ context.Context, _ *gin.Context, token string) (struct{}, *APIError) { + if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) != 1 { + return struct{}{}, &APIError{ + Code: http.StatusUnauthorized, + Err: errors.New("invalid access token"), + ClientMsg: "Invalid Access token.", + } + } + + return struct{}{}, nil + } +} + +// NewApiKeyAuthenticator creates an authenticator for the ApiKeyAuth security scheme (X-API-Key header, e2b_ prefix). +func NewApiKeyAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (*types.Team, *APIError)) Authenticator { + return &commonAuthenticator[*types.Team]{ + schemeName: "ApiKeyAuth", + header: headerKey{ + name: HeaderAPIKey, + prefix: PrefixAPIKey, + }, + validationFunc: validationFunc, + setContextFunc: authcontext.SetTeamInfo, + errorMessage: "Invalid API key, please visit https://e2b.dev/docs/api-key for more information.", + } +} + +// NewAccessTokenAuthenticator creates an authenticator for the AccessTokenAuth security scheme (Authorization Bearer sk_e2b_). +func NewAccessTokenAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError)) Authenticator { + return &commonAuthenticator[uuid.UUID]{ + schemeName: "AccessTokenAuth", + header: headerKey{ + name: HeaderAuthorization, + prefix: PrefixAccessToken, + removePrefix: PrefixBearer, + }, + validationFunc: validationFunc, + setContextFunc: authcontext.SetUserID, + errorMessage: "Invalid Access token, try to login again by running `e2b auth login`.", + } +} + +// NewAuthProviderBearerAuthenticator creates an authenticator for AuthProviderBearerAuth (Authorization Bearer token). +func NewAuthProviderBearerAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError)) Authenticator { + return &commonAuthenticator[uuid.UUID]{ + schemeName: "AuthProviderBearerAuth", + header: headerKey{ + name: HeaderAuthorization, + removePrefix: PrefixBearer, + }, + validationFunc: validationFunc, + setContextFunc: authcontext.SetUserID, + errorMessage: "Invalid auth provider token.", + } +} + +// NewAuthProviderTeamAuthenticator creates an authenticator for the AuthProviderTeamAuth security scheme (X-Team-Id header). +func NewAuthProviderTeamAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (*types.Team, *APIError)) Authenticator { + return &commonAuthenticator[*types.Team]{ + schemeName: "AuthProviderTeamAuth", + header: headerKey{ + name: HeaderTeamID, + }, + validationFunc: validationFunc, + setContextFunc: authcontext.SetTeamInfo, + errorMessage: "Invalid auth provider token teamID.", + } +} + +// NewAdminJWTAuthenticator creates an authenticator for the AdminJWTAuth security scheme. +func NewAdminJWTAuthenticator(verifier *token.AdminVerifier) Authenticator { + return &commonAuthenticator[struct{}]{ + schemeName: "AdminJWTAuth", + header: headerKey{ + name: HeaderAuthorization, + removePrefix: PrefixBearer, + }, + validationFunc: func(ctx context.Context, _ *gin.Context, token string) (struct{}, *APIError) { + if _, err := verifier.Verify(ctx, token); err != nil { + return struct{}{}, &APIError{ + Code: http.StatusUnauthorized, + Err: err, + ClientMsg: "Invalid service token.", + } + } + + return struct{}{}, nil + }, + errorMessage: "Invalid service token.", + } +} + +// NewAdminApiKeyAuthenticator creates an authenticator for the AdminApiKeyAuth security scheme (X-Admin-Token header). +func NewAdminApiKeyAuthenticator(adminToken string) Authenticator { + return newAdminApiKeyAuthenticator("AdminApiKeyAuth", adminToken) +} + +func newAdminApiKeyAuthenticator(schemeName, adminToken string) Authenticator { + return &commonAuthenticator[struct{}]{ + schemeName: schemeName, + header: headerKey{ + name: HeaderAdminToken, + }, + validationFunc: adminValidationFunction(adminToken), + errorMessage: "Invalid Access token.", + } +} + +// NewAdminTeamAuthenticator creates an authenticator for AdminTeamAuth (X-Team-ID header). +func NewAdminTeamAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError)) Authenticator { + return newAdminTeamAuthenticator("AdminTeamAuth", validationFunc) +} + +func newAdminTeamAuthenticator( + schemeName string, + validationFunc func(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError), +) Authenticator { + return &commonAuthenticator[*types.Team]{ + schemeName: schemeName, + header: headerKey{ + name: HeaderTeamID, + }, + validationFunc: validationFunc, + setContextFunc: authcontext.SetTeamInfo, + errorMessage: "Invalid admin token teamID.", + } +} + +// CreateAuthenticationFunc creates an OpenAPI authentication function from a list of authenticators. +func CreateAuthenticationFunc( + authenticators []Authenticator, + preAuthHook func(*gin.Context), +) openapi3filter.AuthenticationFunc { + return func(ctx context.Context, input *openapi3filter.AuthenticationInput) error { + ginCtx := middleware.GetGinContext(ctx) + + if preAuthHook != nil { + preAuthHook(ginCtx) + } + + ctx, span := tracer.Start(ginCtx.Request.Context(), "authenticate") + defer span.End() + + for _, validator := range authenticators { + if input.SecuritySchemeName == validator.SecuritySchemeName() { + //nolint:contextcheck // We use the gin request context here by design. + return validator.Authenticate(ctx, ginCtx, input) + } + } + + return fmt.Errorf("invalid security scheme name '%s'", input.SecuritySchemeName) + } +} diff --git a/packages/auth/pkg/auth/middleware_test.go b/packages/auth/internal/middleware/middleware_test.go similarity index 87% rename from packages/auth/pkg/auth/middleware_test.go rename to packages/auth/internal/middleware/middleware_test.go index 9c49d391b9..144bd14f8b 100644 --- a/packages/auth/pkg/auth/middleware_test.go +++ b/packages/auth/internal/middleware/middleware_test.go @@ -1,4 +1,4 @@ -package auth +package middleware import ( "context" @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" "github.com/e2b-dev/infra/packages/auth/pkg/types" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" ) @@ -69,12 +70,12 @@ func TestAdminTeamAuthenticatorSetsTeamContext(t *testing.T) { t.Fatalf("AdminTeamAuth.Authenticate(valid team ID) error: %v", err) } - got, ok := GetTeamInfo(ginCtx) + got, ok := authcontext.GetTeamInfo(ginCtx) if !ok { - t.Fatalf("GetTeamInfo(ginCtx) ok = false, want true") + t.Fatalf("authcontext.GetTeamInfo(ginCtx) ok = false, want true") } if got.Team.ID != teamID { - t.Errorf("GetTeamInfo(ginCtx).Team.ID = %s, want %s", got.Team.ID, teamID) + t.Errorf("authcontext.GetTeamInfo(ginCtx).Team.ID = %s, want %s", got.Team.ID, teamID) } } diff --git a/packages/auth/pkg/auth/cache.go b/packages/auth/internal/service/cache.go similarity index 98% rename from packages/auth/pkg/auth/cache.go rename to packages/auth/internal/service/cache.go index ef2a37c34b..124562afca 100644 --- a/packages/auth/pkg/auth/cache.go +++ b/packages/auth/internal/service/cache.go @@ -1,4 +1,4 @@ -package auth +package service import ( "context" diff --git a/packages/auth/pkg/auth/identity_lookup.go b/packages/auth/internal/service/identity_lookup.go similarity index 97% rename from packages/auth/pkg/auth/identity_lookup.go rename to packages/auth/internal/service/identity_lookup.go index 33014b77f0..f65714c8ce 100644 --- a/packages/auth/pkg/auth/identity_lookup.go +++ b/packages/auth/internal/service/identity_lookup.go @@ -1,4 +1,4 @@ -package auth +package service import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" + "github.com/e2b-dev/infra/packages/auth/internal/token/oidc" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/shared/pkg/cache" diff --git a/packages/auth/pkg/auth/identity_lookup_test.go b/packages/auth/internal/service/identity_lookup_test.go similarity index 97% rename from packages/auth/pkg/auth/identity_lookup_test.go rename to packages/auth/internal/service/identity_lookup_test.go index 395a5db6c7..080a13b0dd 100644 --- a/packages/auth/pkg/auth/identity_lookup_test.go +++ b/packages/auth/internal/service/identity_lookup_test.go @@ -1,4 +1,4 @@ -package auth +package service import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" + "github.com/e2b-dev/infra/packages/auth/internal/token/oidc" ) type countingIdentityLookup struct { diff --git a/packages/auth/internal/service/service.go b/packages/auth/internal/service/service.go new file mode 100644 index 0000000000..9d1522b51a --- /dev/null +++ b/packages/auth/internal/service/service.go @@ -0,0 +1,291 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" + internalauthteam "github.com/e2b-dev/infra/packages/auth/internal/team" + "github.com/e2b-dev/infra/packages/auth/internal/token" + "github.com/e2b-dev/infra/packages/auth/pkg/types" + authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" + "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" + "github.com/e2b-dev/infra/packages/shared/pkg/keys" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +// authStore abstracts the DB operations needed for auth validation. +type authStore interface { + GetTeamByHashedAPIKey(ctx context.Context, hashedKey string) (*types.Team, error) + GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) + GetTeamByIDAndUserID(ctx context.Context, userID uuid.UUID, teamID string) (*types.Team, error) + GetUserIDByHashedAccessToken(ctx context.Context, hashedToken string) (uuid.UUID, error) + GetTeamAPIKeyHashes(ctx context.Context, teamID uuid.UUID) ([]string, error) +} + +type APIError = apierrors.APIError + +// Service is the interface implemented by the internal AuthService. It +// exposes the auth validation, team lookup, and cache invalidation operations +// used by callers such as APIStore and the dashboard-api handlers. +type Service interface { + ValidateAPIKey(ctx context.Context, ginCtx *gin.Context, apiKey string) (*types.Team, *APIError) + ValidateAccessToken(ctx context.Context, ginCtx *gin.Context, accessToken string) (uuid.UUID, *APIError) + ValidateAuthProviderToken(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError) + ValidateAuthProviderTeam(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError) + GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) + InvalidateTeamMemberCache(ctx context.Context, userID uuid.UUID, teamID string) + InvalidateTeamCache(ctx context.Context, teamID uuid.UUID) error + Close(ctx context.Context) error +} + +// AuthService encapsulates the cache, store, and JWT verifier for auth validation. +type AuthService struct { + store authStore + teamCache *authCache + authProviderVerifier *token.ProviderVerifier +} + +// Compile-time assertion that *AuthService satisfies the Service interface. +var _ Service = (*AuthService)(nil) + +// NewAuthService wires up the team cache, auth store, identity lookup, and JWT +// verifier from the supplied dependencies. The HTTP client is used for OIDC +// discovery and JWKS fetches. +func NewAuthService( + ctx context.Context, + redisClient redis.UniversalClient, + authDB *authdb.Client, + providerConfig token.ProviderConfig, + httpClient *http.Client, +) (*AuthService, error) { + if redisClient == nil { + return nil, errors.New("redisClient is required") + } + if authDB == nil { + return nil, errors.New("authDB is required") + } + if httpClient == nil { + return nil, errors.New("httpClient is required") + } + + cache := newAuthCache(redisClient) + store := newAuthStore(authDB) + // OIDC bootstrap writes identity rows on the primary immediately before the + // next authenticated request; using the read replica here races replication lag. + identityLookup := newAuthIdentityLookup(authDB.Write) + v, err := token.NewProviderVerifier(ctx, providerConfig, httpClient, identityLookup) + if err != nil { + return nil, fmt.Errorf("initializing auth provider JWT verifier: %w", err) + } + + return &AuthService{ + store: store, + teamCache: cache, + authProviderVerifier: v, + }, nil +} + +// ValidateAPIKey verifies the API key format and fetches the associated team via cache + store. +func (s *AuthService) ValidateAPIKey(ctx context.Context, ginCtx *gin.Context, apiKey string) (*types.Team, *APIError) { + hashedKey, err := keys.VerifyKey(keys.ApiKeyPrefix, apiKey) + if err != nil { + return nil, &APIError{ + Err: fmt.Errorf("failed to verify api key: %w", err), + ClientMsg: "Invalid API key format", + Code: http.StatusUnauthorized, + } + } + + result, err := s.teamCache.GetOrSet(ctx, hashedKey, func(ctx context.Context, key string) (*types.Team, error) { + return s.store.GetTeamByHashedAPIKey(ctx, key) + }) + if err != nil { + var forbiddenErr *internalauthteam.ForbiddenError + if errors.As(err, &forbiddenErr) { + return nil, &APIError{ + Err: err, + ClientMsg: err.Error(), + Code: http.StatusForbidden, + } + } + + return nil, &APIError{ + Err: fmt.Errorf("failed to get the team from db for an api key: %w", err), + ClientMsg: "Cannot get the team for the given API key", + Code: http.StatusUnauthorized, + } + } + + //nolint:contextcheck // We use the gin request context to set attributes on the parent span. + telemetry.SetAttributes(ginCtx.Request.Context(), + telemetry.WithMaskedAPIKey(keys.MaskToken(keys.ApiKeyPrefix, apiKey)), + telemetry.WithTeamID(result.TeamID()), + ) + + return result, nil +} + +// GetTeamByID fetches team auth data via cache + store. +func (s *AuthService) GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) { + return s.teamCache.GetOrSet(ctx, teamCacheKey(teamID), func(ctx context.Context, _ string) (*types.Team, error) { + return s.store.GetTeamByID(ctx, teamID) + }) +} + +// ValidateAccessToken verifies the access token format and fetches the associated user ID. +func (s *AuthService) ValidateAccessToken(ctx context.Context, ginCtx *gin.Context, accessToken string) (uuid.UUID, *APIError) { + hashedToken, err := keys.VerifyKey(keys.AccessTokenPrefix, accessToken) + if err != nil { + return uuid.UUID{}, &APIError{ + Err: fmt.Errorf("failed to verify access token: %w", err), + ClientMsg: "Invalid access token format", + Code: http.StatusUnauthorized, + } + } + + userID, err := s.store.GetUserIDByHashedAccessToken(ctx, hashedToken) + if err != nil { + return uuid.UUID{}, &APIError{ + Err: fmt.Errorf("failed to get the user from db for an access token: %w", err), + ClientMsg: "Cannot get the user for the given access token", + Code: http.StatusUnauthorized, + } + } + + //nolint:contextcheck // We use the gin request context to set attributes on the parent span. + telemetry.SetAttributes(ginCtx.Request.Context(), + telemetry.WithMaskedAccessToken(keys.MaskToken(keys.AccessTokenPrefix, accessToken)), + telemetry.WithUserID(userID.String()), + ) + + return userID, nil +} + +// ValidateAuthProviderToken verifies a JWT against the configured auth provider and resolves an internal user ID. +// +// When no auth provider verifier is configured (AUTH_PROVIDER_CONFIG is unset), +// every token is denied with 401. This makes "no auth provider" a valid +// configuration: API key / access token flows keep working, but JWT-based +// flows are universally rejected. +func (s *AuthService) ValidateAuthProviderToken(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError) { + if s.authProviderVerifier == nil { + return uuid.UUID{}, &APIError{ + Err: errors.New("auth provider is not configured"), + ClientMsg: "Backend authentication failed", + Code: http.StatusUnauthorized, + } + } + + return s.validateJWTWithProvider(ctx, ginCtx, s.authProviderVerifier, token, "auth provider") +} + +func (s *AuthService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *token.ProviderVerifier, token string, tokenSource string) (uuid.UUID, *APIError) { + userID, _, err := v.Verify(ctx, token) + if err != nil { + return uuid.UUID{}, &APIError{ + Err: err, + ClientMsg: "Backend authentication failed", + Code: http.StatusUnauthorized, + } + } + + if userID == uuid.Nil { + return uuid.UUID{}, &APIError{ + Err: fmt.Errorf("%s token user claim is missing or is not an internal UUID", tokenSource), + ClientMsg: "Backend authentication failed", + Code: http.StatusUnauthorized, + } + } + + //nolint:contextcheck // We use the gin request context to set attributes on the parent span. + telemetry.SetAttributes(ginCtx.Request.Context(), + telemetry.WithUserID(userID.String()), + ) + + return userID, nil +} + +// ValidateAuthProviderTeam extracts the user ID from the gin context and fetches the team via cache + store. +func (s *AuthService) ValidateAuthProviderTeam(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError) { + userID, ok := authcontext.GetUserID(ginCtx) + if !ok { + return nil, &APIError{ + Err: errors.New("user ID has invalid type"), + ClientMsg: "Backend authentication failed", + Code: http.StatusInternalServerError, + } + } + + cacheKey := teamMemberCacheKey(userID, teamID) + + result, err := s.teamCache.GetOrSet(ctx, cacheKey, func(ctx context.Context, _ string) (*types.Team, error) { + return s.store.GetTeamByIDAndUserID(ctx, userID, teamID) + }) + if err != nil { + var forbiddenErr *internalauthteam.ForbiddenError + if errors.As(err, &forbiddenErr) { + return nil, &APIError{ + Err: fmt.Errorf("failed getting team: %w", err), + ClientMsg: fmt.Sprintf("Forbidden: %s", err.Error()), + Code: http.StatusForbidden, + } + } + + return nil, &APIError{ + Err: fmt.Errorf("failed getting team: %w", err), + ClientMsg: "Backend authentication failed", + Code: http.StatusUnauthorized, + } + } + + //nolint:contextcheck // We use the gin request context to set attributes on the parent span. + telemetry.SetAttributes(ginCtx.Request.Context(), + telemetry.WithUserID(userID.String()), + telemetry.WithTeamID(result.TeamID()), + ) + + return result, nil +} + +// InvalidateTeamMemberCache removes the cached auth entry for a specific user-team pair. +// This should be called when team membership changes (member added or removed). +func (s *AuthService) InvalidateTeamMemberCache(ctx context.Context, userID uuid.UUID, teamID string) { + s.teamCache.Invalidate(ctx, teamMemberCacheKey(userID, teamID)) +} + +// InvalidateTeamCache queries the team's API key hashes and removes their cached entries. +func (s *AuthService) InvalidateTeamCache(ctx context.Context, teamID uuid.UUID) error { + s.teamCache.Invalidate(ctx, teamCacheKey(teamID)) + + hashes, err := s.store.GetTeamAPIKeyHashes(ctx, teamID) + if err != nil { + return fmt.Errorf("failed to get team API key hashes: %w", err) + } + + for _, hash := range hashes { + s.teamCache.Invalidate(ctx, hash) + } + + return nil +} + +func teamMemberCacheKey(userID uuid.UUID, teamID string) string { + return fmt.Sprintf("%s-%s", userID.String(), strings.ToLower(teamID)) +} + +func teamCacheKey(teamID uuid.UUID) string { + return fmt.Sprintf("team-%s", teamID.String()) +} + +// Close stops the underlying cache's background refresh goroutines. +func (s *AuthService) Close(ctx context.Context) error { + return s.teamCache.Close(ctx) +} diff --git a/packages/auth/pkg/auth/auth_store.go b/packages/auth/internal/service/store.go similarity index 90% rename from packages/auth/pkg/auth/auth_store.go rename to packages/auth/internal/service/store.go index af31b11a49..34548a5422 100644 --- a/packages/auth/pkg/auth/auth_store.go +++ b/packages/auth/internal/service/store.go @@ -1,4 +1,4 @@ -package auth +package service import ( "context" @@ -8,6 +8,7 @@ import ( "go.opentelemetry.io/otel" "go.uber.org/zap" + internalauthteam "github.com/e2b-dev/infra/packages/auth/internal/team" "github.com/e2b-dev/infra/packages/auth/pkg/types" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" @@ -35,7 +36,7 @@ func (s *authStoreImpl) GetTeamByHashedAPIKey(ctx context.Context, hashedKey str return nil, fmt.Errorf("failed to get team from API key: %w", err) } - if err := CheckTeamBanned(result.Team); err != nil { + if err := internalauthteam.CheckTeamBanned(result.Team); err != nil { return nil, err } @@ -62,7 +63,7 @@ func (s *authStoreImpl) GetTeamByID(ctx context.Context, teamID uuid.UUID) (*typ return nil, fmt.Errorf("failed to get team from team ID: %w", err) } - if err := CheckTeamBanned(result.Team); err != nil { + if err := internalauthteam.CheckTeamBanned(result.Team); err != nil { return nil, err } @@ -88,7 +89,7 @@ func (s *authStoreImpl) GetTeamByIDAndUserID(ctx context.Context, userID uuid.UU return nil, fmt.Errorf("failed to get team from teamID and userID key: %w", err) } - if err := CheckTeamBanned(result.Team); err != nil { + if err := internalauthteam.CheckTeamBanned(result.Team); err != nil { return nil, err } diff --git a/packages/auth/internal/team/error.go b/packages/auth/internal/team/error.go new file mode 100644 index 0000000000..f99530c315 --- /dev/null +++ b/packages/auth/internal/team/error.go @@ -0,0 +1,17 @@ +package team + +type ForbiddenError struct { + Message string +} + +func (e *ForbiddenError) Error() string { + return e.Message +} + +type BlockedError struct { + Message string +} + +func (e *BlockedError) Error() string { + return e.Message +} diff --git a/packages/auth/pkg/auth/team_middleware.go b/packages/auth/internal/team/middleware.go similarity index 93% rename from packages/auth/pkg/auth/team_middleware.go rename to packages/auth/internal/team/middleware.go index c90e2f5288..1b31ea2828 100644 --- a/packages/auth/pkg/auth/team_middleware.go +++ b/packages/auth/internal/team/middleware.go @@ -1,10 +1,11 @@ -package auth +package team import ( "net/http" "github.com/gin-gonic/gin" + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" "github.com/e2b-dev/infra/packages/auth/pkg/types" "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" ) @@ -58,7 +59,7 @@ func CheckTeamAccess(c *gin.Context, team *types.Team, allowlist BlockedTeamAllo // with 403 unless the matched route is in allowlist. Must run after auth. func EnforceBlockedTeam(allowlist BlockedTeamAllowlist) gin.HandlerFunc { return func(c *gin.Context) { - team, ok := GetTeamInfo(c) + team, ok := authcontext.GetTeamInfo(c) if !ok || team == nil { c.Next() diff --git a/packages/auth/pkg/auth/team_middleware_test.go b/packages/auth/internal/team/middleware_test.go similarity index 97% rename from packages/auth/pkg/auth/team_middleware_test.go rename to packages/auth/internal/team/middleware_test.go index 39a6a27bd5..ed3c18f918 100644 --- a/packages/auth/pkg/auth/team_middleware_test.go +++ b/packages/auth/internal/team/middleware_test.go @@ -1,4 +1,4 @@ -package auth +package team import ( "net/http" @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" "github.com/e2b-dev/infra/packages/auth/pkg/types" authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" ) @@ -133,13 +134,13 @@ func TestCheckTeamAccess(t *testing.T) { wantForbidden := func(t *testing.T, err error) { t.Helper() - var target *TeamForbiddenError + var target *ForbiddenError require.ErrorAs(t, err, &target) } wantBlocked := func(t *testing.T, err error) { t.Helper() - var target *TeamBlockedError + var target *BlockedError require.ErrorAs(t, err, &target) } @@ -249,7 +250,7 @@ func runEnforceBlockedTeam( r := gin.New() r.Handle(method, fullPath, func(c *gin.Context) { if team != nil { - SetTeamInfoForTest(t, c, team) + authcontext.SetTeamInfo(c, team) } EnforceBlockedTeam(allowlist)(c) if !c.IsAborted() { diff --git a/packages/auth/pkg/auth/team_state.go b/packages/auth/internal/team/state.go similarity index 79% rename from packages/auth/pkg/auth/team_state.go rename to packages/auth/internal/team/state.go index a30099ddb5..3984c67501 100644 --- a/packages/auth/pkg/auth/team_state.go +++ b/packages/auth/internal/team/state.go @@ -1,4 +1,4 @@ -package auth +package team import ( "fmt" @@ -7,18 +7,18 @@ import ( authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" ) -// CheckTeamBanned returns *TeamForbiddenError if the team is banned. +// CheckTeamBanned returns *ForbiddenError if the team is banned. // Called inside the shared auth store so every service rejects banned teams // at auth time without per-handler plumbing. func CheckTeamBanned(team authqueries.Team) error { if team.IsBanned { - return &TeamForbiddenError{Message: "team is banned"} + return &ForbiddenError{Message: "team is banned"} } return nil } -// CheckTeamBlocked returns *TeamBlockedError if the team is blocked. +// CheckTeamBlocked returns *BlockedError if the team is blocked. // Called inline at any handler that creates or mutates a billable resource. // Each service decides for itself which endpoints need it. // @@ -35,5 +35,5 @@ func CheckTeamBlocked(team *types.Team) error { msg = fmt.Sprintf("%s: %s", msg, *team.BlockedReason) } - return &TeamBlockedError{Message: msg} + return &BlockedError{Message: msg} } diff --git a/packages/auth/pkg/auth/team_state_test.go b/packages/auth/internal/team/state_test.go similarity index 96% rename from packages/auth/pkg/auth/team_state_test.go rename to packages/auth/internal/team/state_test.go index 1ca4da9efd..af53b90c0d 100644 --- a/packages/auth/pkg/auth/team_state_test.go +++ b/packages/auth/internal/team/state_test.go @@ -1,4 +1,4 @@ -package auth +package team import ( "testing" @@ -41,7 +41,7 @@ func TestCheckTeamBanned(t *testing.T) { return } - var forbidden *TeamForbiddenError + var forbidden *ForbiddenError assert.ErrorAs(t, err, &forbidden) }) } @@ -105,7 +105,7 @@ func TestCheckTeamBlocked(t *testing.T) { return } - var blocked *TeamBlockedError + var blocked *BlockedError require.ErrorAs(t, err, &blocked) assert.Contains(t, err.Error(), tc.wantMsgHas) }) diff --git a/packages/auth/pkg/token/admin.go b/packages/auth/internal/token/admin.go similarity index 96% rename from packages/auth/pkg/token/admin.go rename to packages/auth/internal/token/admin.go index 43a172be69..04257aa13e 100644 --- a/packages/auth/pkg/token/admin.go +++ b/packages/auth/internal/token/admin.go @@ -9,7 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" ) // adminJWTClockSkew is the leeway applied to time-based claims of admin diff --git a/packages/auth/pkg/token/admin_test.go b/packages/auth/internal/token/admin_test.go similarity index 98% rename from packages/auth/pkg/token/admin_test.go rename to packages/auth/internal/token/admin_test.go index 4cd7892400..5de89b94ed 100644 --- a/packages/auth/pkg/token/admin_test.go +++ b/packages/auth/internal/token/admin_test.go @@ -12,7 +12,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" ) const ( diff --git a/packages/auth/pkg/token/jwks/audience.go b/packages/auth/internal/token/jwks/audience.go similarity index 100% rename from packages/auth/pkg/token/jwks/audience.go rename to packages/auth/internal/token/jwks/audience.go diff --git a/packages/auth/pkg/token/jwks/audience_test.go b/packages/auth/internal/token/jwks/audience_test.go similarity index 100% rename from packages/auth/pkg/token/jwks/audience_test.go rename to packages/auth/internal/token/jwks/audience_test.go diff --git a/packages/auth/pkg/token/jwks/config.go b/packages/auth/internal/token/jwks/config.go similarity index 100% rename from packages/auth/pkg/token/jwks/config.go rename to packages/auth/internal/token/jwks/config.go diff --git a/packages/auth/pkg/token/jwks/config_test.go b/packages/auth/internal/token/jwks/config_test.go similarity index 100% rename from packages/auth/pkg/token/jwks/config_test.go rename to packages/auth/internal/token/jwks/config_test.go diff --git a/packages/auth/pkg/token/jwks/testserver.go b/packages/auth/internal/token/jwks/testserver.go similarity index 100% rename from packages/auth/pkg/token/jwks/testserver.go rename to packages/auth/internal/token/jwks/testserver.go diff --git a/packages/auth/pkg/token/jwks/verifier.go b/packages/auth/internal/token/jwks/verifier.go similarity index 100% rename from packages/auth/pkg/token/jwks/verifier.go rename to packages/auth/internal/token/jwks/verifier.go diff --git a/packages/auth/pkg/token/oidc/oidc.go b/packages/auth/internal/token/oidc/oidc.go similarity index 97% rename from packages/auth/pkg/token/oidc/oidc.go rename to packages/auth/internal/token/oidc/oidc.go index 0124a4ee84..0680ab574d 100644 --- a/packages/auth/pkg/token/oidc/oidc.go +++ b/packages/auth/internal/token/oidc/oidc.go @@ -9,7 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" ) // ErrIdentityNotFound is returned by Verify when the token is valid but no diff --git a/packages/auth/pkg/token/oidc/oidc_test.go b/packages/auth/internal/token/oidc/oidc_test.go similarity index 98% rename from packages/auth/pkg/token/oidc/oidc_test.go rename to packages/auth/internal/token/oidc/oidc_test.go index c0b2037e54..23afa28755 100644 --- a/packages/auth/pkg/token/oidc/oidc_test.go +++ b/packages/auth/internal/token/oidc/oidc_test.go @@ -13,7 +13,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" ) const testIssuerURL = "https://issuer.example.com" diff --git a/packages/auth/pkg/token/provider.go b/packages/auth/internal/token/provider.go similarity index 96% rename from packages/auth/pkg/token/provider.go rename to packages/auth/internal/token/provider.go index b1909a4eb2..e3d5873fd4 100644 --- a/packages/auth/pkg/token/provider.go +++ b/packages/auth/internal/token/provider.go @@ -9,8 +9,8 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" - "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/oidc" ) // ProviderConfig describes external auth provider verification. diff --git a/packages/auth/pkg/token/provider_config_parse.go b/packages/auth/internal/token/provider_config_parse.go similarity index 88% rename from packages/auth/pkg/token/provider_config_parse.go rename to packages/auth/internal/token/provider_config_parse.go index 1d8aae1d35..c326026bbe 100644 --- a/packages/auth/pkg/token/provider_config_parse.go +++ b/packages/auth/internal/token/provider_config_parse.go @@ -7,7 +7,7 @@ import ( ) // ParseProviderConfig parses a provider-config env value (AUTH_PROVIDER_CONFIG, -// ADMIN_AUTH_CONFIG) into a ProviderConfig. Empty input and the literal string +// ADMIN_AUTH_PROVIDER_CONFIG) into a ProviderConfig. Empty input and the literal string // "null" (with surrounding whitespace) both produce a zero-value ProviderConfig // with no error, so that Terraform `jsonencode(null)` values and unset env vars // behave the same. diff --git a/packages/auth/pkg/token/provider_test.go b/packages/auth/internal/token/provider_test.go similarity index 97% rename from packages/auth/pkg/token/provider_test.go rename to packages/auth/internal/token/provider_test.go index ef0720f7e9..3447a0e48a 100644 --- a/packages/auth/pkg/token/provider_test.go +++ b/packages/auth/internal/token/provider_test.go @@ -16,8 +16,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" - "github.com/e2b-dev/infra/packages/auth/pkg/token/oidc" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" + "github.com/e2b-dev/infra/packages/auth/internal/token/oidc" ) const testIssuerURL = "https://issuer.example.com" diff --git a/packages/auth/pkg/auth/error.go b/packages/auth/pkg/auth/error.go index 2206aadd88..b656a19040 100644 --- a/packages/auth/pkg/auth/error.go +++ b/packages/auth/pkg/auth/error.go @@ -1,24 +1,15 @@ package auth -import "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" +import ( + internalauthteam "github.com/e2b-dev/infra/packages/auth/internal/team" + "github.com/e2b-dev/infra/packages/shared/pkg/apierrors" +) // APIError is re-exported from apierrors so that auth internals can use it unqualified. type APIError = apierrors.APIError // TeamForbiddenError is returned when a team's access is forbidden (e.g. banned). -type TeamForbiddenError struct { - Message string -} - -func (e *TeamForbiddenError) Error() string { - return e.Message -} +type TeamForbiddenError = internalauthteam.ForbiddenError // TeamBlockedError is returned when a team is blocked. -type TeamBlockedError struct { - Message string -} - -func (e *TeamBlockedError) Error() string { - return e.Message -} +type TeamBlockedError = internalauthteam.BlockedError diff --git a/packages/auth/pkg/auth/gin.go b/packages/auth/pkg/auth/gin.go index 05b60afefe..d3cf6a0d58 100644 --- a/packages/auth/pkg/auth/gin.go +++ b/packages/auth/pkg/auth/gin.go @@ -4,65 +4,26 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" "github.com/e2b-dev/infra/packages/auth/pkg/types" ) -const ( - teamContextKey = "team" - userIDContextKey = "user_id" -) - -func setUserID(c *gin.Context, userID uuid.UUID) { - setInGinContext(c, userIDContextKey, userID) -} - func GetUserID(c *gin.Context) (uuid.UUID, bool) { - return getFromGinContextSafely[uuid.UUID](c, userIDContextKey) + return authcontext.GetUserID(c) } func MustGetUserID(c *gin.Context) uuid.UUID { - userID, ok := GetUserID(c) - if !ok { - panic("user id not found in context") - } - - return userID -} - -func setTeamInfo(c *gin.Context, t *types.Team) { - setInGinContext(c, teamContextKey, t) + return authcontext.MustGetUserID(c) } func MustGetTeamInfo(c *gin.Context) *types.Team { - team, ok := GetTeamInfo(c) - if !ok { - panic("team not found in context") - } - - return team + return authcontext.MustGetTeamInfo(c) } func MustGetTeamID(c *gin.Context) uuid.UUID { - return MustGetTeamInfo(c).Team.ID + return authcontext.MustGetTeamID(c) } func GetTeamInfo(c *gin.Context) (*types.Team, bool) { - return getFromGinContextSafely[*types.Team](c, teamContextKey) -} - -func setInGinContext(c *gin.Context, key string, value any) { - c.Set(key, value) -} - -func getFromGinContextSafely[T any](c *gin.Context, contextKey string) (T, bool) { - var t T - - val, ok := c.Get(contextKey) - if !ok { - return t, false - } - - t, ok = val.(T) - - return t, ok + return authcontext.GetTeamInfo(c) } diff --git a/packages/auth/pkg/auth/middleware.go b/packages/auth/pkg/auth/middleware.go index 45c17ba4cc..1ff351a742 100644 --- a/packages/auth/pkg/auth/middleware.go +++ b/packages/auth/pkg/auth/middleware.go @@ -2,271 +2,50 @@ package auth import ( "context" - "crypto/subtle" - "errors" - "fmt" - "net/http" - "strings" "github.com/getkin/kin-openapi/openapi3filter" "github.com/gin-gonic/gin" "github.com/google/uuid" - middleware "github.com/oapi-codegen/gin-middleware" - "go.opentelemetry.io/otel/attribute" - "github.com/e2b-dev/infra/packages/auth/pkg/token" + internalauthmiddleware "github.com/e2b-dev/infra/packages/auth/internal/middleware" "github.com/e2b-dev/infra/packages/auth/pkg/types" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) var ( - ErrNoAuthHeader = errors.New("authorization header is missing") - ErrInvalidAuthHeader = errors.New("authorization header is malformed") + ErrNoAuthHeader = internalauthmiddleware.ErrNoAuthHeader + ErrInvalidAuthHeader = internalauthmiddleware.ErrInvalidAuthHeader ) -// headerKey describes how to extract an authentication token from an HTTP request header. -type headerKey struct { - name string - prefix string - removePrefix string -} - -// Authenticator is implemented by types that can authenticate requests against a security scheme. -type Authenticator interface { - Authenticate(ctx context.Context, ginCtx *gin.Context, input *openapi3filter.AuthenticationInput) error - SecuritySchemeName() string -} - -// commonAuthenticator implements Authenticator using a header-based token with a pluggable validation function. -type commonAuthenticator[T any] struct { - schemeName string - header headerKey - validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (T, *APIError) - setContextFunc func(ginCtx *gin.Context, value T) - errorMessage string -} - -// getHeaderKeysFromRequest extracts the token from the request header. -func (a *commonAuthenticator[T]) getHeaderKeysFromRequest(req *http.Request) (string, error) { - key := req.Header.Get(a.header.name) - if key == "" { - return "", ErrNoAuthHeader - } - - if a.header.removePrefix != "" { - key = strings.TrimSpace(strings.TrimPrefix(key, a.header.removePrefix)) - } - - if a.header.prefix != "" && !strings.HasPrefix(key, a.header.prefix) { - return "", ErrInvalidAuthHeader - } - - return key, nil -} - -// Authenticate validates the request against the security scheme. -func (a *commonAuthenticator[T]) Authenticate(ctx context.Context, ginCtx *gin.Context, input *openapi3filter.AuthenticationInput) error { - key, err := a.getHeaderKeysFromRequest(input.RequestValidationInput.Request) - if err != nil { - telemetry.ReportEvent(ctx, "auth scheme skipped", - attribute.String("auth.scheme", a.schemeName), - attribute.String("auth.reason", err.Error()), - ) - - // stamp 401 so the ErrorHandler's max(writer, 400) resolves to 401 - // when every security group fails. without this, auth failures become 400s. - ginCtx.Status(http.StatusUnauthorized) - - return err - } - - telemetry.ReportEvent(ctx, "api key extracted") - - result, validationError := a.validationFunc(ctx, ginCtx, key) - if validationError != nil { - telemetry.ReportError(ctx, - "validation error", - validationError.Err, - attribute.String("error.message", a.errorMessage), - attribute.Int("http.status_code", validationError.Code), - attribute.String("http.status_text", http.StatusText(validationError.Code)), - ) - - ginCtx.Status(validationError.Code) - - var forbiddenError *TeamForbiddenError - if errors.As(validationError.Err, &forbiddenError) { - return validationError.Err - } - - return fmt.Errorf("%s\n%s", a.errorMessage, validationError.ClientMsg) - } +type Authenticator = internalauthmiddleware.Authenticator - telemetry.ReportEvent(ctx, "api key validated") - - if a.setContextFunc != nil { - a.setContextFunc(ginCtx, result) - } - - return nil -} - -// SecuritySchemeName returns the name of the security scheme this authenticator handles. -func (a *commonAuthenticator[T]) SecuritySchemeName() string { - return a.schemeName -} - -func adminValidationFunction(adminToken string) func(ctx context.Context, ginCtx *gin.Context, token string) (struct{}, *APIError) { - return func(_ context.Context, _ *gin.Context, token string) (struct{}, *APIError) { - if subtle.ConstantTimeCompare([]byte(token), []byte(adminToken)) != 1 { - return struct{}{}, &APIError{ - Code: http.StatusUnauthorized, - Err: errors.New("invalid access token"), - ClientMsg: "Invalid Access token.", - } - } - - return struct{}{}, nil - } -} - -// NewApiKeyAuthenticator creates an authenticator for the ApiKeyAuth security scheme (X-API-Key header, e2b_ prefix). -func NewApiKeyAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (*types.Team, *APIError)) Authenticator { - return &commonAuthenticator[*types.Team]{ - schemeName: "ApiKeyAuth", - header: headerKey{ - name: HeaderAPIKey, - prefix: PrefixAPIKey, - }, - validationFunc: validationFunc, - setContextFunc: setTeamInfo, - errorMessage: "Invalid API key, please visit https://e2b.dev/docs/api-key for more information.", - } +func NewApiKeyAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewApiKeyAuthenticator(validationFunc) } -// NewAccessTokenAuthenticator creates an authenticator for the AccessTokenAuth security scheme (Authorization Bearer sk_e2b_). -func NewAccessTokenAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError)) Authenticator { - return &commonAuthenticator[uuid.UUID]{ - schemeName: "AccessTokenAuth", - header: headerKey{ - name: HeaderAuthorization, - prefix: PrefixAccessToken, - removePrefix: PrefixBearer, - }, - validationFunc: validationFunc, - setContextFunc: setUserID, - errorMessage: "Invalid Access token, try to login again by running `e2b auth login`.", - } +func NewAccessTokenAuthenticator(validationFunc func(context.Context, *gin.Context, string) (uuid.UUID, *APIError)) Authenticator { + return internalauthmiddleware.NewAccessTokenAuthenticator(validationFunc) } -// NewAuthProviderBearerAuthenticator creates an authenticator for AuthProviderBearerAuth (Authorization Bearer token). -func NewAuthProviderBearerAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError)) Authenticator { - return &commonAuthenticator[uuid.UUID]{ - schemeName: "AuthProviderBearerAuth", - header: headerKey{ - name: HeaderAuthorization, - removePrefix: PrefixBearer, - }, - validationFunc: validationFunc, - setContextFunc: setUserID, - errorMessage: "Invalid auth provider token.", - } +func NewAuthProviderBearerAuthenticator(validationFunc func(context.Context, *gin.Context, string) (uuid.UUID, *APIError)) Authenticator { + return internalauthmiddleware.NewAuthProviderBearerAuthenticator(validationFunc) } -// NewAuthProviderTeamAuthenticator creates an authenticator for the AuthProviderTeamAuth security scheme (X-Team-Id header). -func NewAuthProviderTeamAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, token string) (*types.Team, *APIError)) Authenticator { - return &commonAuthenticator[*types.Team]{ - schemeName: "AuthProviderTeamAuth", - header: headerKey{ - name: HeaderTeamID, - }, - validationFunc: validationFunc, - setContextFunc: setTeamInfo, - errorMessage: "Invalid auth provider token teamID.", - } +func NewAuthProviderTeamAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewAuthProviderTeamAuthenticator(validationFunc) } -// NewAdminJWTAuthenticator creates an authenticator for the AdminJWTAuth security scheme. -func NewAdminJWTAuthenticator(verifier *token.AdminVerifier) Authenticator { - return &commonAuthenticator[struct{}]{ - schemeName: "AdminJWTAuth", - header: headerKey{ - name: HeaderAuthorization, - removePrefix: PrefixBearer, - }, - validationFunc: func(ctx context.Context, _ *gin.Context, token string) (struct{}, *APIError) { - if _, err := verifier.Verify(ctx, token); err != nil { - return struct{}{}, &APIError{ - Code: http.StatusUnauthorized, - Err: err, - ClientMsg: "Invalid service token.", - } - } - - return struct{}{}, nil - }, - errorMessage: "Invalid service token.", - } +func NewAdminJWTAuthenticator(verifier *AdminVerifier) Authenticator { + return internalauthmiddleware.NewAdminJWTAuthenticator(verifier) } -// NewAdminApiKeyAuthenticator creates an authenticator for the AdminApiKeyAuth security scheme (X-Admin-Token header). func NewAdminApiKeyAuthenticator(adminToken string) Authenticator { - return newAdminApiKeyAuthenticator("AdminApiKeyAuth", adminToken) -} - -func newAdminApiKeyAuthenticator(schemeName, adminToken string) Authenticator { - return &commonAuthenticator[struct{}]{ - schemeName: schemeName, - header: headerKey{ - name: HeaderAdminToken, - }, - validationFunc: adminValidationFunction(adminToken), - errorMessage: "Invalid Access token.", - } -} - -// NewAdminTeamAuthenticator creates an authenticator for AdminTeamAuth (X-Team-ID header). -func NewAdminTeamAuthenticator(validationFunc func(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError)) Authenticator { - return newAdminTeamAuthenticator("AdminTeamAuth", validationFunc) + return internalauthmiddleware.NewAdminApiKeyAuthenticator(adminToken) } -func newAdminTeamAuthenticator( - schemeName string, - validationFunc func(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError), -) Authenticator { - return &commonAuthenticator[*types.Team]{ - schemeName: schemeName, - header: headerKey{ - name: HeaderTeamID, - }, - validationFunc: validationFunc, - setContextFunc: setTeamInfo, - errorMessage: "Invalid admin token teamID.", - } +func NewAdminTeamAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewAdminTeamAuthenticator(validationFunc) } -// CreateAuthenticationFunc creates an OpenAPI authentication function from a list of authenticators. -func CreateAuthenticationFunc( - authenticators []Authenticator, - preAuthHook func(*gin.Context), -) openapi3filter.AuthenticationFunc { - return func(ctx context.Context, input *openapi3filter.AuthenticationInput) error { - ginCtx := middleware.GetGinContext(ctx) - - if preAuthHook != nil { - preAuthHook(ginCtx) - } - - ctx, span := tracer.Start(ginCtx.Request.Context(), "authenticate") - defer span.End() - - for _, validator := range authenticators { - if input.SecuritySchemeName == validator.SecuritySchemeName() { - //nolint:contextcheck // We use the gin request context here by design. - return validator.Authenticate(ctx, ginCtx, input) - } - } - - return fmt.Errorf("invalid security scheme name '%s'", input.SecuritySchemeName) - } +func CreateAuthenticationFunc(authenticators []Authenticator, preAuthHook func(*gin.Context)) openapi3filter.AuthenticationFunc { + return internalauthmiddleware.CreateAuthenticationFunc(authenticators, preAuthHook) } diff --git a/packages/auth/pkg/auth/service.go b/packages/auth/pkg/auth/service.go index 9d1952aab4..e8cb8445b0 100644 --- a/packages/auth/pkg/auth/service.go +++ b/packages/auth/pkg/auth/service.go @@ -2,287 +2,24 @@ package auth import ( "context" - "errors" - "fmt" "net/http" - "strings" - "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/redis/go-redis/v9" - "github.com/e2b-dev/infra/packages/auth/pkg/token" - "github.com/e2b-dev/infra/packages/auth/pkg/types" + internalauthservice "github.com/e2b-dev/infra/packages/auth/internal/service" authdb "github.com/e2b-dev/infra/packages/db/pkg/auth" - "github.com/e2b-dev/infra/packages/shared/pkg/keys" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) -// authStore abstracts the DB operations needed for auth validation. -type authStore interface { - GetTeamByHashedAPIKey(ctx context.Context, hashedKey string) (*types.Team, error) - GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) - GetTeamByIDAndUserID(ctx context.Context, userID uuid.UUID, teamID string) (*types.Team, error) - GetUserIDByHashedAccessToken(ctx context.Context, hashedToken string) (uuid.UUID, error) - GetTeamAPIKeyHashes(ctx context.Context, teamID uuid.UUID) ([]string, error) -} - -// Service is the interface implemented by the unexported authService. It -// exposes the auth validation, team lookup, and cache invalidation operations -// used by callers such as APIStore and the dashboard-api handlers. -type Service interface { - ValidateAPIKey(ctx context.Context, ginCtx *gin.Context, apiKey string) (*types.Team, *APIError) - ValidateAccessToken(ctx context.Context, ginCtx *gin.Context, accessToken string) (uuid.UUID, *APIError) - ValidateAuthProviderToken(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError) - ValidateAuthProviderTeam(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError) - GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) - InvalidateTeamMemberCache(ctx context.Context, userID uuid.UUID, teamID string) - InvalidateTeamCache(ctx context.Context, teamID uuid.UUID) error - Close(ctx context.Context) error -} +type Service = internalauthservice.Service -// authService encapsulates the cache, store, and JWT verifier for auth validation. -type authService struct { - store authStore - teamCache *authCache - authProviderVerifier *token.ProviderVerifier -} - -// Compile-time assertion that *authService satisfies the Service interface. -var _ Service = (*authService)(nil) +type authService = internalauthservice.AuthService -// NewAuthService wires up the team cache, auth store, identity lookup, and JWT -// verifier from the supplied dependencies. The HTTP client is used for OIDC -// discovery and JWKS fetches. -// -//nolint:revive // returning unexported type is intentional to prevent external instantiation func NewAuthService( ctx context.Context, redisClient redis.UniversalClient, authDB *authdb.Client, - providerConfig token.ProviderConfig, + providerConfig ProviderConfig, httpClient *http.Client, ) (*authService, error) { - if redisClient == nil { - return nil, errors.New("redisClient is required") - } - if authDB == nil { - return nil, errors.New("authDB is required") - } - if httpClient == nil { - return nil, errors.New("httpClient is required") - } - - cache := newAuthCache(redisClient) - store := newAuthStore(authDB) - // OIDC bootstrap writes identity rows on the primary immediately before the - // next authenticated request; using the read replica here races replication lag. - identityLookup := newAuthIdentityLookup(authDB.Write) - v, err := token.NewProviderVerifier(ctx, providerConfig, httpClient, identityLookup) - if err != nil { - return nil, fmt.Errorf("initializing auth provider JWT verifier: %w", err) - } - - return &authService{ - store: store, - teamCache: cache, - authProviderVerifier: v, - }, nil -} - -// ValidateAPIKey verifies the API key format and fetches the associated team via cache + store. -func (s *authService) ValidateAPIKey(ctx context.Context, ginCtx *gin.Context, apiKey string) (*types.Team, *APIError) { - hashedKey, err := keys.VerifyKey(keys.ApiKeyPrefix, apiKey) - if err != nil { - return nil, &APIError{ - Err: fmt.Errorf("failed to verify api key: %w", err), - ClientMsg: "Invalid API key format", - Code: http.StatusUnauthorized, - } - } - - result, err := s.teamCache.GetOrSet(ctx, hashedKey, func(ctx context.Context, key string) (*types.Team, error) { - return s.store.GetTeamByHashedAPIKey(ctx, key) - }) - if err != nil { - var forbiddenErr *TeamForbiddenError - if errors.As(err, &forbiddenErr) { - return nil, &APIError{ - Err: err, - ClientMsg: err.Error(), - Code: http.StatusForbidden, - } - } - - return nil, &APIError{ - Err: fmt.Errorf("failed to get the team from db for an api key: %w", err), - ClientMsg: "Cannot get the team for the given API key", - Code: http.StatusUnauthorized, - } - } - - //nolint:contextcheck // We use the gin request context to set attributes on the parent span. - telemetry.SetAttributes(ginCtx.Request.Context(), - telemetry.WithMaskedAPIKey(keys.MaskToken(keys.ApiKeyPrefix, apiKey)), - telemetry.WithTeamID(result.TeamID()), - ) - - return result, nil -} - -// GetTeamByID fetches team auth data via cache + store. -func (s *authService) GetTeamByID(ctx context.Context, teamID uuid.UUID) (*types.Team, error) { - return s.teamCache.GetOrSet(ctx, teamCacheKey(teamID), func(ctx context.Context, _ string) (*types.Team, error) { - return s.store.GetTeamByID(ctx, teamID) - }) -} - -// ValidateAccessToken verifies the access token format and fetches the associated user ID. -func (s *authService) ValidateAccessToken(ctx context.Context, ginCtx *gin.Context, accessToken string) (uuid.UUID, *APIError) { - hashedToken, err := keys.VerifyKey(keys.AccessTokenPrefix, accessToken) - if err != nil { - return uuid.UUID{}, &APIError{ - Err: fmt.Errorf("failed to verify access token: %w", err), - ClientMsg: "Invalid access token format", - Code: http.StatusUnauthorized, - } - } - - userID, err := s.store.GetUserIDByHashedAccessToken(ctx, hashedToken) - if err != nil { - return uuid.UUID{}, &APIError{ - Err: fmt.Errorf("failed to get the user from db for an access token: %w", err), - ClientMsg: "Cannot get the user for the given access token", - Code: http.StatusUnauthorized, - } - } - - //nolint:contextcheck // We use the gin request context to set attributes on the parent span. - telemetry.SetAttributes(ginCtx.Request.Context(), - telemetry.WithMaskedAccessToken(keys.MaskToken(keys.AccessTokenPrefix, accessToken)), - telemetry.WithUserID(userID.String()), - ) - - return userID, nil -} - -// ValidateAuthProviderToken verifies a JWT against the configured auth provider and resolves an internal user ID. -// -// When no auth provider verifier is configured (AUTH_PROVIDER_CONFIG is unset), -// every token is denied with 401. This makes "no auth provider" a valid -// configuration: API key / access token flows keep working, but JWT-based -// flows are universally rejected. -func (s *authService) ValidateAuthProviderToken(ctx context.Context, ginCtx *gin.Context, token string) (uuid.UUID, *APIError) { - if s.authProviderVerifier == nil { - return uuid.UUID{}, &APIError{ - Err: errors.New("auth provider is not configured"), - ClientMsg: "Backend authentication failed", - Code: http.StatusUnauthorized, - } - } - - return s.validateJWTWithProvider(ctx, ginCtx, s.authProviderVerifier, token, "auth provider") -} - -func (s *authService) validateJWTWithProvider(ctx context.Context, ginCtx *gin.Context, v *token.ProviderVerifier, token string, tokenSource string) (uuid.UUID, *APIError) { - userID, _, err := v.Verify(ctx, token) - if err != nil { - return uuid.UUID{}, &APIError{ - Err: err, - ClientMsg: "Backend authentication failed", - Code: http.StatusUnauthorized, - } - } - - if userID == uuid.Nil { - return uuid.UUID{}, &APIError{ - Err: fmt.Errorf("%s token user claim is missing or is not an internal UUID", tokenSource), - ClientMsg: "Backend authentication failed", - Code: http.StatusUnauthorized, - } - } - - //nolint:contextcheck // We use the gin request context to set attributes on the parent span. - telemetry.SetAttributes(ginCtx.Request.Context(), - telemetry.WithUserID(userID.String()), - ) - - return userID, nil -} - -// ValidateAuthProviderTeam extracts the user ID from the gin context and fetches the team via cache + store. -func (s *authService) ValidateAuthProviderTeam(ctx context.Context, ginCtx *gin.Context, teamID string) (*types.Team, *APIError) { - userID, ok := GetUserID(ginCtx) - if !ok { - return nil, &APIError{ - Err: errors.New("user ID has invalid type"), - ClientMsg: "Backend authentication failed", - Code: http.StatusInternalServerError, - } - } - - cacheKey := teamMemberCacheKey(userID, teamID) - - result, err := s.teamCache.GetOrSet(ctx, cacheKey, func(ctx context.Context, _ string) (*types.Team, error) { - return s.store.GetTeamByIDAndUserID(ctx, userID, teamID) - }) - if err != nil { - var forbiddenErr *TeamForbiddenError - if errors.As(err, &forbiddenErr) { - return nil, &APIError{ - Err: fmt.Errorf("failed getting team: %w", err), - ClientMsg: fmt.Sprintf("Forbidden: %s", err.Error()), - Code: http.StatusForbidden, - } - } - - return nil, &APIError{ - Err: fmt.Errorf("failed getting team: %w", err), - ClientMsg: "Backend authentication failed", - Code: http.StatusUnauthorized, - } - } - - //nolint:contextcheck // We use the gin request context to set attributes on the parent span. - telemetry.SetAttributes(ginCtx.Request.Context(), - telemetry.WithUserID(userID.String()), - telemetry.WithTeamID(result.TeamID()), - ) - - return result, nil -} - -// InvalidateTeamMemberCache removes the cached auth entry for a specific user-team pair. -// This should be called when team membership changes (member added or removed). -func (s *authService) InvalidateTeamMemberCache(ctx context.Context, userID uuid.UUID, teamID string) { - s.teamCache.Invalidate(ctx, teamMemberCacheKey(userID, teamID)) -} - -// InvalidateTeamCache queries the team's API key hashes and removes their cached entries. -func (s *authService) InvalidateTeamCache(ctx context.Context, teamID uuid.UUID) error { - s.teamCache.Invalidate(ctx, teamCacheKey(teamID)) - - hashes, err := s.store.GetTeamAPIKeyHashes(ctx, teamID) - if err != nil { - return fmt.Errorf("failed to get team API key hashes: %w", err) - } - - for _, hash := range hashes { - s.teamCache.Invalidate(ctx, hash) - } - - return nil -} - -func teamMemberCacheKey(userID uuid.UUID, teamID string) string { - return fmt.Sprintf("%s-%s", userID.String(), strings.ToLower(teamID)) -} - -func teamCacheKey(teamID uuid.UUID) string { - return fmt.Sprintf("team-%s", teamID.String()) -} - -// Close stops the underlying cache's background refresh goroutines. -func (s *authService) Close(ctx context.Context) error { - return s.teamCache.Close(ctx) + return internalauthservice.NewAuthService(ctx, redisClient, authDB, providerConfig, httpClient) } diff --git a/packages/auth/pkg/auth/team.go b/packages/auth/pkg/auth/team.go new file mode 100644 index 0000000000..a7888e956f --- /dev/null +++ b/packages/auth/pkg/auth/team.go @@ -0,0 +1,31 @@ +package auth + +import ( + "github.com/gin-gonic/gin" + + internalauthteam "github.com/e2b-dev/infra/packages/auth/internal/team" + "github.com/e2b-dev/infra/packages/auth/pkg/types" + authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries" +) + +type BlockedTeamAllowlist = internalauthteam.BlockedTeamAllowlist + +func CheckTeamBanned(team authqueries.Team) error { + return internalauthteam.CheckTeamBanned(team) +} + +func CheckTeamBlocked(team *types.Team) error { + return internalauthteam.CheckTeamBlocked(team) +} + +func CheckBlockedTeamForRoute(c *gin.Context, team *types.Team, allowlist BlockedTeamAllowlist) error { + return internalauthteam.CheckBlockedTeamForRoute(c, team, allowlist) +} + +func CheckTeamAccess(c *gin.Context, team *types.Team, allowlist BlockedTeamAllowlist) error { + return internalauthteam.CheckTeamAccess(c, team, allowlist) +} + +func EnforceBlockedTeam(allowlist BlockedTeamAllowlist) gin.HandlerFunc { + return internalauthteam.EnforceBlockedTeam(allowlist) +} diff --git a/packages/auth/pkg/auth/testing.go b/packages/auth/pkg/auth/testing.go index 950318819d..88241b5b70 100644 --- a/packages/auth/pkg/auth/testing.go +++ b/packages/auth/pkg/auth/testing.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/e2b-dev/infra/packages/auth/internal/authcontext" "github.com/e2b-dev/infra/packages/auth/pkg/types" ) @@ -13,12 +14,12 @@ import ( func SetUserIDForTest(t *testing.T, c *gin.Context, userID uuid.UUID) { t.Helper() - setUserID(c, userID) + authcontext.SetUserID(c, userID) } // SetTeamInfoForTest sets the team info on the gin context for use in tests. func SetTeamInfoForTest(t *testing.T, c *gin.Context, team *types.Team) { t.Helper() - setTeamInfo(c, team) + authcontext.SetTeamInfo(c, team) } diff --git a/packages/auth/pkg/auth/token.go b/packages/auth/pkg/auth/token.go new file mode 100644 index 0000000000..12886609d2 --- /dev/null +++ b/packages/auth/pkg/auth/token.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + "net/http" + + "github.com/e2b-dev/infra/packages/auth/internal/token" + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" +) + +type ProviderConfig = token.ProviderConfig + +type JWTConfig = jwks.Config + +type JWTIssuer = jwks.Issuer + +type AudienceMatchPolicy = jwks.AudienceMatchPolicy + +const AudienceMatchAny = jwks.AudienceMatchAny + +type AdminVerifier = token.AdminVerifier + +func ParseProviderConfig(value string) (ProviderConfig, error) { + return token.ParseProviderConfig(value) +} + +func NewAdminVerifier(ctx context.Context, config ProviderConfig, httpClient *http.Client) (*AdminVerifier, error) { + return token.NewAdminVerifier(ctx, config, httpClient) +} diff --git a/packages/dashboard-api/internal/cfg/model.go b/packages/dashboard-api/internal/cfg/model.go index 7d6f02adc0..f6d9b079a1 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -6,17 +6,17 @@ import ( "github.com/caarlos0/env/v11" - "github.com/e2b-dev/infra/packages/auth/pkg/token" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) type Config struct { - Port int `env:"PORT" envDefault:"3010"` - PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING,required,notEmpty"` - ClickhouseConnectionString string `env:"CLICKHOUSE_CONNECTION_STRING"` - ClickhouseConnectionStrings []string `env:"CLICKHOUSE_CONNECTION_STRINGS" envSeparator:";"` - AdminToken string `env:"ADMIN_TOKEN,required,notEmpty"` - AuthProvider token.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` - AdminAuth token.ProviderConfig `env:"ADMIN_AUTH_CONFIG"` + Port int `env:"PORT" envDefault:"3010"` + PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING,required,notEmpty"` + ClickhouseConnectionString string `env:"CLICKHOUSE_CONNECTION_STRING"` + ClickhouseConnectionStrings []string `env:"CLICKHOUSE_CONNECTION_STRINGS" envSeparator:";"` + AdminToken string `env:"ADMIN_TOKEN,required,notEmpty"` + AuthProvider sharedauth.ProviderConfig `env:"AUTH_PROVIDER_CONFIG"` + AdminAuthProvider sharedauth.ProviderConfig `env:"ADMIN_AUTH_PROVIDER_CONFIG"` AuthDBConnectionString string `env:"AUTH_DB_CONNECTION_STRING"` AuthDBReadReplicaConnectionString string `env:"AUTH_DB_READ_REPLICA_CONNECTION_STRING"` @@ -75,8 +75,8 @@ func Parse() (Config, error) { var config Config err := env.ParseWithOptions(&config, env.Options{ FuncMap: map[reflect.Type]env.ParserFunc{ - reflect.TypeFor[token.ProviderConfig](): func(v string) (any, error) { - return token.ParseProviderConfig(v) + reflect.TypeFor[sharedauth.ProviderConfig](): func(v string) (any, error) { + return sharedauth.ParseProviderConfig(v) }, }, }) diff --git a/packages/dashboard-api/internal/cfg/model_test.go b/packages/dashboard-api/internal/cfg/model_test.go index a639ce401a..5200a4d89c 100644 --- a/packages/dashboard-api/internal/cfg/model_test.go +++ b/packages/dashboard-api/internal/cfg/model_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) func setBaseEnv(t *testing.T) { @@ -41,7 +41,7 @@ func TestParseAuthProviderConfig(t *testing.T) { entry := config.AuthProvider.JWT[0] require.Equal(t, "https://auth.example.com", entry.Issuer.URL) require.Equal(t, []string{"dashboard-api", "other"}, entry.Issuer.Audiences) - require.Equal(t, jwks.AudienceMatchAny, entry.Issuer.AudienceMatchPolicy) + require.Equal(t, sharedauth.AudienceMatchAny, entry.Issuer.AudienceMatchPolicy) require.Equal(t, 30*time.Minute, entry.CacheDuration) } @@ -107,14 +107,14 @@ func TestParseOryWithAuthProviderConfig(t *testing.T) { require.Equal(t, "https://auth.mycompany.com", config.AuthProvider.JWT[0].Issuer.URL) } -func TestParseAdminAuthConfig(t *testing.T) { +func TestParseAdminAuthProviderConfig(t *testing.T) { setBaseEnv(t) - t.Setenv("ADMIN_AUTH_CONFIG", `{"jwt":[{"issuer":{"url":"https://workspace.example.com","audiences":["fx1"]}}]}`) + t.Setenv("ADMIN_AUTH_PROVIDER_CONFIG", `{"jwt":[{"issuer":{"url":"https://workspace.example.com","audiences":["fx1"]}}]}`) config, err := Parse() require.NoError(t, err) - require.Len(t, config.AdminAuth.JWT, 1) - require.Equal(t, "https://workspace.example.com", config.AdminAuth.JWT[0].Issuer.URL) + require.Len(t, config.AdminAuthProvider.JWT, 1) + require.Equal(t, "https://workspace.example.com", config.AdminAuthProvider.JWT[0].Issuer.URL) } func TestParseFailureCondition(t *testing.T) { diff --git a/packages/dashboard-api/internal/identity/issuer.go b/packages/dashboard-api/internal/identity/issuer.go index c46179957e..7b42ebba1a 100644 --- a/packages/dashboard-api/internal/identity/issuer.go +++ b/packages/dashboard-api/internal/identity/issuer.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) // ResolveOryIssuer picks the Ory issuer URL from the auth provider's JWT @@ -14,7 +14,7 @@ import ( // When exactly one JWT entry is configured, its issuer is used without // requiring a host match. // When no JWT entries are configured, it falls back to the SDK URL. -func ResolveOryIssuer(sdkURL string, jwtConfigs []jwks.Config) (string, error) { +func ResolveOryIssuer(sdkURL string, jwtConfigs []sharedauth.JWTConfig) (string, error) { sdkURL = strings.TrimSpace(sdkURL) issuers := uniqueIssuerURLs(jwtConfigs) @@ -48,7 +48,7 @@ func ResolveOryIssuer(sdkURL string, jwtConfigs []jwks.Config) (string, error) { return "", fmt.Errorf("no JWT issuer in AUTH_PROVIDER_CONFIG matches ORY_SDK_URL host %q", sdkHost) } -func uniqueIssuerURLs(jwtConfigs []jwks.Config) []string { +func uniqueIssuerURLs(jwtConfigs []sharedauth.JWTConfig) []string { seen := make(map[string]struct{}, len(jwtConfigs)) issuers := make([]string, 0, len(jwtConfigs)) for _, jwt := range jwtConfigs { diff --git a/packages/dashboard-api/internal/identity/issuer_test.go b/packages/dashboard-api/internal/identity/issuer_test.go index 70d9cf09ea..c17dd52ade 100644 --- a/packages/dashboard-api/internal/identity/issuer_test.go +++ b/packages/dashboard-api/internal/identity/issuer_test.go @@ -5,14 +5,14 @@ import ( "github.com/stretchr/testify/require" - "github.com/e2b-dev/infra/packages/auth/pkg/token/jwks" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) func TestResolveOryIssuer_SingleJWT(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ - {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []sharedauth.JWTConfig{ + {Issuer: sharedauth.JWTIssuer{URL: "https://auth.example.com"}}, }) require.NoError(t, err) require.Equal(t, "https://auth.example.com", issuer) @@ -21,9 +21,9 @@ func TestResolveOryIssuer_SingleJWT(t *testing.T) { func TestResolveOryIssuer_MultipleJWTMatchesSDKHost(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ - {Issuer: jwks.Issuer{URL: "https://auth-a.mycompany.com"}}, - {Issuer: jwks.Issuer{URL: "https://tenant.projects.oryapis.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []sharedauth.JWTConfig{ + {Issuer: sharedauth.JWTIssuer{URL: "https://auth-a.mycompany.com"}}, + {Issuer: sharedauth.JWTIssuer{URL: "https://tenant.projects.oryapis.com"}}, }) require.NoError(t, err) require.Equal(t, "https://tenant.projects.oryapis.com", issuer) @@ -32,9 +32,9 @@ func TestResolveOryIssuer_MultipleJWTMatchesSDKHost(t *testing.T) { func TestResolveOryIssuer_MultipleJWTNoMatch(t *testing.T) { t.Parallel() - _, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ - {Issuer: jwks.Issuer{URL: "https://auth-a.mycompany.com"}}, - {Issuer: jwks.Issuer{URL: "https://auth-b.mycompany.com"}}, + _, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []sharedauth.JWTConfig{ + {Issuer: sharedauth.JWTIssuer{URL: "https://auth-a.mycompany.com"}}, + {Issuer: sharedauth.JWTIssuer{URL: "https://auth-b.mycompany.com"}}, }) require.Error(t, err) require.Contains(t, err.Error(), "no JWT issuer") @@ -59,9 +59,9 @@ func TestResolveOryIssuer_NoJWTConfigsAndNoSDKURL(t *testing.T) { func TestResolveOryIssuer_DeduplicatesSameIssuer(t *testing.T) { t.Parallel() - issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []jwks.Config{ - {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, - {Issuer: jwks.Issuer{URL: "https://auth.example.com"}}, + issuer, err := ResolveOryIssuer("https://tenant.projects.oryapis.com", []sharedauth.JWTConfig{ + {Issuer: sharedauth.JWTIssuer{URL: "https://auth.example.com"}}, + {Issuer: sharedauth.JWTIssuer{URL: "https://auth.example.com"}}, }) require.NoError(t, err) require.Equal(t, "https://auth.example.com", issuer) diff --git a/packages/dashboard-api/main.go b/packages/dashboard-api/main.go index 8918efce44..c75d530c79 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -24,7 +24,6 @@ import ( "go.uber.org/zap/zapcore" sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/auth/pkg/token" clickhouse "github.com/e2b-dev/infra/packages/clickhouse/pkg" "github.com/e2b-dev/infra/packages/dashboard-api/internal/api" "github.com/e2b-dev/infra/packages/dashboard-api/internal/cfg" @@ -248,7 +247,7 @@ func run() int { } swagger.Servers = nil - adminVerifier, err := token.NewAdminVerifier(ctx, config.AdminAuth, authClient) + adminVerifier, err := sharedauth.NewAdminVerifier(ctx, config.AdminAuthProvider, authClient) if err != nil { l.Error(ctx, "initializing admin JWT verifier", zap.Error(err)) @@ -256,7 +255,7 @@ func run() int { } if adminVerifier == nil { - l.Warn(ctx, "ADMIN_AUTH_CONFIG is not configured; /admin/v1 endpoints will reject requests with 401") + l.Warn(ctx, "ADMIN_AUTH_PROVIDER_CONFIG is not configured; /admin/v1 endpoints will reject requests with 401") } authenticationFunc := sharedauth.CreateAuthenticationFunc( From d71f52eeba74f21fcffdba5b4fcd103b8e2ee42f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 09:58:15 +0000 Subject: [PATCH 7/9] chore: auto-commit generated changes --- packages/auth/internal/service/store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth/internal/service/store.go b/packages/auth/internal/service/store.go index 34548a5422..74fac0b5e5 100644 --- a/packages/auth/internal/service/store.go +++ b/packages/auth/internal/service/store.go @@ -15,7 +15,7 @@ import ( "github.com/e2b-dev/infra/packages/shared/pkg/logger" ) -var tracer = otel.Tracer("github.com/e2b-dev/infra/packages/auth/pkg/auth") +var tracer = otel.Tracer("github.com/e2b-dev/infra/packages/auth/internal/service") type authStoreImpl struct { authDB *authdb.Client From b1f4a7766e843a742094fa5dbaf676b01e89ca28 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 12:10:12 +0200 Subject: [PATCH 8/9] fix(auth): refresh JWKS signing methods --- packages/auth/internal/token/jwks/verifier.go | 20 ++-- .../auth/internal/token/jwks/verifier_test.go | 92 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 packages/auth/internal/token/jwks/verifier_test.go diff --git a/packages/auth/internal/token/jwks/verifier.go b/packages/auth/internal/token/jwks/verifier.go index e3599bdf6e..64a03c4488 100644 --- a/packages/auth/internal/token/jwks/verifier.go +++ b/packages/auth/internal/token/jwks/verifier.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "time" @@ -23,8 +24,7 @@ const httpTimeout = 10 * time.Second // Option customizes a Verifier beyond the issuer configuration. type Option func(*Verifier) -// WithParserOptions appends jwt parser options (e.g. jwt.WithValidMethods, -// jwt.WithLeeway) to the verifier's defaults. +// WithParserOptions appends jwt parser options to the verifier's defaults. func WithParserOptions(options ...jwt.ParserOption) Option { return func(v *Verifier) { v.parserOptions = append(v.parserOptions, options...) @@ -34,6 +34,7 @@ func WithParserOptions(options ...jwt.ParserOption) Option { // Verifier verifies JWTs against the JWKS of a single OIDC issuer. type Verifier struct { keyfunc keyfunc.Keyfunc + storage jwkset.Storage audiences []string parserOptions []jwt.ParserOption } @@ -114,8 +115,7 @@ func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient * return nil, fmt.Errorf("create JWKS storage: %w", err) } - validMethods, err := validMethodsFromStorage(ctx, storage) - if err != nil { + if _, err := validMethodsFromStorage(ctx, storage); err != nil { return nil, fmt.Errorf("validate JWKS signing algorithms: %w", err) } @@ -130,11 +130,11 @@ func newVerifier(ctx context.Context, entry Config, jwksURL string, httpClient * parserOptions := []jwt.ParserOption{ jwt.WithExpirationRequired(), jwt.WithIssuer(entry.Issuer.URL), - jwt.WithValidMethods(validMethods), } verifier := &Verifier{ keyfunc: keyFunc, + storage: storage, audiences: entry.Issuer.Audiences, parserOptions: parserOptions, } @@ -183,14 +183,20 @@ func validMethodsFromStorage(ctx context.Context, storage jwkset.Storage) ([]str // Verify parses and validates the supplied token string and returns its // claims. func (v *Verifier) Verify(ctx context.Context, tokenString string) (jwt.MapClaims, error) { - if v == nil || v.keyfunc == nil { + if v == nil || v.keyfunc == nil || v.storage == nil { return nil, errors.New("JWKS verifier is not configured") } + validMethods, err := validMethodsFromStorage(ctx, v.storage) + if err != nil { + return nil, fmt.Errorf("validate JWKS signing algorithms: %w", err) + } + parserOptions := append(slices.Clone(v.parserOptions), jwt.WithValidMethods(validMethods)) + claims := jwt.MapClaims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { return v.keyfunc.KeyfuncCtx(ctx)(token) - }, v.parserOptions...) + }, parserOptions...) if err != nil { return nil, fmt.Errorf("failed to verify token: %w", err) } diff --git a/packages/auth/internal/token/jwks/verifier_test.go b/packages/auth/internal/token/jwks/verifier_test.go new file mode 100644 index 0000000000..ba5faa8b29 --- /dev/null +++ b/packages/auth/internal/token/jwks/verifier_test.go @@ -0,0 +1,92 @@ +package jwks + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v4" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVerifierRefreshesValidMethodsWithJWKS(t *testing.T) { + t.Parallel() + + rsaPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + _, edPrivateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + const ( + rsaKeyID = "rsa-key" + edKeyID = "ed-key" + audience = "test-audience" + ) + + var keySet atomic.Value + keySet.Store(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: &rsaPrivateKey.PublicKey, + KeyID: rsaKeyID, + Algorithm: jwt.SigningMethodRS256.Alg(), + Use: "sig", + }}}) + + mux := http.NewServeMux() + server := httptest.NewTLSServer(mux) + t.Cleanup(server.Close) + mux.HandleFunc(defaultJWKSPath, func(w http.ResponseWriter, _ *http.Request) { + if encodeErr := json.NewEncoder(w).Encode(keySet.Load().(jose.JSONWebKeySet)); encodeErr != nil { + t.Errorf("encode JWKS: %v", encodeErr) + } + }) + + verifier, err := NewVerifierFromIssuerJWKS(t.Context(), Config{ + Issuer: Issuer{ + URL: server.URL, + Audiences: []string{audience}, + AudienceMatchPolicy: AudienceMatchAny, + }, + CacheDuration: 10 * time.Millisecond, + }, server.Client()) + require.NoError(t, err) + + rsaToken := signedTestToken(t, jwt.SigningMethodRS256, rsaPrivateKey, rsaKeyID, server.URL, audience) + _, err = verifier.Verify(t.Context(), rsaToken) + require.NoError(t, err) + + keySet.Store(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: edPrivateKey.Public().(ed25519.PublicKey), + KeyID: edKeyID, + Algorithm: jwt.SigningMethodEdDSA.Alg(), + Use: "sig", + }}}) + edToken := signedTestToken(t, jwt.SigningMethodEdDSA, edPrivateKey, edKeyID, server.URL, audience) + + require.EventuallyWithT(t, func(collect *assert.CollectT) { + _, verifyErr := verifier.Verify(t.Context(), edToken) + assert.NoError(collect, verifyErr) + }, time.Second, 10*time.Millisecond) +} + +func signedTestToken(t *testing.T, method jwt.SigningMethod, privateKey any, keyID, issuer, audience string) string { + t.Helper() + + token := jwt.NewWithClaims(method, jwt.MapClaims{ + "iss": issuer, + "aud": audience, + "exp": time.Now().Add(time.Minute).Unix(), + }) + token.Header["kid"] = keyID + signed, err := token.SignedString(privateKey) + require.NoError(t, err) + + return signed +} From 128cbf06847ab10c4b49e86a3c24246b49a05760 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 21 Jul 2026 12:46:13 +0200 Subject: [PATCH 9/9] fix(api): relax admin project slug validation --- .../dashboard-api/internal/api/api.gen.go | 208 +++++++++--------- spec/openapi-dashboard.yml | 1 - 2 files changed, 104 insertions(+), 105 deletions(-) diff --git a/packages/dashboard-api/internal/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index 06e58eb6ae..462e198320 100644 --- a/packages/dashboard-api/internal/api/api.gen.go +++ b/packages/dashboard-api/internal/api/api.gen.go @@ -1967,110 +1967,110 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "7F17b9y2lv8qhPYC217M2M7rYmvg4iKOk9t0kzaI7dsFAu+Yls7M8EYiVZLyI66/+4IPSZRESdTY48bd", - "/NM6I4qPc348PC8e3UQxy3JGgUoR7d9EOeY4Awlc/+u8IGmyIIn6OwERc5JLwmi0H71NgEqyJMARWyK5", - "BqTb7kSziKjnOZbraBZRnEG0X/czizj8VhAOSbQveQGzSMRryLAaYMl4hmW0HxWFbimvc/WukJzQVXR7", - "O6u6WTC+kJDlKZbQndov+g+coiVJJXB0fm3mhkg15xkqX2/8yHj9O04JFtVyfiuAX3fX05iIu5b+uYvu", - "hF+xLMNzAYr2EhKUEiEVVc2s3x4KJBlagURCYlkIEGjJuJoaXOUpSyDaX+JUwPBUxSDtiYRMBDBhFmX4", - "6q1p/GRvr3qOOcdq0IKS3wqwDdQgt7NIyOtUtVFdRxUlyrVMJUdFA8kQoXFaJBBKimpI78r/wmEZ7Uf/", - "sVtviF3TTOweqKGP9OtqBY1F961QLOKCC8Y9C9S/Iw6y4BQSBVC1gXIOF4QVwiyYg8gZFYAIRWcxB0WK", - "BZa/l/w8Q4ZVfRC1gweAUixSkhHZned7fEWyIkO0yM7NPtfEUpQ3c0c5cJTjFfRNwnTsziGBJS5SGe2/", - "2JvVYCNUPnsaaXCpES22MkLtvyqSEyphBVxPXmCanLOrt4ch0sk27pFPdVdDm6RLP4lX3cGPSxEi8Qqp", - "ARTB4jXEn/vIpLoZGjgj9B3QlVy7pGhOY4GFICuaAZXh7OSAk2tUv4k4u2yzV+JV37TrF9/18/lvQ2x+", - "+mKUy8213eemMr2aXeVQ7562VnvewUwZZscfttvUelacFfl9suBGMC5vf79R20XIhcOR299vJF7dlqxA", - "36mWc4lXK0hm6OObV8+ePfvhZ0zZ9wObSrwK5ZRdWTCTEiIkobFU22MKi1Tzgc2i9sO9sUkA5vHawyYs", - "YE6oACqIJBeARHFuyFFqTIxWsmsHvUxTdgkJiteY41hphghzQGd4/uVshs725j+o/+2o/yzUf+ZnfYu3", - "E2pINnxVSra/PZ8pqSyBq1f/9xOef9mb/7CzmJ/+9S/RbJhvChzdhR4xLhHjCXCFIo3FCptqfebl3tmq", - "Pr1ciixc1WhK9aCKHZ9av1aQjm2X7p+6zWnfovTZNH6s9Jxldz5MAGdhZ6pq2TcJ08nd1H3VyVFaeI9Y", - "nCGRFiuz9QRLL3q3nGo29Vi3pA4lg2ndS4qqs81msQ2Be4HTApSMrSZXidod9BH+DbHSvInS+ARS76CE", - "gUCUSZRhGa/tZvqtAHHHY7Ja5ONUQuv558V5SuLuAt4YmVo1VFy6IIKck5TIa/SdAgL6OzKvz5C2X9Df", - "kRqEU5z2nm92PA95zxlLAdPW/DY4Dgyr1WlQyh41tpgZ41j/QRPXlJ4g+YegMCDOY5YWGdXjJoRDrJ5t", - "IMFrg6otxZ0nRmB3mxZ50mzi/NAv2QsB/G2QJ0W17BEmtpO7yNVb9bIRCdrufb63p/4XMyqBarLjPE9J", - "jNX8dv8t1CRvnP6H7OTXnDNuxmgu8gAnpbhQBvLzvSfbH/NlIdeKtKZXBKadGvzZ9gd/w/g5SRKgZsTn", - "2x/xZybRkhU0MSP+sP0RXzG6TEmsOfriIVB0BPwCeM3JFw8Bo19y4AZBxByAJMtTUPYZJGYST7c/iZNc", - "SK6UHrv223LH6y38MkmURvQe1BH50W6z/Zso5ywHLonZ6JBhkjYkhPnFJ6xq8fLJtqplGjtX6oFa+ssk", - "I1Rtsw+cXZAE+AfOliSFgbGby3qtfkY4STgIgZacZVoAxowuyargkCBcyDXKbfdKKNIiTfG5GsMIvo6D", - "slfO2gMVvX56oEWs4/pVHY/roy5RKkE8nTriHWOfi1yv/Stglvho9OfeqZiliju5iTNCy38G+Iy9tBaT", - "V6XPuO56ctsi2PvbC/OOK7g18WqkoJmfCOAHjEkhOc57ucFIEi+IEAXwUWtuZlor+i2CAeW+ZCI/wWMY", - "JeVmfI8KsqJFviC5RxOsnuou8coK1WHYu1RpLaBLhF5uvGJUcpZ+SDEFI0xPcgFc9jIDJwkki/PrQHt2", - "dNAPnOlnqus0/WUZ7X8KgKanh+a8b2ftiRvGThN5JPFQ7nRgGdrRJbpUixmNC86ByoV1vENTqFSW2IDt", - "NXO7KU2HhQlobNBZQsTnRXbefvNvz0ffhAvt35UyXST4epOxM3y14DjbbHj1sqXiItW7dLFmBd90Hhdx", - "Xkx+tQUUL38HZuqM3CBGzZVBZndZcDphrx3rhjeV/ZfABaQsV/qdsh0lXqm9MFMQTgptbnrMu/7uRwRI", - "KTEdP+jTFyYoMiRyc9P5QtrJbyIi9MKVtLUeNtcV+6w9g65j9vS7f+zbP+enfy1//P4ffxmVI3rNs9Iv", - "11hKL+OUejt+NvZomQckTQld7SoVHccS6WaVS7j0Yo6fiyWzPL7I0h875lz10KH/TNJR57d0yTwi1Dgl", - "XnrcJPotpBsoo0WSDITEWY4ILeMmTnyrWnWCJcxVY9/Kl4QSsR4cjymraGzEGSJLVHbWO/yo+qC9UAMe", - "cv0ccUh1/oBkSK6JMPkDegb4AhM9gvaylCpgdxj/PJywv04ImJY7YF56D0LglQdNbzBJCw4oMw3Q5Rqo", - "zXlQ9ufZEpMUkrMZYnIN/JIIQGdqnmcBtlFbTFcYajC4Wld7rr0QParo4EOGnXyG8xwSvesSLNbnDPME", - "xSlRtNpx/G+aS0bkmrWqeRRxDEJ4Ba8zA2VCdLfKV4bdiclUo4bWIwehXlQFOA8MR9En3hEh+629BMvw", - "PB/VFSS6W1+eD4Ur+Wo4FiMZyrEQRugAUm+UYRh9aOkkM0MshSdFPzCOJdO2DGRMo6JeZGN+/eQ6sllQ", - "/SQ7r8HiE7PvvPlYriQNRKLer2M2dHMyvmW9+nDyihXUs71ffThBMeMmYc5NA4pmU9XsV1pYqrP+fnU5", - "n07gW+ShCV0cO1mPLVPUhGSCod7q8KV63Yd5Tf+Kvh2zoEsp/cLbJMhL1FBjwlQRoBfJv4ALYvyqgfK2", - "q0FX0bp20GwWcZy995ti3dWKHF9SL3l6XpBM4vSQiM9H5Av0DNOzKKcXn5nmHdAnbkuo1Lwq12w77s5y", - "1tAWqtCjA44GKQIQbADnh7FfG1NKXY7jEA9Ta9Wm04BJDQjFKjq56Q4blXT1CN6Zlsw46Mo59QwJ8gXa", - "ck5pMe/JwaC42/PhywQiPJ6bxKMu6MZIPduJZiEiIutTPExP9vHOqCWpp1N35yPbj4BTue5na+9Ufiwy", - "TOcccKJwhta6H5PGiTiIIpXj8xuamKtqdOmcF6NHmhMkKbne1Rr75dADW5BJEHwNXHuXplVwDjHjCfjX", - "OS45W+dHcybqIbowT+th0SUWyNILXRK57sxj9BT6kxshGWSMX/t4+14/GWNsIGYfm61T57QMOCv04WSG", - "zTkIoNIdq0queXu4Ew0MEJZZ4qaqBdhlztUWZ5xeB8Gs36VQCTQHKw2h0NyYPoH5fgxjG5x9T57+lw9n", - "R6aHj3qHD+gorXwTzcgWpb1aryPdh4BcGTYd+TmodtQt1Xssw8Qj6Q6wAGQeKuxxaJBOcrxcklhtAKxd", - "L0SBPADvxhP/EaRCHqOvr3IDqPbwv65BQbwxKNAEEpQxPRdMK+fsfwpkukW87BddEpqwS4RXbIYE01mK", - "to0yha1BnTK6Al57+2peOMq+K7qGyFrBT2+U+1rfklxBMryujFEimc7HwzRBKVtNX+QmV2V6nE28R2k4", - "JpkVnu6a1clpXwpWFYRkeT59EP3SxifiZim/JmXlck3itWKHOykrCkcFbSM92L2HVNM6SHx2MNm7GX3C", - "tU4f6glyH3hj3KNkHTfwx0VKGdTp0WJGlRMiDsu8T5+1H5y0YLM5PpBYFhxOeDObouAkZDVlKlPTjOzC", - "cchQJHXekbu6lo1eDTTMbzFkG+kGwQavA6KxJZRd982tyknqm1sg78vgZogTWjXtm880v9sIOzd2qh2G", - "OdUc9WKqIRgmoe0rRia0Id8Tkg20Se4hJWVW5nkfulI8YAZNHevutuX4koclU4qFPMrxJb2bAHU1nAA8", - "VMHVcDgPOVOnO0gbJ3E3vdPk2ofDdei8LbdV0AlbeT1r/6mhVVPw1hN0N0qbmw3CtBypLoxckIwJJ3FP", - "UbCGwPvzhcHKpR2C9KaLHJbh6blY4xwSJAhdpTCvlD4OONlB7wnnjBulj9Alx3OcE3RW9n6GDDnRkkCa", - "mGZ14JsrY4DrwHfvcZJAziHWSqRdvcfeBOG9hrbhSdS6UVBdudIeqabmq5Ru9bIM9Pk6J9iYdm2uTtrb", - "6cYDVyfrmOFNrY41oC/AGTo5eXtozALKKOyMHYphqZW19X3apvyF8cIWVHqnbI+f0mdZI70xxSa0hz2y", - "TbunwQP71qae2DBauD6FDjU67tt7pEm4t/a+BvVQrHMOBzNHvYkKcRdXrXt6hzGr9lR0WNX0xt4jn3oy", - "sn7WiVhto/m7KpS3a5yf9o6/GaJ8lnw/TZL13b90XTH1VUVhL1yqFTKaXjt+rvIabZkYOKbTTBSaHTiE", - "qkJ3cO621KaJ+DWvhkqYP1Lf2kzHGlISjvHqZVWQw2P92YoVAXTFK03S8o1SNerGPwald10dJNDjj1du", - "RRHtEgoK6RhqhR5J1ao6AbPwpelX3wwEygaHvHtQzElcGaNqc+zpt8caXHTTMBw8dVjQpVAwcAMsg+aS", - "P7YrA1nveqWH4dVM1wWApDw/loTbWgBhNoZvf/15jY1jvHp9RcQQJ0A/DzzA1L5eK3EiUQpKSjMKnXpO", - "/tOLKqim5Askx77yVT9Xj5ujlSnuI/cPzSLao4yQ5p+cFXmfdM3K2oRTIErEA2J0jcV7xqGfdTrow3sm", - "rEmGzuGa0cStqwGJ21hXsfAzNKwK2TjvTOUYl+j10kIYOIDtCbsYC3RW1246q0ROtVXvaTtruk1xbrfQ", - "OprOpXqfIh+E1k0GMtCYxB53xbH6uS5IZVCmy1K1jeYgrbO9Cj2ob+YnWu9qZcRmhH5wJv1kdm/3nbqh", - "l3FxPTjnO0YWypXc11w78YjeVOATAVwtwZO4nbL4MyQfAYtAD/QmCbh3j8cdYEoh8fuLiTgwq+h7POwy", - "r66cDu3lkoL2gup9c7M37DSLJDER1lD+V9fk9It11K87LZdyDo1nLVA0A4aWXC4OhhA3fqH3aPA+79Ad", - "3lIQHvTf4PXf2u3mUPc0NsftsUwP+67peq/EGrn0o+9ObY/ZnuGrj74U8v4x/tV363ZEOremN/NyYoDK", - "9eDOrCuytkk2BI3BzGmchZ+zlXQbT5dW3XbnpLYfxAUn8vpI9Qm2REpG6Muc/DdcvyxMeT1dXmkNONE7", - "yxZY+p+5bjk/Zp+B1gzA+s3qOupPvx6XvZwD5sDflIz76dfjsuSUFk36ad3PWkqtObh1Hw50m57+jFtq", - "Ie10JnWsyDiyWNVkrh0yrYUqMhJ79VQSqcsbv356gKrgCHr54W00iy5K52y0t/NkZ0/Xg8iB4pxE+9Gz", - "nb2dvUjfHl5rLuxiRb1dzbrd8/I+r4YME75sZy2WhE580gkNaiyBsPbNaf0GU6T7nOO62pOyy8u+0SXj", - "n5cpu1TKDytL+SgrP/rAhKwuF4vqdrEtrgVCHrDk+t5K+/RfY75tItt6VBs1up7eY3UlX3qFr9aSuXO5", - "LNL0uiZnrq1CnO2YIlN7fYNVs99VjeqKX2Ntnzi1pIbbvjD92upLY22fNuSCdqF3JMKn09vTWSSKLMP8", - "OtqPKkbpFUelvfAp0oCLTlWPFtCFAD4vq73snl/PKyWpxHUP9JS0KyvWHFy/tqf71vAXWI3ogRE5XsNn", - "DJ+67pkpV0joCpWc+EpQOhl5hiWmSFW5FnR+jarKBIFAtJVRJ+LQCoeHxmGrDNWjw6AldvLo0WcZ0YTf", - "BNTdmHpdt2p+K/Cg7p/QBd1JWU/N/QBJT6CzbrJry7CpBXz14skS6LHC4sSBwygaerS7AenzMArYaL21", - "x6aMmVqxjxNRtYLVqPKo1+QgzBp6HYS5ciaBFHy3rA7178pi0NLsXInpjF0oJUHnbenInryuxza3+mbI", - "Vo/TjwmY8iWErkzFYxOZ3tHTQNxnW5hxa3Dfu3h77qmP7ILEEOQB8GFr2461fe5UpR1r+8OWcWeY04ZZ", - "V5BdPNm11anE7o2paN8Cm4/pZQ2/EJbZtvq+oY5h4HOh43ZfjXX1JKTtkx4mVG6SHg5gZOmrdxWR+lKz", - "5Cyd5ymmYMiy4/BIGfM6OWhecmvqjrIfJlDTyQvPmWTqtbk83NJJNF608Q9QgH21KD3HkY5kW0NLs09J", - "TRqTFBIN3af3WJd5yrzK7VSlnmxV7m1Dlm1vyxlXGmK8Zla9/4b32LBE3K1jLlvciu/KSMVDbsgyPhSy", - "Ez3S/fVyCbH+pIIhkf5ujrim8ZozSr5sHaDhB/PXANCPFSyhTTft6L0XrNp7Zz2648Bxbu+1hbDd3qtb", - "k/yBTvTHx+mMXbinv+HKdg762RQle1wOOTh4ADnkK0J92/2ERwAMbWmJbzj0ShxMEcvxb4V1eun7JFhM", - "RGhD9AyZqC0vSMFXcCIChYtqOGeXFBIk1pgn/+9MB00us/Z5ymKcWt5ooCszwkRM5zpiaqljA4VtFt+v", - "wKnEiMJBXYe8zwNaReCnjeL98nCAkGt9dDf0jfKzXOHt7Ve/tuqR9dT9HPfO22+k5XhFqIkR6162LBGf", - "hbR9dkeHR08ywafTvnyATsCJCGli6lVN9XJj2B+MeDP/2HW/3TyM76O6OuhmOBcPgaROSdRgNLVqoH6D", - "UqPoM4wB6aZk8+04lA6qSxKbIekBgKRrtU/ETqJvIZdiaDt42I5iti3sWIr0QcdUYhzCi6n5GG2R262q", - "kh6W/+jWixQV/w3VqgW7rfSj3eqDGbs3VTGg211eVSLrW3OV7ndUvmWrl03dLnUJoq3ul2aJteA9U1ZX", - "+rZrahBZUtrQlbNt6o+vmJ1TJWJaELXv0ygaC4TTVKsC5upqM63OBNEgZVR/9Xqmq2FW3i2aVNeHl6n5", - "bHwHpzrXbptbs5uQGgwuvTq99J0HjJL28t+jotUz9MRHZwNh9prs9++16JZGfwRB9LKc61eUzLgpMqxn", - "v5Wk6EbNTb6tkw3WIwD08yq9tvy+tlOHsYyXz9AFTkmCZRlIN99TVK/WnhL/3q+zy6b78/THwLd6LG0C", - "pSrxy8HSn/NE8ueJabCUyBhEoBtEz7E0X6NuCSv1swbKcfkJ+Y2jStsQdt1bbw8s7DxX2MYAaiskbF/W", - "PRYb1dBwXGK2w0ZDCriDWet5vxt0tyjg2oUfgxUkvdMtLXb+/L6wrOLjZFXrHoGwjTCT53PRQWLsyUjW", - "mS4V6xLvqxE3j8WYe5k06DdJPE2IandBumGG4kZh1om5jDpl8yvF1R+JFRtHD4GL8+2aYePfiY+k9jtf", - "jv5fdYS+c639+mdCU0JhhgqaghDVq8o0YBRhlEBiXQlxWggJ/HvrQ1iSVALXGbYCMI/XJtM2ZmmRUV3F", - "Q5kaFwSjz3AtQCITZyqnSxjtMTnKlU8Htn1zYetQBUC8fsesYeI7jMtpbwTH5upXHiI8569JukGErpr2", - "t+3eUEykg+p6v1dfj2ru+V27S8c3vy4GaDd9d2vrbSrXQHhZTccEMAhdMhN4N2XahvfhYTmZLeKv96Ne", - "wRDsrP5rdQh2JhqEiJu6KN9twJFg6t/W1aBM+omtkmgLCxjjEnVq53LAyQwRmsCVfaf5QZt+pBy7hQM3", - "k93bN6YalYTD7ahGUZ5vWm9VBWkyendV8924LMU5Kt50OSNEqxqdoWWTQmBalXD66vHaLTYVDl28MpWP", - "v+E22o80ERtl8sSGEK6L/3kx/GoN8Wf9lTBdTw5vUAgwFMKvywp+m2M4QCHFqweDeqvu4mhkSJG6XfxQ", - "swdoDN8cGlO3iP1iphavXpJuuGNWuuTgqNT3jOxUVTSdGN32nBX6C1058LneVURIxq9njkHCLoCbQ0K/", - "0TBFp+wxUy5x+3tsUS91grGIVwtDl43eKS3MSS9NsJedl7TB/EBSpFXhcoLP3EGemfg3GbKZ5dsh5IaS", - "40bi1e1uq7hsiBDx1NIuLyeVNhJezRDjCXBIEIXLqujszCMxgp1XTeHRrOz8AEIkx3I9XeBMlQTum1bu", - "PNDW9tXJnqQQO6AwxWe/7e/p+7uts7Z3lrP9+/Z9VdP3U1lptkwdVRi0v9RZcc6Pxlve+KHsV/3Yqupc", - "3hVpXVfXN0dQtY1FK3lOCwAB/ILEgH769Vh7yuxw7esnt6e3/xcAAP//", + "7H1rc9w2lvZfQfGdqjezxZbkS6Y2qpoPluVMPBtnXJY02SqXtwWRp7sxJgEGAHWxRv99CxeSIAmSYEut", + "WFl/SawmiMs5Dw7ODYe3UcLyglGgUkSHt1GBOc5BAtd/XZQkS5ckVf9OQSScFJIwGh1Gb1OgkqwIcMRW", + "SG4A6bZ7URwR9bzAchPFEcU5RIdNP3HE4beScEijQ8lLiCORbCDHaoAV4zmW0WFUlrqlvCnUu0JyQtfR", + "3V1cd7NkfCkhLzIsoT+1f+h/4AytSCaBo4sbMzdE6jnHqHq99SPjze84I1jUy/mtBH7TX09rIu5ahucu", + "+hN+zfIcLwQo2ktIUUaEVFQ1s357LJBkaA0SCYllKUCgFeNqanBdZCyF6HCFMwHjUxWjtCcSchHAhDjK", + "8fVb0/jZwUH9HHOO1aAlJb+VYBuoQe7iSMibTLVRXUc1Jaq1zCVHTQPJEKFJVqYQSop6SO/K/8RhFR1G", + "/2+/2RD7ppnYP1JDn+jX1Qpaix5aoVgmJReMexaof0ccZMkppAqgagMVHC4JK4VZMAdRMCoAEYrOEw6K", + "FEss/13x8xwZVg1B1A4eAEqxzEhOZH+e7/A1ycsc0TK/MPtcE0tR3swdFcBRgdcwNAnTsTuHFFa4zGR0", + "+P1B3ICNUPnieaTBpUa02MoJtX/VJCdUwhq4nrzANL1g12+PQ6STbTwgn5quxjZJn34Sr/uDn1YiROI1", + "UgMogiUbSD4PkUl1MzZwTujPQNdy45KiPY0lFoKsaQ5UhrOTA05vUPMm4uyqy16J10PTbl78eZjPfxlj", + "8/PvJ7ncXttDbirTq9lVDvUeaGt15x3MlHF2/G67Ta1nzVlZPCQLbgXj8u7ft2q7CLl0OHL371uJ13cV", + "K9B3quVC4vUa0hh9+PH1ixcvfvgFU/bnkU0lXodyyq4smEkpEZLQRKrtMYdFqvnIZlH74cHYJADzZONh", + "ExawIFQAFUSSS0CivDDkqDQmRmvZtYdeZRm7ghQlG8xxojRDhDmgc7z4ch6j84PFD+p/e+o/S/WfxfnQ", + "4u2EWpINX1eS7S8vYyWVJXD16v98xIsvB4sf9paLT//xpyge55sCR3+hJ4xLxHgKXKFIY7HGplqfeXlw", + "tqpPL5ciC1c1mlI9qGLHx86vNaQT26X7T93m09Ci9Nk0fawMnGX3PkwA52Fnqmo5NAnTyf3UfdXJSVZ6", + "j1icI5GVa7P1BMsuB7ecajb3WLekDiWDaT1Iirqz7WaxC4F7ibMSlIytJ1eL2j30Af4FidK8idL4BFLv", + "oJSBQJRJlGOZbOxm+q0Ecc9jsl7k01RCm/kX5UVGkv4CfjQytW6ouHRJBLkgGZE36DsFBPRXZF6PkbZf", + "0F+RGoRTnA2eb3Y8D3kvGMsA0878tjgODKvVaVDJHjW2iI1xrP9BU9eUniH5x6AwIs4TlpU51eOmhEOi", + "nm0hwRuDqivFnSdGYPeblkXabuL8MCzZSwH8bZAnRbUcECa2k/vI1Tv1shEJ2u59eXCg/pcwKoFqsuOi", + "yEiC1fz2/yXUJG+d/sfs5DecM27GaC/yCKeVuFAG8suDZ7sf81UpN4q0plcEpp0a/MXuB/+R8QuSpkDN", + "iC93P+IvTKIVK2lqRvxh9yO+ZnSVkURz9PvHQNEJ8EvgDSe/fwwY/aMAbhBEzAFI8iIDZZ9BaibxfPeT", + "OCuE5ErpsWu/q3a83sKv0lRpRO9AHZEf7DY7vI0KzgrgkpiNDjkmWUtCmF98wqoRLx9tq0amsQulHqil", + "v0pzQtU2e8/ZJUmBv+dsRTIYGbu9rDfqZ4TTlIMQaMVZrgVgwuiKrEsOKcKl3KDCdq+EIi2zDF+oMYzg", + "6zkoB+WsPVDRm+dHWsQ6rl/V8bQ+6hKlFsTzqSN+ZuxzWei1fwXMEh+M/jw4FbNUcS83cU5o9WeAz9hL", + "azF7VfqM66+nsC2Cvb+DMO+5gjsTr0cKmvmZAH7EmBSS42KQG4ykyZIIUQKftOZi01rRbxkMKPclE/kJ", + "HsMoKbfTe1SQNS2LJSk8mmD9VHeJ11aojsPepUpnAX0iDHLjNaOSs+x9hikYYXpWCOBykBk4TSFdXtwE", + "2rOTg77nTD9TXWfZP1bR4ccAaHp6aM/7Lu5O3DB2nsgjqYdyn0aWoR1dok+1hNGk5ByoXFrHO7SFSm2J", + "jdhesdtNZTosTUBji85SIj4v84vum395OfkmXGr/rpTZMsU324yd4+slx/l2w6uXLRWXmd6lyw0r+bbz", + "uEyKcvarHaB4+TsyU2fkFjEarowyu8+CTzP22qlueFvbfylcQsYKpd8p21HitdoLsYJwWmpz02PeDXc/", + "IUAqien4QZ9/b4IiYyK3MJ0vpZ38NiJCL1xJW+thc12xL+JJN6HLcL2KuPK0tSY3yAqlsE6fdgN64xHJ", + "MkLX+0rpxolEulnt5K38ktMnXUV+j3ex8rBuQYfhU0bHkd/SFfMIReNmeOVxfOi3kG6gzBBJchAS5wUi", + "tIqEOBGretUplrBQjX0rXxFKxGZ0PKbsnKkRY0RWqOpscPhJhUD7lUZ83vo54pDpjADJkNwQYTIC9Azw", + "JSZ6BO03qZS6/jD+eTiBfB3in5cNYF56B0LgtQdNP2KSlRxQbhqgqw1Qm8WgLMrzFSYZpOcxYnID/IoI", + "QOdqnucB1k5X8NYYajG4Xld3roMQPanp4EOGnXyOiwJSvetSLDYXDPMUJRlRtNpzPGqaS0aImrWqeZRJ", + "AkJ4RakzA2UU9LfKV4bdmelRk6bTEwehXlQNOA8MJ9EnfiZCDttvKZbhmTuqK0h1t77MHQrX8vV4dEUy", + "VGAhjNABpN6oAiv60NJpY4ZYCk+KfmBcRaZtFZqYR0W9yNb8hsl1YvOahkl20YDFJ2Z/9mZYuZI0EIl6", + "v05Zxe3J+Jb1+v3Za1ZSz/Z+/f4MJYybFDg3sSeK5yrOr7WwVGf9w2pnPp3At8hjE4w4dfIYO8alCbIE", + "Q73T4Sv1ug/zmv41fXuKfp9S+oW3aZDfp6XGhKkiQC/TfwIXxHhKA+VtXyeu42/dMFgccZy/8xtX/dWK", + "Al9RL3kGXpBM4uyYiM8n5AsMDDOwKKcXn+HlHdAnbiuoNLyq1mw77s8ybmkLdTDRAUeLFAEINoDzw9iv", + "jSmlrsBJiM+os2rTacCkRoRiHW/cdodNSrpmBO9MK2Yc9eWceoYE+QJdOae0mHfkaFTcHfjwZUILHl9M", + "6lEXdGOknu1FcYiIyIcUD9OTfbw36WPS02m685HtJ8CZ3AyzdXAqP5U5pgsOOFU4Qxvdj0nMRBxEmcnp", + "+Y1NzFU1+nQuyskjzQl7VFzva43DcuiRLcg0CL4GroNL0yo4h4TxFPzrnJacnfOjPRP1EF2ap82w6AoL", + "ZOmFrojc9OYxeQr9wY2QHHLGb3y8faefTDE2ELNPzdZpslRGnBX6cDLDFhwEUOmOVafLvD3ei0YGCMsV", + "cZPPAuwy57KKM86ggyAedinUAs3BSksotDemT2C+m8LYFmffs+f/6cPZienhg97hIzpKJ4NEM7JDaa/W", + "60j3MSDXhk1Pfo6qHU1L9R7LMfFIuiMsAJmHCnscWqSTHK9WJFEbAGvXC1EgD8C78a1/AKmQx+ib68IA", + "qjv8rxtQEG8NCjSFFOVMzwXT2jn7/wUy3SJe9YuuCE3ZFcJrFiPBdN6hbaNMYWtQZ4yugTfevoYXjrLv", + "iq4xstbw0xvloda3IteQjq8rZ5RIpjPsME1RxtbzF7nN5ZcBZxMfUBpOSW6Fp7tmdXLal4JVBSFZUcwf", + "RL+09Ym4XRKvSUK52pBko9jhTsqKwklB20r4dW8WNbQOEp89TA5uRp9wbRKCBsLWR96o9SRZpw38aZFS", + "BXUGtJhJ5YSI4yqT02ftB6ch2PyM9ySRJYcz3s6PKDkJWU2VnNQ2I/twHDMUSZNJ5K6uY6PXA43zW4zZ", + "RrpBsMHrgGhqCVXXQ3Ors4yG5hbI+ypcGeKEVk2H5jPP7zbBzq2dasdhTjVHvZhrCIZJaPuKkQldyA+E", + "ZANtkgdIMomrzO1jV4oHzKCtY93ftpxe8rhkyrCQJwW+ovcToK6GE4CHOrgaDucxZ+p8B2nrJO4nbJrs", + "+XC4jp231bYKOmFrr2fjPzW0agveZoLuRulys0WYjiPVhZELkinhJB4oCtYSeH+8MFi1tGOQ3nSR4yo8", + "vRAbXECKBKHrDBa10scBp3voHeGccaP0EbrieIELgs6r3s+RISdaEchS06wJfHNlDHAd+B48TlIoOCRa", + "ibSr99ibILwXy7Y8iTp3BOpLVNoj1dZ8ldKtXpaBPl/nBJvSrs1lSHvf3HjgmmQdM7ypvrEB9AU4Q2dn", + "b4+NWUAZhb2pQzEsWbKxvj91KX9pvLAlld4p2+On8lk2SG9NsQ3tcY9s2+5p8cC+ta0nNowWrk+hR42e", + "+/YBaRLurX2oQT0U653DwcxRb6JS3MdV657eYcxqPBU9VrW9sQ/Ip4GMrF90IlbXaP6uDuXtG+envbVv", + "hqiepX+eJ8mGblS6rpjm8qGwVyjVChnNbhw/V3UxtkoMnNJpZgrNHhxCVaF7OHc7atNM/JpXQyXM76lv", + "badjjSkJp3j9qi6x4bH+bA2KALritSZp9UalGvXjH6PSu6n3Eejxx2u3Roh2CQWFdAy1Qo+kelW9gFn4", + "0vSrP44EykaHvH9QzElcmaJqe+z598FaXHTTMBw89VjQp1AwcAMsg/aSP3Rr/Vjveq2H4XWsb/pDWp0f", + "K8Lt7f4wG8O3v/64xsYpXr+5JmKME6CfBx5gal9vlDiRKAMlpRmFXoUm/+lFFVQz8gXSU19Bql/qx+3R", + "qhT3iRuFZhHdUSZI8zfOymJIuuZVtcE5ECXiETG6weId4zDMOh304QMT1iRDF3DDaOpWyoDUbazrUvgZ", + "GlZXbJp3phaMS/RmaSEMHMH2jF2MBTpvqjGd1yKn3qoPtJ013eY4tztonUznUr3PkQ9C6yYjGWhMYo+7", + "4lT93JSYMijThaa6RnOQ1tldhR7UN/MzrXd1MmJzQt87k34WP9gNpn7oZVpcj875npGFaiUPNddePGIw", + "FfhMAFdL8CRuZyz5DOkHwCLQA71NAu7943FHmFJI/f5iIo7MKoYej7vM60ukY3u5oqC9cvrQ3BwMO8WR", + "JCbCGsr/+pqcfrGJ+vWn5VLOoXHcAUU7YGjJ5eJgDHHTV3RPRm/ojt3KrQTh0fCdXP893H4O9UBjc9ye", + "yux46OKt95KrkUs/+W7JDpjtOb7+4EshHx7jn0P3aCekc2d6sZcTI1RuBndmXZO1S7IxaIxmTuM8/Jyt", + "pdt0urTqtj8ntf0gKTmRNyeqT7BFT3JCXxXkv+DmVWkK5umCSRvAqd5ZtmTSfy90y8Up+wy0YQDWb9bX", + "Uf/+62nVywVgDvzHinF///W0KiKlRZN+2vSzkVJrDm4lhyPdZqA/45ZaSjudWR0rMk4sVjVZaIdMZ6GK", + "jMRePZVE6oLFb54foTo4gl69fxvF0WXlnI0O9p7tHegKDwVQXJDoMHqxd7B3EOlCjRvNhX2sqLevWbd/", + "Ud3n1ZBhwpftrMWS0IlPOqFBjSUQ1r45rd9ginSfC9zUb1J2edU3umL88ypjV0r5YVVxHmXlR++ZkPXl", + "YlHfLrblskDII5bePFixnuFrzHdtZFuPaqvq1vMHrJfkS6/wVU8ydy5XZZbdNOQstFWI8z1TNupgaLB6", + "9vuqUVPDa6rtM6c61Hjb702/tp7SVNvnLbmgXeg9ifDx092nOBJlnmN+Ex1GNaP0iqPKXvgYacBFn1SP", + "FtClAL6o6rfsX9wsaiWpwvUA9JS0q2rQHN28saf7zvAXWF/okRE5XZVnCp+6kpkpQEjoGlWc+EpQOht5", + "hiWm7FS1FnRxg+rKBIFAtLVOZ+LQCofHxmGnsNSTw6Aldvrk0WcZ0YbfDNTdmgpcd2p+a/Cg7m/QB91Z", + "VSHN/aTIQKCzabJvC6upBXz14skS6KnC4syBwyQaBrS7EenzOArYZAW1p6aMmeqvTxNRjYLVqtuo1+Qg", + "zBp6PYS5ciaFDHy3rI7178pi0NLsQonpnF0qJUHnbenInrxpxja3+mJk68HpxwRM+RJC16aGsYlM7+lp", + "IO6zLcy4DbgfXLy99FQ8dkFiCPII+LDVaqfavnTqzE61/WHHuDPM6cKsL8gun+3b6lRi/9bUqO+Azcf0", + "qipfCMtsW33fUMcw8IXQcbuvxrp6FtL22QATajfJAAcwsvTVu4pIfalZcpYtigxTMGTZc3ikjHmdHLSo", + "uDV3R9lPDajpFKXnTDIV2Fwe7ugkmi7D+DsowL7qkp7jSEeyraGl2aekJk1IBqmG7vMHrLQ8Z17VdqpT", + "T3Yq93Yhy3a35YwrDTHeMKvZf+N7bFwi7jcxlx1uxZ+rSMVjbsgqPhSyEz3S/c1qBYn+SIIhkf4Sjrih", + "yYYzSr7sHKDhB/PXANAPNSyhSzft6H0QrNp7ZwO648hxbu+1hbDd3qvbkOKRTvSnx+mcXbqnv+HKbg76", + "eI6SPS2HHBw8ghzylZW+63+UIwCGtrTENxx6JQ6miBX4t9I6vfR9EixmIrQlesZM1I4XpORrOBOBwkU1", + "XLArCikSG8zT/3OmgyaXWfsiYwnOLG800JUZYSKmCx0xtdSxgcIuix9W4NRiROGgqSw+5AGtI/DzRvF+", + "SzhAyHU+oxv6RvWhrfD29jteO/XIeup+Tnvn7VfPCrwm1MSIdS87logvQtq+uKfDYyCZ4OOnoXyAXsCJ", + "CGli6nWV9Gpj2B+MeDN/7LtfYx7H90lTHXQ7nIvHQFKvJGowmjo1UL9BqVX0GaaAdFux+W4aSkf1JYnt", + "kPQIQNK12mdiJ9W3kCsxtBs87EYx2xV2LEWGoGMqMY7hxdR8jHbI7U5VSQ/Lf3LrRYqa/4Zq9YLdVvrR", + "fv0JjP3buhjQ3T6vK5ENrblO9zup3rLVy+Zul6YE0U73S7vEWvCeqaorfds1DYgsKW3oytk2zedUzM6p", + "EzEtiLr3aRSNBcJZplUBc3W1nVZngmiQMaq/Yx3rapi1d4um9fXhVWY+BN/Dqc612+XW7CekBoNLr04v", + "fe8Ro6SD/PeoaM0MPfHReCTM3pD94b0W/dLoTyCIXpVz/YqSGbdFhvXsd5IU3ai5ybd1ssEGBIB+XqfX", + "Vl/MduowVvHyGF3ijKRYVoF084VE9WrjKfHv/Sa7bL4/T3/ee6fH0jZQqhO/HCz9MU8kf56YBkuFjFEE", + "ukH0AkvzfemOsFI/a6CcVh+F3zqqtAth17/19sjCznOFbQqgtkLC7mXdU7FRDQ2nJWY3bDSmgDuYtZ73", + "+0F3hwKuW/gxWEHSO93SYu+P7wvLaz7OVrUeEAi7CDN5PgAdJMaeTWSd6VKxLvG+GnHzVIy5V2mLfrPE", + "04yodh+kW2YobhVmnZnLqFM2v1Jc/Z5YsXH0ELg4364ZN/6d+Ehmv/Pl6P91R+g719pvfiY0IxRiVNIM", + "hKhfVaYBowijFFLrSkiyUkjgf7Y+hBXJJHCdYSsA82RjMm0TlpU51VU8lKlxSTD6DDcCJDJxpmq6hNEB", + "k6Na+Xxg2zeXtg5VAMSbd8waZr7DuJz3RnBsrnnlMcJz/pqkW0To6ml/2+4txUQ6qG72e/31qPae37e7", + "dHrz62KAdtP3t7bepnIDhFfVdEwAg9AVM4F3U6ZtfB8eV5PZIf4GP+oVDMHe6r9Wh2BvokGIuG2K8t0F", + "HAmm/m1TDcqkn9gqibawgDEuUa92LgecxojQFK7tO+0P2gwj5dQtHLid7N69MdWqJBxuR7WK8nzTeusq", + "SLPRu6+a7ydVKc5J8abLGSFa1+gMLZsUAtO6hNNXj9d+salw6OK1qXz8DbfRYaSJ2CqTJ7aEcFP8z4vh", + "1xtIPuuvhOl6cniLQoChEH5TVfDbHsMBCilePxrUO3UXJyNDitTd4oeaPUAT+ObQmLtF7BcztXj1knTL", + "HbPWJQcnpb5nZKeqounE6LYXrNRf6CqAL/SuIkIyfhM7Bgm7BG4OCf1GyxSds8dMucTd77Fls9QZxiJe", + "Lw1dtnqnsjBnvTTDXnZe0gbzI0mRToXLGT5zB3lm4t9kyHaWb4+QW0qOW4nXd/ud4rIhQsRTS7u6nFTZ", + "SHgdI8ZT4JAiCld10dnYIzGCnVdt4dGu7PwIQqTAcjNf4MyVBO6bVu480tb21cmepRA7oDDFZ7/t7/n7", + "u6uzdneWs/2H9n1d0/djVWm2Sh1VGLS/NFlxzo/GW976oepX/dip6lzdFelcV9c3R1C9jUUneU4LAAH8", + "kiSA/v7rqfaU2eG610/uPt39bwAAAP//", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/spec/openapi-dashboard.yml b/spec/openapi-dashboard.yml index b6e7c10996..28de04b573 100644 --- a/spec/openapi-dashboard.yml +++ b/spec/openapi-dashboard.yml @@ -1174,7 +1174,6 @@ components: type: string minLength: 1 maxLength: 63 - pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$" project_type: $ref: "#/components/schemas/AdminControlPlaneProjectType"