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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",
- "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",
+ "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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