diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 953e9d2b67..06a5fcf2b3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,7 +185,13 @@ 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 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 ddd3a894b3..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/auth" + sharedauth "github.com/e2b-dev/infra/packages/auth/pkg/auth" ) const ( @@ -102,7 +102,7 @@ type Config struct { VolumesToken VolumesTokenConfig - AuthProvider auth.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[auth.ProviderConfig](): func(v string) (any, error) { - return auth.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 2cc0dd93fd..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/auth/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 e72fe53c28..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/auth/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 89% rename from packages/auth/pkg/auth/auth_store.go rename to packages/auth/internal/service/store.go index af31b11a49..74fac0b5e5 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,13 +8,14 @@ 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" "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 @@ -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/internal/token/admin.go b/packages/auth/internal/token/admin.go new file mode 100644 index 0000000000..04257aa13e --- /dev/null +++ b/packages/auth/internal/token/admin.go @@ -0,0 +1,69 @@ +package token + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" +) + +// adminJWTClockSkew is the leeway applied to time-based claims of admin +// service JWTs. +const adminJWTClockSkew = 30 * time.Second + +// AdminVerifier verifies admin service JWTs against one or more configured +// issuers and returns the first successful verification. +type AdminVerifier struct { + verifiers []*jwks.Verifier +} + +// NewAdminVerifier builds the verifier for the AdminJWTAuth security +// scheme from the same ProviderConfig shape used for AUTH_PROVIDER_CONFIG: +// 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() { + return nil, nil + } + + verifiers := make([]*jwks.Verifier, 0, len(normalized.JWT)) + for i, entry := range normalized.JWT { + verifier, err := jwks.NewVerifierFromIssuerJWKS(ctx, entry, httpClient, + jwks.WithParserOptions(jwt.WithLeeway(adminJWTClockSkew)), + ) + if err != nil { + return nil, fmt.Errorf("admin JWT jwt[%d]: %w", i, err) + } + verifiers = append(verifiers, verifier) + } + + return &AdminVerifier{verifiers: verifiers}, nil +} + +// Verify iterates over the configured issuers and returns the claims of the +// first successful verification. +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") + } + + 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/internal/token/admin_test.go b/packages/auth/internal/token/admin_test.go new file mode 100644 index 0000000000..5de89b94ed --- /dev/null +++ b/packages/auth/internal/token/admin_test.go @@ -0,0 +1,173 @@ +package token + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "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/internal/token/jwks" +) + +const ( + adminTestKeyID = "workspace-key" + adminTestAudience = "fx1" +) + +func newAdminTestVerifier(t *testing.T) (*AdminVerifier, ed25519.PrivateKey, string) { + t.Helper() + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + 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: server.URL, + Audiences: []string{adminTestAudience}, + }, + }}, + }, server.Client()) + require.NoError(t, err) + + return verifier, privateKey, server.URL +} + +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 TestAdminVerifier(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 TestAdminVerifierDisabled(t *testing.T) { + t.Parallel() + + verifier, err := NewAdminVerifier(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 TestAdminVerifierRejectsNonEdDSA(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) +} + +func TestAdminVerifierES256(t *testing.T) { + t.Parallel() + + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + 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, + Audiences: []string{adminTestAudience}, + }, + }}, + }, 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) +} + +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/auth/oidc/audience.go b/packages/auth/internal/token/jwks/audience.go similarity index 99% rename from packages/auth/pkg/auth/oidc/audience.go rename to packages/auth/internal/token/jwks/audience.go index 9da4e96da5..4c6a089a3f 100644 --- a/packages/auth/pkg/auth/oidc/audience.go +++ b/packages/auth/internal/token/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/internal/token/jwks/audience_test.go similarity index 99% rename from packages/auth/pkg/auth/oidc/audience_test.go rename to packages/auth/internal/token/jwks/audience_test.go index 090b176eeb..5e481cc72d 100644 --- a/packages/auth/pkg/auth/oidc/audience_test.go +++ b/packages/auth/internal/token/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/internal/token/jwks/config.go similarity index 97% rename from packages/auth/pkg/auth/oidc/config.go rename to packages/auth/internal/token/jwks/config.go index c6d7e64614..8e464bca0a 100644 --- a/packages/auth/pkg/auth/oidc/config.go +++ b/packages/auth/internal/token/jwks/config.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "encoding/json" @@ -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/auth/oidc/config_test.go b/packages/auth/internal/token/jwks/config_test.go similarity index 99% rename from packages/auth/pkg/auth/oidc/config_test.go rename to packages/auth/internal/token/jwks/config_test.go index 61a5bf14fa..1f3fe6bd8b 100644 --- a/packages/auth/pkg/auth/oidc/config_test.go +++ b/packages/auth/internal/token/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/internal/token/jwks/testserver.go similarity index 77% rename from packages/auth/pkg/auth/oidc/testserver.go rename to packages/auth/internal/token/jwks/testserver.go index d97ab8ff0b..f366ab266d 100644 --- a/packages/auth/pkg/auth/oidc/testserver.go +++ b/packages/auth/internal/token/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() @@ -34,19 +33,21 @@ func NewTestServer(t *testing.T, publicKey *rsa.PublicKey, keyID string, discove } }) - 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, KeyID: keyID, - Algorithm: string(jose.RS256), + Algorithm: string(algorithm), Use: "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/auth/oidc/oidc.go b/packages/auth/internal/token/jwks/verifier.go similarity index 61% rename from packages/auth/pkg/auth/oidc/oidc.go rename to packages/auth/internal/token/jwks/verifier.go index b6ba417b90..64a03c4488 100644 --- a/packages/auth/pkg/auth/oidc/oidc.go +++ b/packages/auth/internal/token/jwks/verifier.go @@ -1,4 +1,4 @@ -package oidc +package jwks import ( "context" @@ -9,36 +9,34 @@ import ( "net" "net/http" "net/url" + "slices" "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 +// httpTimeout is the timeout used for discovery and JWKS HTTP requests. +const httpTimeout = 10 * time.Second -// ErrIdentityNotFound is returned by Verify when the token is valid but no -// matching row exists in public.user_identities for (iss, sub). -var ErrIdentityNotFound = errors.New("oidc identity not found") +// Option customizes a Verifier beyond the issuer configuration. +type Option func(*Verifier) -// IdentityLookup resolves the internal user UUID for an OIDC identity -// (issuer + subject). Implementations should return ErrIdentityNotFound when -// no row matches the supplied pair. -type IdentityLookup interface { - GetUserIdentity(ctx context.Context, iss, sub string) (uuid.UUID, error) +// 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...) + } } -// Verifier verifies JWTs against a single OIDC issuer. +// 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 - identities IdentityLookup } // discoveryDocument is a minimal subset of the OIDC discovery document @@ -51,17 +49,10 @@ type discoveryDocument struct { // 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") - } - - if identities == nil { - return nil, errors.New("OIDC identity lookup is required") - } - - if entry.Issuer.URL == "" { - return nil, errors.New("issuer URL is required") +func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, options ...Option) (*Verifier, error) { + entry, err := validateConfig(entry, httpClient) + if err != nil { + return nil, err } discoveryURL := entry.discoveryURL() @@ -82,14 +73,50 @@ func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, ide 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: oidcHTTPTimeout, + HTTPTimeout: httpTimeout, RefreshInterval: entry.CacheDuration, }) if err != nil { - return nil, fmt.Errorf("create OIDC JWKS storage: %w", err) + return nil, fmt.Errorf("create JWKS storage: %w", err) + } + + if _, err := validMethodsFromStorage(ctx, storage); err != nil { + return nil, fmt.Errorf("validate JWKS signing algorithms: %w", err) } keyFunc, err := keyfunc.New(keyfunc.Options{ @@ -97,87 +124,95 @@ func NewVerifier(ctx context.Context, entry Config, httpClient *http.Client, ide 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), - }, - identities: identities, - }, nil -} - -// Verify parses and validates the supplied token string and resolves the -// internal user UUID for the (iss, sub) pair via the configured -// 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...) - 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") + return nil, fmt.Errorf("create JWKS keyfunc: %w", err) } - if err := validateAudience(claims, v.audiences); err != nil { - return uuid.Nil, nil, fmt.Errorf("failed to verify auth provider token: %w", err) + parserOptions := []jwt.ParserOption{ + jwt.WithExpirationRequired(), + jwt.WithIssuer(entry.Issuer.URL), } - iss, ok := claimString(claims, "iss") - if !ok { - return uuid.Nil, nil, errors.New("auth provider token is missing iss claim") + verifier := &Verifier{ + keyfunc: keyFunc, + storage: storage, + audiences: entry.Issuer.Audiences, + parserOptions: parserOptions, } - - sub, ok := claimString(claims, "sub") - if !ok { - return uuid.Nil, nil, errors.New("auth provider token is missing sub claim") + for _, option := range options { + option(verifier) } - userID, err := v.identities.GetUserIdentity(ctx, iss, sub) + return verifier, nil +} + +func validMethodsFromStorage(ctx context.Context, storage jwkset.Storage) ([]string, error) { + keys, err := storage.KeyReadAll(ctx) if err != nil { - return uuid.Nil, nil, fmt.Errorf("resolve user identity for auth provider token: %w", err) + 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 userID, claims, nil + return methods, nil } -func claimString(claims jwt.MapClaims, name string) (string, bool) { - value, ok := claims[name] - if !ok { - return "", false +// 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 || v.storage == nil { + return nil, errors.New("JWKS verifier is not configured") } - switch typed := value.(type) { - case string: - return typed, typed != "" - case []string: - if len(typed) == 0 { - return "", false - } + 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)) - return typed[0], typed[0] != "" - case []any: - if len(typed) == 0 { - return "", false - } - first, ok := typed[0].(string) + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { + return v.keyfunc.KeyfuncCtx(ctx)(token) + }, parserOptions...) + if err != nil { + return nil, fmt.Errorf("failed to verify token: %w", err) + } + if !token.Valid { + return nil, errors.New("token is invalid") + } - return first, ok && first != "" - default: - return "", false + 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, oidcHTTPTimeout) + fetchCtx, cancel := context.WithTimeout(ctx, httpTimeout) defer cancel() req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, discoveryURL, nil) 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 +} diff --git a/packages/auth/internal/token/oidc/oidc.go b/packages/auth/internal/token/oidc/oidc.go new file mode 100644 index 0000000000..0680ab574d --- /dev/null +++ b/packages/auth/internal/token/oidc/oidc.go @@ -0,0 +1,105 @@ +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/auth/internal/token/jwks" +) + +// ErrIdentityNotFound is returned by Verify when the token is valid but no +// matching row exists in public.user_identities for (iss, sub). +var ErrIdentityNotFound = errors.New("oidc identity not found") + +// IdentityLookup resolves the internal user UUID for an OIDC identity +// (issuer + subject). Implementations should return ErrIdentityNotFound when +// no row matches the supplied pair. +type IdentityLookup interface { + GetUserIdentity(ctx context.Context, iss, sub string) (uuid.UUID, error) +} + +// Verifier verifies JWTs against a single OIDC issuer and resolves the +// internal user for the token's identity. +type Verifier struct { + 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 jwks.Config, httpClient *http.Client, identities IdentityLookup) (*Verifier, error) { + if identities == nil { + return nil, errors.New("OIDC identity lookup is required") + } + + tokens, err := jwks.NewVerifier(ctx, entry, httpClient) + if err != nil { + return nil, err + } + + return &Verifier{ + tokens: tokens, + identities: identities, + }, nil +} + +// Verify parses and validates the supplied token string and resolves the +// internal user UUID for the (iss, sub) pair via the configured +// 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, err := v.tokens.Verify(ctx, tokenString) + if err != nil { + return uuid.Nil, nil, fmt.Errorf("failed to verify auth provider token: %w", err) + } + + iss, ok := claimString(claims, "iss") + if !ok { + return uuid.Nil, nil, errors.New("auth provider token is missing iss claim") + } + + sub, ok := claimString(claims, "sub") + if !ok { + return uuid.Nil, nil, errors.New("auth provider token is missing sub claim") + } + + userID, err := v.identities.GetUserIdentity(ctx, iss, sub) + if err != nil { + return uuid.Nil, nil, fmt.Errorf("resolve user identity for auth provider token: %w", err) + } + + return userID, claims, nil +} + +func claimString(claims jwt.MapClaims, name string) (string, bool) { + value, ok := claims[name] + if !ok { + return "", false + } + + switch typed := value.(type) { + case string: + return typed, typed != "" + case []string: + if len(typed) == 0 { + return "", false + } + + return typed[0], typed[0] != "" + case []any: + if len(typed) == 0 { + return "", false + } + first, ok := typed[0].(string) + + return first, ok && first != "" + default: + return "", false + } +} diff --git a/packages/auth/pkg/auth/oidc/oidc_test.go b/packages/auth/internal/token/oidc/oidc_test.go similarity index 83% rename from packages/auth/pkg/auth/oidc/oidc_test.go rename to packages/auth/internal/token/oidc/oidc_test.go index 254c8e7495..23afa28755 100644 --- a/packages/auth/pkg/auth/oidc/oidc_test.go +++ b/packages/auth/internal/token/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/internal/token/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/verifier.go b/packages/auth/internal/token/provider.go similarity index 72% rename from packages/auth/pkg/auth/verifier.go rename to packages/auth/internal/token/provider.go index b2a7e8c6a7..e3d5873fd4 100644 --- a/packages/auth/pkg/auth/verifier.go +++ b/packages/auth/internal/token/provider.go @@ -1,4 +1,4 @@ -package auth +package token import ( "context" @@ -9,12 +9,13 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" - "github.com/e2b-dev/infra/packages/auth/pkg/auth/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. 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() } @@ -44,24 +45,25 @@ 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. -type Verifier struct { +// ProviderVerifier aggregates one or more OIDC JWT verification strategies and +// returns the first that succeeds. +type ProviderVerifier struct { strategies []strategy } -// NewVerifier constructs a *Verifier 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. -func NewVerifier(ctx context.Context, config ProviderConfig, oidcHTTPClient *http.Client, identities oidc.IdentityLookup) (*Verifier, error) { +// 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 { return nil, err @@ -88,14 +90,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/internal/token/provider_config_parse.go b/packages/auth/internal/token/provider_config_parse.go new file mode 100644 index 0000000000..c326026bbe --- /dev/null +++ b/packages/auth/internal/token/provider_config_parse.go @@ -0,0 +1,26 @@ +package token + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ParseProviderConfig parses a provider-config env value (AUTH_PROVIDER_CONFIG, +// 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. +func ParseProviderConfig(v string) (ProviderConfig, error) { + var config ProviderConfig + trimmed := strings.TrimSpace(v) + if trimmed == "" || trimmed == "null" { + return config, nil + } + + if err := json.Unmarshal([]byte(v), &config); err != nil { + return ProviderConfig{}, fmt.Errorf("parse auth provider config: %w", err) + } + + return config, nil +} diff --git a/packages/auth/pkg/auth/verifier_test.go b/packages/auth/internal/token/provider_test.go similarity index 83% rename from packages/auth/pkg/auth/verifier_test.go rename to packages/auth/internal/token/provider_test.go index e9647c9512..3447a0e48a 100644 --- a/packages/auth/pkg/auth/verifier_test.go +++ b/packages/auth/internal/token/provider_test.go @@ -1,4 +1,4 @@ -package auth +package token import ( "context" @@ -11,11 +11,13 @@ 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/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" @@ -64,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) } @@ -76,17 +78,17 @@ 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" jwksUserID := uuid.New() lookup.set(testIssuerURL, jwksSub, jwksUserID) - verifier, err := NewVerifier(t.Context(), ProviderConfig{ - JWT: []oidc.Config{ + verifier, err := NewProviderVerifier(t.Context(), ProviderConfig{ + JWT: []jwks.Config{ { - Issuer: oidc.Issuer{ + Issuer: jwks.Issuer{ URL: testIssuerURL, DiscoveryURL: server.URL + "/.well-known/openid-configuration", Audiences: []string{"dashboard-api"}, @@ -127,25 +129,25 @@ 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" userID := uuid.New() lookup.set(issuer2URL, tokenSub, userID) - verifier, err := NewVerifier(t.Context(), ProviderConfig{ - JWT: []oidc.Config{ + verifier, err := NewProviderVerifier(t.Context(), ProviderConfig{ + 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/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 98a1819516..1ff351a742 100644 --- a/packages/auth/pkg/auth/middleware.go +++ b/packages/auth/pkg/auth/middleware.go @@ -2,247 +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" + 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 -} +type Authenticator = internalauthmiddleware.Authenticator -// 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 +func NewApiKeyAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewApiKeyAuthenticator(validationFunc) } -// 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 +func NewAccessTokenAuthenticator(validationFunc func(context.Context, *gin.Context, string) (uuid.UUID, *APIError)) Authenticator { + return internalauthmiddleware.NewAccessTokenAuthenticator(validationFunc) } -// 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 +func NewAuthProviderBearerAuthenticator(validationFunc func(context.Context, *gin.Context, string) (uuid.UUID, *APIError)) Authenticator { + return internalauthmiddleware.NewAuthProviderBearerAuthenticator(validationFunc) } -// 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) - } - - 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 NewAuthProviderTeamAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewAuthProviderTeamAuthenticator(validationFunc) } -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 - } +func NewAdminJWTAuthenticator(verifier *AdminVerifier) Authenticator { + return internalauthmiddleware.NewAdminJWTAuthenticator(verifier) } -// 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.", - } -} - -// 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`.", - } -} - -// 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.", - } -} - -// 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.", - } -} - -// NewAdminApiKeyAuthenticator creates an authenticator for the AdminApiKeyAuth security scheme (X-Admin-Token header). func NewAdminApiKeyAuthenticator(adminToken string) Authenticator { - return newAdminApiKeyAuthenticator("AdminApiKeyAuth", adminToken) + return internalauthmiddleware.NewAdminApiKeyAuthenticator(adminToken) } -func newAdminApiKeyAuthenticator(schemeName, adminToken string) Authenticator { - return &commonAuthenticator[struct{}]{ - schemeName: schemeName, - header: headerKey{ - name: HeaderAdminToken, - }, - validationFunc: adminValidationFunction(adminToken), - errorMessage: "Invalid Access token.", - } +func NewAdminTeamAuthenticator(validationFunc func(context.Context, *gin.Context, string) (*types.Team, *APIError)) Authenticator { + return internalauthmiddleware.NewAdminTeamAuthenticator(validationFunc) } -// 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: 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) - } +func CreateAuthenticationFunc(authenticators []Authenticator, preAuthHook func(*gin.Context)) openapi3filter.AuthenticationFunc { + return internalauthmiddleware.CreateAuthenticationFunc(authenticators, preAuthHook) } diff --git a/packages/auth/pkg/auth/provider_config_parse.go b/packages/auth/pkg/auth/provider_config_parse.go deleted file mode 100644 index d8086a753e..0000000000 --- a/packages/auth/pkg/auth/provider_config_parse.go +++ /dev/null @@ -1,25 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "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. -func ParseProviderConfig(v string) (ProviderConfig, error) { - var config ProviderConfig - trimmed := strings.TrimSpace(v) - if trimmed == "" || trimmed == "null" { - return config, nil - } - - if err := json.Unmarshal([]byte(v), &config); err != nil { - 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..e8cb8445b0 100644 --- a/packages/auth/pkg/auth/service.go +++ b/packages/auth/pkg/auth/service.go @@ -2,59 +2,18 @@ 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/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 *Verifier -} - -// 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, @@ -62,226 +21,5 @@ func NewAuthService( 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 := NewVerifier(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 *Verifier, 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/api/api.gen.go b/packages/dashboard-api/internal/api/api.gen.go index fbc8000a0b..462e198320 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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", + "kiSA/v7rqfaU2eG610/uPt39bwAAAP//", } // 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..f6d9b079a1 100644 --- a/packages/dashboard-api/internal/cfg/model.go +++ b/packages/dashboard-api/internal/cfg/model.go @@ -6,16 +6,17 @@ import ( "github.com/caarlos0/env/v11" - "github.com/e2b-dev/infra/packages/auth/pkg/auth" + 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 auth.ProviderConfig `env:"AUTH_PROVIDER_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"` @@ -74,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[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 7faeeedb5c..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/auth/oidc" + 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, oidc.AudienceMatchAny, entry.Issuer.AudienceMatchPolicy) + require.Equal(t, sharedauth.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 TestParseAdminAuthProviderConfig(t *testing.T) { + setBaseEnv(t) + 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.AdminAuthProvider.JWT, 1) + require.Equal(t, "https://workspace.example.com", config.AdminAuthProvider.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..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/auth/oidc" + 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 []oidc.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 []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 []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 d87f62f00d..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/auth/oidc" + 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", []oidc.Config{ - {Issuer: oidc.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", []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", []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", []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", []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", []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", []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 5f19023e00..c75d530c79 100644 --- a/packages/dashboard-api/main.go +++ b/packages/dashboard-api/main.go @@ -247,9 +247,21 @@ func run() int { } swagger.Servers = nil + adminVerifier, err := sharedauth.NewAdminVerifier(ctx, config.AdminAuthProvider, authClient) + if err != nil { + l.Error(ctx, "initializing admin JWT verifier", zap.Error(err)) + + return 1 + } + + if adminVerifier == nil { + l.Warn(ctx, "ADMIN_AUTH_PROVIDER_CONFIG is not configured; /admin/v1 endpoints will reject requests with 401") + } + authenticationFunc := sharedauth.CreateAuthenticationFunc( []sharedauth.Authenticator{ sharedauth.NewAdminApiKeyAuthenticator(config.AdminToken), + sharedauth.NewAdminJWTAuthenticator(adminVerifier), sharedauth.NewAuthProviderBearerAuthenticator(apiStore.GetUserIDFromAuthProviderToken), sharedauth.NewAuthProviderTeamAuthenticator(apiStore.GetTeamFromAuthProviderToken), }, @@ -297,7 +309,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 +380,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..28de04b573 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,89 @@ 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 + 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 +1874,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"