diff --git a/api-contracts/openapi/openapi.yaml b/api-contracts/openapi/openapi.yaml index 8cfe9bbdd6..79034ee883 100644 --- a/api-contracts/openapi/openapi.yaml +++ b/api-contracts/openapi/openapi.yaml @@ -103,6 +103,10 @@ paths: $ref: "./paths/user/user.yaml#/oauth-start-github" /api/v1/users/github/callback: $ref: "./paths/user/user.yaml#/oauth-callback-github" + /api/v1/users/oidc/start: + $ref: "./paths/user/user.yaml#/oauth-start-oidc" + /api/v1/users/oidc/callback: + $ref: "./paths/user/user.yaml#/oauth-callback-oidc" /api/v1/tenants/{tenant}/slack/start: $ref: "./paths/user/user.yaml#/oauth-start-slack" /api/v1/users/slack/callback: diff --git a/api-contracts/openapi/paths/user/user.yaml b/api-contracts/openapi/paths/user/user.yaml index 8832267ab3..a6690b3d99 100644 --- a/api-contracts/openapi/paths/user/user.yaml +++ b/api-contracts/openapi/paths/user/user.yaml @@ -228,6 +228,36 @@ oauth-callback-github: summary: Complete OAuth flow tags: - User +oauth-start-oidc: + get: + description: Starts the OIDC OAuth flow + operationId: user:update:oidc-oauth-start + responses: + "302": + description: Successfully started the OAuth flow + headers: + location: + schema: + type: string + security: [] + summary: Start OIDC OAuth flow + tags: + - User +oauth-callback-oidc: + get: + description: Completes the OIDC OAuth flow + operationId: user:update:oidc-oauth-callback + responses: + "302": + description: Successfully completed the OAuth flow + headers: + location: + schema: + type: string + security: [] + summary: Complete OIDC OAuth flow + tags: + - User oauth-start-slack: get: x-resources: ["tenant"] diff --git a/api/v1/server/authz/rbac.yaml b/api/v1/server/authz/rbac.yaml index 481af2e7fb..05bd1fb20b 100644 --- a/api/v1/server/authz/rbac.yaml +++ b/api/v1/server/authz/rbac.yaml @@ -111,6 +111,8 @@ roles: - TenantResourcePolicyGet - UserUpdateSlackOauthStart - UserUpdateGoogleOauthStart + - UserUpdateOidcOauthStart + - UserUpdateOidcOauthCallback - SlackWebhookDelete - V1CelDebug - V1WorkflowRunGetTimings diff --git a/api/v1/server/handlers/metadata/get.go b/api/v1/server/handlers/metadata/get.go index 97c770763e..33cef7832e 100644 --- a/api/v1/server/handlers/metadata/get.go +++ b/api/v1/server/handlers/metadata/get.go @@ -22,6 +22,10 @@ func (u *MetadataService) MetadataGet(ctx echo.Context, request gen.MetadataGetR authTypes = append(authTypes, "github") } + if u.config.Auth.ConfigFile.OIDC.Enabled { + authTypes = append(authTypes, "oidc") + } + pylonAppID := u.config.Pylon.AppID var posthogConfig *gen.APIMetaPosthog diff --git a/api/v1/server/handlers/users/oidc_oauth_callback.go b/api/v1/server/handlers/users/oidc_oauth_callback.go new file mode 100644 index 0000000000..5b55e1a2bf --- /dev/null +++ b/api/v1/server/handlers/users/oidc_oauth_callback.go @@ -0,0 +1,216 @@ +package users + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/jackc/pgx/v5" + "github.com/labstack/echo/v4" + "golang.org/x/oauth2" + + "github.com/hatchet-dev/hatchet/api/v1/server/authn" + "github.com/hatchet-dev/hatchet/api/v1/server/middleware/redirect" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" + "github.com/hatchet-dev/hatchet/pkg/analytics" + "github.com/hatchet-dev/hatchet/pkg/config/server" + v1 "github.com/hatchet-dev/hatchet/pkg/repository" + "github.com/hatchet-dev/hatchet/pkg/repository/sqlcv1" +) + +// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow. +func (u *UserService) UserUpdateOidcOauthCallback(ctx echo.Context, _ gen.UserUpdateOidcOauthCallbackRequestObject) (gen.UserUpdateOidcOauthCallbackResponseObject, error) { + if u.config.Auth.OIDCOAuthConfig == nil || u.config.Auth.OIDCProvider == nil { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, nil, "OIDC authentication is not configured.") + } + + isValid, _, err := authn.NewSessionHelpers(u.config.SessionStore).ValidateOAuthState(ctx, "oidc") + + if err != nil || !isValid { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Could not log in. Please try again and make sure cookies are enabled.") + } + + token, err := u.config.Auth.OIDCOAuthConfig.Exchange(ctx.Request().Context(), ctx.Request().URL.Query().Get("code")) + + if err != nil { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Forbidden") + } + + if !token.Valid() { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, fmt.Errorf("invalid token"), "Forbidden") + } + + user, err := u.upsertOIDCUserFromToken(ctx.Request().Context(), u.config, token) + + if err != nil { + if errors.Is(err, ErrNotInRestrictedDomain) { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Email is not in the restricted domain group.") + } + + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.") + } + + err = authn.NewSessionHelpers(u.config.SessionStore).SaveAuthenticated(ctx, user) + + if err != nil { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.") + } + + analyticsCtx := context.WithValue(ctx.Request().Context(), analytics.UserIDKey, user.ID) + analyticsCtx = context.WithValue(analyticsCtx, analytics.SourceKey, analytics.SourceUI) + u.config.Analytics.Enqueue( + analyticsCtx, + analytics.User, analytics.Login, + user.ID.String(), + map[string]interface{}{"provider": "oidc"}, + ) + + return gen.UserUpdateOidcOauthCallback302Response{ + Headers: gen.UserUpdateOidcOauthCallback302ResponseHeaders{ + Location: u.config.Runtime.ServerURL, + }, + }, nil +} + +func (u *UserService) upsertOIDCUserFromToken(ctx context.Context, config *server.ServerConfig, tok *oauth2.Token) (*sqlcv1.User, error) { + claims, err := getOIDCClaimsFromToken(ctx, config, tok) + if err != nil { + return nil, err + } + + if err := u.checkUserRestrictionsForEmail(config, claims.Email); err != nil { + return nil, err + } + + // Consistent with the Google/GitHub handlers, an unverified email is not + // rejected here — the user is created and the application's verify-email gate + // handles it. SetEmailVerified (SERVER_AUTH_SET_EMAIL_VERIFIED) auto-verifies, + // the same instance-wide setting other providers honor. This also covers + // providers that don't emit email_verified at all (e.g. Microsoft Entra ID). + emailVerified := claims.EmailVerified || config.Auth.ConfigFile.SetEmailVerified + + expiresAt := tok.Expiry + + accessTokenEncrypted, err := config.Encryption.Encrypt([]byte(tok.AccessToken), "oidc_access_token") + if err != nil { + return nil, fmt.Errorf("failed to encrypt access token: %s", err.Error()) + } + + refreshToken := tok.RefreshToken + if refreshToken == "" { + refreshToken = "none" + } + + refreshTokenEncrypted, err := config.Encryption.Encrypt([]byte(refreshToken), "oidc_refresh_token") + if err != nil { + return nil, fmt.Errorf("failed to encrypt refresh token: %s", err.Error()) + } + + oauthOpts := &v1.OAuthOpts{ + Provider: "oidc", + ProviderUserId: claims.Sub, + AccessToken: accessTokenEncrypted, + RefreshToken: refreshTokenEncrypted, + ExpiresAt: &expiresAt, + } + + user, err := u.config.V1.User().GetUserByEmail(ctx, claims.Email) + + switch err { + case nil: + user, err = u.config.V1.User().UpdateUser(ctx, user.ID, &v1.UpdateUserOpts{ + EmailVerified: v1.BoolPtr(emailVerified), + Name: v1.StringPtr(claims.Name), + OAuth: oauthOpts, + }) + + if err != nil { + return nil, fmt.Errorf("failed to update user: %s", err.Error()) + } + case pgx.ErrNoRows: + if !config.Runtime.AllowSignup { + return nil, fmt.Errorf("user signup is disabled") + } + + user, err = u.config.V1.User().CreateUser(ctx, &v1.CreateUserOpts{ + Email: claims.Email, + EmailVerified: v1.BoolPtr(emailVerified), + Name: v1.StringPtr(claims.Name), + OAuth: oauthOpts, + }) + + if err != nil { + return nil, fmt.Errorf("failed to create user: %s", err.Error()) + } + default: + return nil, fmt.Errorf("failed to get user: %s", err.Error()) + } + + return user, nil +} + +type oidcClaims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Sub string `json:"sub"` +} + +func getOIDCClaimsFromToken(ctx context.Context, config *server.ServerConfig, tok *oauth2.Token) (*oidcClaims, error) { + verifier := config.Auth.OIDCProvider.Verifier(&oidc.Config{ + ClientID: config.Auth.OIDCOAuthConfig.ClientID, + }) + + rawIDToken, ok := tok.Extra("id_token").(string) + if !ok { + return nil, fmt.Errorf("no id_token in token response") + } + + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("failed to verify ID token: %s", err.Error()) + } + + claims := &oidcClaims{} + if err := idToken.Claims(claims); err != nil { + return nil, fmt.Errorf("failed to parse ID token claims: %s", err.Error()) + } + + // Fall back to UserInfo endpoint when the ID token is missing optional claims. + // The OIDC spec does not require providers to include email/name in the ID token. + if claims.Email == "" || claims.Name == "" || !claims.EmailVerified { + userInfo, uiErr := config.Auth.OIDCProvider.UserInfo(ctx, oauth2.StaticTokenSource(tok)) + if uiErr == nil { + uiClaims := &oidcClaims{} + if err := userInfo.Claims(uiClaims); err == nil { + // Per OIDC spec, the UserInfo sub must match the ID token sub. + if uiClaims.Sub != "" && claims.Sub != "" && uiClaims.Sub != claims.Sub { + return nil, fmt.Errorf("OIDC UserInfo sub claim (%s) does not match ID token sub claim (%s)", uiClaims.Sub, claims.Sub) + } + if claims.Email == "" { + claims.Email = uiClaims.Email + } + if claims.Name == "" { + claims.Name = uiClaims.Name + } + if !claims.EmailVerified && uiClaims.EmailVerified { + claims.EmailVerified = true + } + if claims.Sub == "" { + claims.Sub = uiClaims.Sub + } + } + } + } + + if claims.Email == "" { + return nil, fmt.Errorf("OIDC provider did not return an email claim") + } + + if claims.Sub == "" { + return nil, fmt.Errorf("OIDC provider did not return a sub claim") + } + + return claims, nil +} diff --git a/api/v1/server/handlers/users/oidc_oauth_callback_integration_test.go b/api/v1/server/handlers/users/oidc_oauth_callback_integration_test.go new file mode 100644 index 0000000000..8cbb983813 --- /dev/null +++ b/api/v1/server/handlers/users/oidc_oauth_callback_integration_test.go @@ -0,0 +1,151 @@ +//go:build integration + +package users + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/rs/zerolog" + + "github.com/hatchet-dev/hatchet/internal/testutils" + "github.com/hatchet-dev/hatchet/pkg/config/database" + "github.com/hatchet-dev/hatchet/pkg/config/server" + "github.com/hatchet-dev/hatchet/pkg/encryption" + "github.com/hatchet-dev/hatchet/pkg/random" +) + +// newOIDCTestConfig builds a ServerConfig wired to the in-process mock issuer + +// the real database layer. SetEmailVerified defaults to false (the production +// default), so unverified emails are created but left unverified. +func newOIDCTestConfig(t *testing.T, dbConf *database.Layer) (*server.ServerConfig, *mockOIDC) { + t.Helper() + + masterKey, privJWT, pubJWT, _, err := encryption.GenerateLocalKeys() + if err != nil { + t.Fatalf("generate local keys: %v", err) + } + enc, err := encryption.NewLocalEncryption(masterKey, privJWT, pubJWT) + if err != nil { + t.Fatalf("new local encryption: %v", err) + } + + m := newMockOIDC(t) + logger := zerolog.Nop() + cfg := &server.ServerConfig{ + Layer: dbConf, + Encryption: enc, + Logger: &logger, + Runtime: server.ConfigFileRuntime{AllowSignup: true, ServerURL: "http://localhost:8080"}, + Auth: server.AuthConfig{ + OIDCProvider: m.provider, + OIDCOAuthConfig: m.oauthCfg, + }, + } + return cfg, m +} + +func uniqueEmail(t *testing.T, prefix string) string { + t.Helper() + suffix, err := random.Generate(8) + if err != nil { + t.Fatalf("random suffix: %v", err) + } + // CreateUser stores emails lowercased, so use a lowercase address. + return strings.ToLower(prefix + "-" + suffix + "@example.com") +} + +// TestUpsertOIDCUserFromToken exercises the full OIDC upsert against a real +// database: verify the ID token, then create (and on a second login, update) the +// Hatchet user + OAuth link. This is the path where OAuthOpts.Provider="oidc" +// must pass repository validation — a regression here is exactly the bug that +// shipped in the original PR (provider "oidc" rejected by the oneof validator). +func TestUpsertOIDCUserFromToken(t *testing.T) { + // InitDataLayer requires a message-queue URL to be present (it is not + // connected to in this DB-only test). Mirrors the other integration tests. + _ = os.Setenv("SERVER_MSGQUEUE_RABBITMQ_URL", "amqp://user:password@localhost:5672/") + + testutils.RunTestWithDatabase(t, func(dbConf *database.Layer) error { + cfg, m := newOIDCTestConfig(t, dbConf) + us := NewUserService(cfg) + ctx := context.Background() + + email := uniqueEmail(t, "oidc") + + // First login: user does not exist yet -> CreateUser with provider="oidc". + tok := m.token(t, idTokenClaims{ + Subject: "kc-sub-abc", Email: email, EmailVerified: true, Name: "Alice Example", + }) + user, err := us.upsertOIDCUserFromToken(ctx, cfg, tok) + if err != nil { + t.Fatalf("create via OIDC failed: %v", err) + } + if user.Email != email { + t.Fatalf("created user email = %q, want %q", user.Email, email) + } + if !user.EmailVerified { + t.Fatal("created user should be email-verified") + } + + // Second login for the same email: should update the existing user, not + // create a duplicate. + tok2 := m.token(t, idTokenClaims{ + Subject: "kc-sub-abc", Email: email, EmailVerified: true, Name: "Alice Renamed", + }) + user2, err := us.upsertOIDCUserFromToken(ctx, cfg, tok2) + if err != nil { + t.Fatalf("update via OIDC failed: %v", err) + } + if user2.ID != user.ID { + t.Fatalf("second login created a new user (%s) instead of updating (%s)", user2.ID, user.ID) + } + + return nil + }) +} + +// TestUpsertOIDCUserFromToken_UnverifiedEmail covers a provider that does not +// assert a verified email (e.g. Microsoft Entra ID). The login is never rejected +// (matching Google/GitHub); SetEmailVerified controls whether the stored user is +// verified or left for the application's verify-email gate. +func TestUpsertOIDCUserFromToken_UnverifiedEmail(t *testing.T) { + _ = os.Setenv("SERVER_MSGQUEUE_RABBITMQ_URL", "amqp://user:password@localhost:5672/") + + testutils.RunTestWithDatabase(t, func(dbConf *database.Layer) error { + cfg, m := newOIDCTestConfig(t, dbConf) + us := NewUserService(cfg) + ctx := context.Background() + + // SetEmailVerified=false (default): the user is created but left + // unverified (the verify-email gate then applies) — not rejected. + gatedEmail := uniqueEmail(t, "entra-gated") + gatedTok := m.token(t, idTokenClaims{ + Subject: "entra-sub-1", Email: gatedEmail, EmailVerified: false, Name: "Bob Example", + }) + gated, err := us.upsertOIDCUserFromToken(ctx, cfg, gatedTok) + if err != nil { + t.Fatalf("unverified email should be created, not rejected: %v", err) + } + if gated.EmailVerified { + t.Fatal("with SetEmailVerified=false an unverified email should stay unverified") + } + + // SetEmailVerified=true: the same flow auto-verifies the user. + cfg.Auth.ConfigFile.SetEmailVerified = true + autoEmail := uniqueEmail(t, "entra-auto") + autoTok := m.token(t, idTokenClaims{ + Subject: "entra-sub-2", Email: autoEmail, EmailVerified: false, Name: "Carol Example", + }) + auto, err := us.upsertOIDCUserFromToken(ctx, cfg, autoTok) + if err != nil { + t.Fatalf("upsert with SetEmailVerified=true failed: %v", err) + } + if !auto.EmailVerified { + t.Fatal("with SetEmailVerified=true the user should be stored as verified") + } + + return nil + }) +} diff --git a/api/v1/server/handlers/users/oidc_oauth_callback_test.go b/api/v1/server/handlers/users/oidc_oauth_callback_test.go new file mode 100644 index 0000000000..aad4122398 --- /dev/null +++ b/api/v1/server/handlers/users/oidc_oauth_callback_test.go @@ -0,0 +1,181 @@ +package users + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-jose/go-jose/v4" + "golang.org/x/oauth2" + + "github.com/hatchet-dev/hatchet/pkg/config/server" +) + +const testOIDCClientID = "hatchet-test" + +// mockOIDC is an in-process OIDC issuer for tests: it serves the discovery +// document + JWKS and mints signed ID tokens, so the OIDC verification and user +// upsert paths can be exercised without a real IdP (Keycloak/Dex). This keeps +// the test deterministic and container-free, matching the project's preference +// for in-process integration tests. +type mockOIDC struct { + server *httptest.Server + signer jose.Signer + provider *oidc.Provider + oauthCfg *oauth2.Config +} + +func newMockOIDC(t *testing.T) *mockOIDC { + t.Helper() + + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key: %v", err) + } + + const keyID = "test-key-1" + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: jose.JSONWebKey{Key: priv, KeyID: keyID}}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + t.Fatalf("new signer: %v", err) + } + + jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: priv.Public(), KeyID: keyID, Algorithm: "RS256", Use: "sig", + }}} + + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/auth", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/keys", + "userinfo_endpoint": srv.URL + "/userinfo", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(jwks) + }) + + provider, err := oidc.NewProvider(context.Background(), srv.URL) + if err != nil { + t.Fatalf("oidc discovery: %v", err) + } + + return &mockOIDC{ + server: srv, + signer: signer, + provider: provider, + oauthCfg: &oauth2.Config{ClientID: testOIDCClientID}, + } +} + +// idTokenClaims are the claims the mock signs into an ID token. Zero values for +// iss/aud/exp/iat are filled with sensible defaults in token(). +type idTokenClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expiry int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name,omitempty"` +} + +// token mints an *oauth2.Token whose "id_token" extra is a signed JWT carrying +// the given claims (audience defaults to the test client id). +func (m *mockOIDC) token(t *testing.T, c idTokenClaims) *oauth2.Token { + t.Helper() + + if c.Issuer == "" { + c.Issuer = m.server.URL + } + if c.Audience == "" { + c.Audience = testOIDCClientID + } + if c.Expiry == 0 { + c.Expiry = time.Now().Add(time.Hour).Unix() + } + if c.IssuedAt == 0 { + c.IssuedAt = time.Now().Unix() + } + + payload, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + jws, err := m.signer.Sign(payload) + if err != nil { + t.Fatalf("sign id token: %v", err) + } + raw, err := jws.CompactSerialize() + if err != nil { + t.Fatalf("serialize id token: %v", err) + } + + return (&oauth2.Token{AccessToken: "test-access-token", TokenType: "Bearer"}). + WithExtra(map[string]any{"id_token": raw}) +} + +// TestGetOIDCClaimsFromToken covers the security-critical verification path: +// the ID token is checked against the issuer's JWKS and audience before any +// claims are trusted. +func TestGetOIDCClaimsFromToken(t *testing.T) { + m := newMockOIDC(t) + cfg := &server.ServerConfig{ + Auth: server.AuthConfig{ + OIDCProvider: m.provider, + OIDCOAuthConfig: m.oauthCfg, + }, + } + ctx := context.Background() + + t.Run("valid token returns verified claims", func(t *testing.T) { + tok := m.token(t, idTokenClaims{ + Subject: "kc-sub-123", Email: "alice@example.com", EmailVerified: true, Name: "Alice", + }) + + claims, err := getOIDCClaimsFromToken(ctx, cfg, tok) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims.Email != "alice@example.com" || claims.Sub != "kc-sub-123" || + !claims.EmailVerified || claims.Name != "Alice" { + t.Fatalf("unexpected claims: %+v", claims) + } + }) + + t.Run("token for a different audience is rejected", func(t *testing.T) { + tok := m.token(t, idTokenClaims{ + Subject: "x", Audience: "some-other-client", Email: "a@b.c", EmailVerified: true, + }) + if _, err := getOIDCClaimsFromToken(ctx, cfg, tok); err == nil { + t.Fatal("expected verification error for wrong audience, got nil") + } + }) + + t.Run("response without an id_token is rejected", func(t *testing.T) { + tok := &oauth2.Token{AccessToken: "no-id-token"} + if _, err := getOIDCClaimsFromToken(ctx, cfg, tok); err == nil { + t.Fatal("expected error for missing id_token, got nil") + } + }) +} diff --git a/api/v1/server/handlers/users/oidc_oauth_start.go b/api/v1/server/handlers/users/oidc_oauth_start.go new file mode 100644 index 0000000000..74032ebeac --- /dev/null +++ b/api/v1/server/handlers/users/oidc_oauth_start.go @@ -0,0 +1,30 @@ +package users + +import ( + "github.com/labstack/echo/v4" + + "github.com/hatchet-dev/hatchet/api/v1/server/authn" + "github.com/hatchet-dev/hatchet/api/v1/server/middleware/redirect" + "github.com/hatchet-dev/hatchet/api/v1/server/oas/gen" +) + +// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow. +func (u *UserService) UserUpdateOidcOauthStart(ctx echo.Context, _ gen.UserUpdateOidcOauthStartRequestObject) (gen.UserUpdateOidcOauthStartResponseObject, error) { + if u.config.Auth.OIDCOAuthConfig == nil { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, nil, "OIDC authentication is not configured.") + } + + state, err := authn.NewSessionHelpers(u.config.SessionStore).SaveOAuthState(ctx, "oidc") + + if err != nil { + return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Could not get cookie. Please make sure cookies are enabled.") + } + + url := u.config.Auth.OIDCOAuthConfig.AuthCodeURL(state) + + return gen.UserUpdateOidcOauthStart302Response{ + Headers: gen.UserUpdateOidcOauthStart302ResponseHeaders{ + Location: url, + }, + }, nil +} diff --git a/api/v1/server/oas/gen/openapi.gen.go b/api/v1/server/oas/gen/openapi.gen.go index 0322477c30..cfc23eb566 100644 --- a/api/v1/server/oas/gen/openapi.gen.go +++ b/api/v1/server/oas/gen/openapi.gen.go @@ -3862,6 +3862,12 @@ type ServerInterface interface { // List tenant memberships // (GET /api/v1/users/memberships) TenantMembershipsList(ctx echo.Context) error + // Complete OIDC OAuth flow + // (GET /api/v1/users/oidc/callback) + UserUpdateOidcOauthCallback(ctx echo.Context) error + // Start OIDC OAuth flow + // (GET /api/v1/users/oidc/start) + UserUpdateOidcOauthStart(ctx echo.Context) error // Change user password // (POST /api/v1/users/password) UserUpdatePassword(ctx echo.Context) error @@ -7748,6 +7754,24 @@ func (w *ServerInterfaceWrapper) TenantMembershipsList(ctx echo.Context) error { return err } +// UserUpdateOidcOauthCallback converts echo context to params. +func (w *ServerInterfaceWrapper) UserUpdateOidcOauthCallback(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.UserUpdateOidcOauthCallback(ctx) + return err +} + +// UserUpdateOidcOauthStart converts echo context to params. +func (w *ServerInterfaceWrapper) UserUpdateOidcOauthStart(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.UserUpdateOidcOauthStart(ctx) + return err +} + // UserUpdatePassword converts echo context to params. func (w *ServerInterfaceWrapper) UserUpdatePassword(ctx echo.Context) error { var err error @@ -8179,6 +8203,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/api/v1/users/login", wrapper.UserUpdateLogin) router.POST(baseURL+"/api/v1/users/logout", wrapper.UserUpdateLogout) router.GET(baseURL+"/api/v1/users/memberships", wrapper.TenantMembershipsList) + router.GET(baseURL+"/api/v1/users/oidc/callback", wrapper.UserUpdateOidcOauthCallback) + router.GET(baseURL+"/api/v1/users/oidc/start", wrapper.UserUpdateOidcOauthStart) router.POST(baseURL+"/api/v1/users/password", wrapper.UserUpdatePassword) router.POST(baseURL+"/api/v1/users/register", wrapper.UserCreate) router.GET(baseURL+"/api/v1/users/slack/callback", wrapper.UserUpdateSlackOauthCallback) @@ -13314,6 +13340,48 @@ func (response TenantMembershipsList403JSONResponse) VisitTenantMembershipsListR return json.NewEncoder(w).Encode(response) } +type UserUpdateOidcOauthCallbackRequestObject struct { +} + +type UserUpdateOidcOauthCallbackResponseObject interface { + VisitUserUpdateOidcOauthCallbackResponse(w http.ResponseWriter) error +} + +type UserUpdateOidcOauthCallback302ResponseHeaders struct { + Location string +} + +type UserUpdateOidcOauthCallback302Response struct { + Headers UserUpdateOidcOauthCallback302ResponseHeaders +} + +func (response UserUpdateOidcOauthCallback302Response) VisitUserUpdateOidcOauthCallbackResponse(w http.ResponseWriter) error { + w.Header().Set("location", fmt.Sprint(response.Headers.Location)) + w.WriteHeader(302) + return nil +} + +type UserUpdateOidcOauthStartRequestObject struct { +} + +type UserUpdateOidcOauthStartResponseObject interface { + VisitUserUpdateOidcOauthStartResponse(w http.ResponseWriter) error +} + +type UserUpdateOidcOauthStart302ResponseHeaders struct { + Location string +} + +type UserUpdateOidcOauthStart302Response struct { + Headers UserUpdateOidcOauthStart302ResponseHeaders +} + +func (response UserUpdateOidcOauthStart302Response) VisitUserUpdateOidcOauthStartResponse(w http.ResponseWriter) error { + w.Header().Set("location", fmt.Sprint(response.Headers.Location)) + w.WriteHeader(302) + return nil +} + type UserUpdatePasswordRequestObject struct { Body *UserUpdatePasswordJSONRequestBody } @@ -14144,6 +14212,10 @@ type StrictServerInterface interface { TenantMembershipsList(ctx echo.Context, request TenantMembershipsListRequestObject) (TenantMembershipsListResponseObject, error) + UserUpdateOidcOauthCallback(ctx echo.Context, request UserUpdateOidcOauthCallbackRequestObject) (UserUpdateOidcOauthCallbackResponseObject, error) + + UserUpdateOidcOauthStart(ctx echo.Context, request UserUpdateOidcOauthStartRequestObject) (UserUpdateOidcOauthStartResponseObject, error) + UserUpdatePassword(ctx echo.Context, request UserUpdatePasswordRequestObject) (UserUpdatePasswordResponseObject, error) UserCreate(ctx echo.Context, request UserCreateRequestObject) (UserCreateResponseObject, error) @@ -17262,6 +17334,46 @@ func (sh *strictHandler) TenantMembershipsList(ctx echo.Context) error { return nil } +// UserUpdateOidcOauthCallback operation +func (sh *strictHandler) UserUpdateOidcOauthCallback(ctx echo.Context) error { + var request UserUpdateOidcOauthCallbackRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.UserUpdateOidcOauthCallback(ctx, request.(UserUpdateOidcOauthCallbackRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(UserUpdateOidcOauthCallbackResponseObject); ok { + return validResponse.VisitUserUpdateOidcOauthCallbackResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + +// UserUpdateOidcOauthStart operation +func (sh *strictHandler) UserUpdateOidcOauthStart(ctx echo.Context) error { + var request UserUpdateOidcOauthStartRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.UserUpdateOidcOauthStart(ctx, request.(UserUpdateOidcOauthStartRequestObject)) + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(UserUpdateOidcOauthStartResponseObject); ok { + return validResponse.VisitUserUpdateOidcOauthStartResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("Unexpected response type: %T", response) + } + return nil +} + // UserUpdatePassword operation func (sh *strictHandler) UserUpdatePassword(ctx echo.Context) error { var request UserUpdatePasswordRequestObject @@ -17952,20 +18064,21 @@ var swaggerSpec = []string{ "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", + "+f7l+/8XAAD//4a9Lnw1jwMA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/frontend/app/src/pages/auth/components/auth-page.tsx b/frontend/app/src/pages/auth/components/auth-page.tsx index 39be31bf16..a5e1fc0783 100644 --- a/frontend/app/src/pages/auth/components/auth-page.tsx +++ b/frontend/app/src/pages/auth/components/auth-page.tsx @@ -29,12 +29,14 @@ export function AuthPage({ const googleEnabled = schemes.includes('google'); const githubEnabled = schemes.includes('github'); const ssoEnabled = schemes.includes('sso'); + const oidcEnabled = schemes.includes('oidc'); const providers = [ googleEnabled && 'google', githubEnabled && 'github', ssoEnabled && 'sso', - ].filter(Boolean) as Array<'google' | 'github' | 'sso'>; + oidcEnabled && 'oidc', + ].filter(Boolean) as Array<'google' | 'github' | 'sso' | 'oidc'>; const sections = [ providers.length > 0 && ( diff --git a/frontend/app/src/pages/auth/components/social-auth.tsx b/frontend/app/src/pages/auth/components/social-auth.tsx index 4cd4f1f6e3..8fa9e9dde2 100644 --- a/frontend/app/src/pages/auth/components/social-auth.tsx +++ b/frontend/app/src/pages/auth/components/social-auth.tsx @@ -1,10 +1,12 @@ import { Button } from '@/components/v1/ui/button'; import { Icons } from '@/components/v1/ui/icons'; import useControlPlane from '@/hooks/use-control-plane.ts'; -import { ArrowLeft, LockOpen } from 'lucide-react'; +import { ArrowLeft, LockOpen, LockKeyhole } from 'lucide-react'; import React, { useState } from 'react'; -export type SocialAuthProvider = 'google' | 'github' | 'sso'; +// 'sso' is the cloud/control-plane SSO flow (email-first); 'oidc' is the +// self-hosted OIDC provider (PR hatchet-dev#3607) — a direct redirect. +export type SocialAuthProvider = 'google' | 'github' | 'sso' | 'oidc'; const PROVIDER_CONFIG: Record< SocialAuthProvider, @@ -25,6 +27,11 @@ const PROVIDER_CONFIG: Record< label: 'SSO', icon: , }, + oidc: { + href: 'users/oidc/start', + label: 'OIDC', + icon: , + }, }; export function OrContinueWith() { diff --git a/frontend/docs/pages/self-hosting/configuration-options.mdx b/frontend/docs/pages/self-hosting/configuration-options.mdx index 37ad08d197..ec1e882ad5 100644 --- a/frontend/docs/pages/self-hosting/configuration-options.mdx +++ b/frontend/docs/pages/self-hosting/configuration-options.mdx @@ -288,6 +288,13 @@ Variables marked with ⚠️ are conditionally required when specific features a | `SERVER_AUTH_GITHUB_CLIENT_ID` ⚠️ | GitHub auth client ID (required if GitHub auth enabled) | | | `SERVER_AUTH_GITHUB_CLIENT_SECRET` ⚠️ | GitHub auth client secret (required if GitHub auth enabled) | | | `SERVER_AUTH_GITHUB_SCOPES` | GitHub auth scopes | `["read:user", "user:email"]` | +| `SERVER_AUTH_OIDC_ENABLED` | Whether generic OIDC auth is enabled | `false` | +| `SERVER_AUTH_OIDC_CLIENT_ID` ⚠️ | OIDC client ID (required if OIDC auth enabled) | | +| `SERVER_AUTH_OIDC_CLIENT_SECRET` ⚠️ | OIDC client secret (required if OIDC auth enabled) | | +| `SERVER_AUTH_OIDC_ISSUER_URL` ⚠️ | OIDC issuer URL used for discovery (required if OIDC auth enabled) | | +| `SERVER_AUTH_OIDC_SCOPES` | OIDC auth scopes | `["openid", "profile", "email"]` | + +The OIDC provider works with any OIDC-compliant identity provider (Keycloak, Authentik, Dex, Okta, Azure AD, etc.). The issuer URL is the base URL from which the provider's `/.well-known/openid-configuration` is discovered — e.g. `https://auth.example.com/realms/my-realm` for Keycloak. The redirect URI to register with the provider is `${SERVER_URL}/api/v1/users/oidc/callback`. ## Task Queue Configuration diff --git a/go.mod b/go.mod index 5569cffb99..fc31b6dc82 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/charmbracelet/log v1.0.0 github.com/cockroachdb/errors v1.12.0 github.com/containerd/errdefs v1.0.0 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/creasty/defaults v1.8.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.7.0 @@ -23,6 +24,7 @@ require ( github.com/fatih/color v1.19.0 github.com/getkin/kin-openapi v0.135.0 github.com/go-co-op/gocron/v2 v2.21.1 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/google/go-github/v57 v57.0.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 diff --git a/go.sum b/go.sum index 538434a592..b786e62dfb 100644 --- a/go.sum +++ b/go.sum @@ -128,6 +128,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -188,6 +190,8 @@ github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnA github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= diff --git a/pkg/auth/oauth/configs.go b/pkg/auth/oauth/configs.go index a0d181238a..98701c53cd 100644 --- a/pkg/auth/oauth/configs.go +++ b/pkg/auth/oauth/configs.go @@ -1,6 +1,8 @@ package oauth import ( + "strings" + "golang.org/x/oauth2" ) @@ -11,6 +13,16 @@ type Config struct { BaseURL string } +type OIDCConfig struct { + ClientID string + ClientSecret string + Scopes []string + BaseURL string + IssuerURL string + AuthURL string + TokenURL string +} + const ( GoogleAuthURL string = "https://accounts.google.com/o/oauth2/v2/auth" GoogleTokenURL string = "https://oauth2.googleapis.com/token" // #nosec G101 @@ -58,3 +70,16 @@ func NewSlackClient(cfg *Config) *oauth2.Config { Scopes: cfg.Scopes, } } + +func NewOIDCClient(cfg *OIDCConfig) *oauth2.Config { + return &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: cfg.AuthURL, + TokenURL: cfg.TokenURL, + }, + RedirectURL: strings.TrimRight(cfg.BaseURL, "/") + "/api/v1/users/oidc/callback", + Scopes: cfg.Scopes, + } +} diff --git a/pkg/config/loader/loader.go b/pkg/config/loader/loader.go index b237f66329..3bd1707c4c 100644 --- a/pkg/config/loader/loader.go +++ b/pkg/config/loader/loader.go @@ -22,6 +22,8 @@ import ( "github.com/rs/zerolog" "golang.org/x/oauth2" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/hatchet-dev/hatchet/internal/integrations/alerting" "github.com/hatchet-dev/hatchet/internal/services/ingestor" "github.com/hatchet-dev/hatchet/pkg/analytics" @@ -695,6 +697,59 @@ func createControllerLayer(dc *database.Layer, cf *server.ServerConfigFile, vers }) } + if cf.Auth.OIDC.Enabled { + // Parse ScopesString before building OIDCOAuthConfig so the env var is used. + if cf.Auth.OIDC.ScopesString != "" { + cf.Auth.OIDC.Scopes = getStrArr(cf.Auth.OIDC.ScopesString) + } + + if cf.Auth.OIDC.ClientID == "" { + return nil, nil, fmt.Errorf("oidc client id is required") + } + + if cf.Auth.OIDC.ClientSecret == "" { + return nil, nil, fmt.Errorf("oidc client secret is required") + } + + if cf.Auth.OIDC.IssuerURL == "" { + return nil, nil, fmt.Errorf("oidc issuer url is required") + } + + // Ensure "openid" scope is always present, since we rely on the ID token. + hasOpenID := false + for _, s := range cf.Auth.OIDC.Scopes { + if s == "openid" { + hasOpenID = true + break + } + } + + if !hasOpenID { + cf.Auth.OIDC.Scopes = append([]string{"openid"}, cf.Auth.OIDC.Scopes...) + } + + discoveryCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + oidcProvider, err := oidc.NewProvider(discoveryCtx, cf.Auth.OIDC.IssuerURL) + if err != nil { + return nil, nil, fmt.Errorf("could not create OIDC provider from issuer URL %s: %w", cf.Auth.OIDC.IssuerURL, err) + } + + endpoint := oidcProvider.Endpoint() + + auth.OIDCProvider = oidcProvider + auth.OIDCOAuthConfig = oauth.NewOIDCClient(&oauth.OIDCConfig{ + ClientID: cf.Auth.OIDC.ClientID, + ClientSecret: cf.Auth.OIDC.ClientSecret, + BaseURL: cf.Runtime.ServerURL, + Scopes: cf.Auth.OIDC.Scopes, + IssuerURL: cf.Auth.OIDC.IssuerURL, + AuthURL: endpoint.AuthURL, + TokenURL: endpoint.TokenURL, + }) + } + encryptionSvc, err := LoadEncryptionSvc(cf) if err != nil { diff --git a/pkg/config/server/server.go b/pkg/config/server/server.go index ba41525749..b20c93cb0a 100644 --- a/pkg/config/server/server.go +++ b/pkg/config/server/server.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/labstack/echo/v4" "github.com/rs/zerolog" "github.com/spf13/viper" @@ -443,6 +444,8 @@ type ConfigFileAuth struct { Github ConfigFileAuthGithub `mapstructure:"github" json:"github,omitempty"` + OIDC ConfigFileAuthOIDC `mapstructure:"oidc" json:"oidc,omitempty"` + ControlPlaneExchangeTokenConfig ConfigFileAuthControlPlaneExchangeToken `mapstructure:"controlPlaneExchangeToken" json:"controlPlaneExchangeToken,omitempty"` } @@ -496,6 +499,18 @@ type ConfigFileAuthGithub struct { Scopes []string `mapstructure:"scopes" json:"scopes,omitempty" default:"[\"read:user\", \"user:email\"]"` } +type ConfigFileAuthOIDC struct { + Enabled bool `mapstructure:"enabled" json:"enabled,omitempty" default:"false"` + + ClientID string `mapstructure:"clientID" json:"clientID,omitempty"` + ClientSecret string `mapstructure:"clientSecret" json:"clientSecret,omitempty"` + IssuerURL string `mapstructure:"issuerURL" json:"issuerURL,omitempty"` + Scopes []string `mapstructure:"scopes" json:"scopes,omitempty" default:"[\"openid\", \"profile\", \"email\"]"` + // ScopesString is used to bind the SERVER_AUTH_OIDC_SCOPES env var, since + // direct env-to-[]string binding is unreliable without a decode hook. + ScopesString string `mapstructure:"scopesString" json:"scopesString,omitempty"` +} + type ConfigFileAuthCookie struct { Name string `mapstructure:"name" json:"name,omitempty" default:"hatchet"` Domain string `mapstructure:"domain" json:"domain,omitempty"` @@ -593,6 +608,12 @@ type AuthConfig struct { GithubOAuthConfig *oauth2.Config + OIDCOAuthConfig *oauth2.Config + + // OIDCProvider is the cached OIDC provider, created at startup via discovery. + // Reuse this instead of calling oidc.NewProvider on every request. + OIDCProvider *oidc.Provider + JWTManager token.JWTManager ExchangeTokenClient exchangetoken.ExchangeTokenClient @@ -831,6 +852,12 @@ func BindAllEnv(v *viper.Viper) { _ = v.BindEnv("auth.github.clientID", "SERVER_AUTH_GITHUB_CLIENT_ID") _ = v.BindEnv("auth.github.clientSecret", "SERVER_AUTH_GITHUB_CLIENT_SECRET") _ = v.BindEnv("auth.github.scopes", "SERVER_AUTH_GITHUB_SCOPES") + _ = v.BindEnv("auth.oidc.enabled", "SERVER_AUTH_OIDC_ENABLED") + _ = v.BindEnv("auth.oidc.clientID", "SERVER_AUTH_OIDC_CLIENT_ID") + _ = v.BindEnv("auth.oidc.clientSecret", "SERVER_AUTH_OIDC_CLIENT_SECRET") + _ = v.BindEnv("auth.oidc.issuerURL", "SERVER_AUTH_OIDC_ISSUER_URL") + _ = v.BindEnv("auth.oidc.scopes", "SERVER_AUTH_OIDC_SCOPES") + _ = v.BindEnv("auth.oidc.scopesString", "SERVER_AUTH_OIDC_SCOPES") // task queue options // legacy options diff --git a/pkg/repository/user.go b/pkg/repository/user.go index ea09e69287..3322394638 100644 --- a/pkg/repository/user.go +++ b/pkg/repository/user.go @@ -24,7 +24,7 @@ type CreateUserOpts struct { } type OAuthOpts struct { - Provider string `validate:"required,oneof=google github sso"` + Provider string `validate:"required,oneof=google github sso oidc"` ProviderUserId string `validate:"required,min=1"` AccessToken []byte `validate:"required,min=1"` RefreshToken []byte // optional