Skip to content

Commit dc4c089

Browse files
feat: add built-in OIDC authentication support
Add native OIDC support for self-hosted instances, enabling direct integration with providers like Keycloak, PocketID, and any OpenID Connect compliant identity provider. Changes: - Add OIDC OAuth2 start and callback handlers - Add OIDC config struct with env var bindings (SERVER_AUTH_OIDC_*) - Use OIDC discovery to resolve auth/token endpoints at startup - Cache oidc.Provider for ID token verification (no per-request discovery) - Add OIDC routes to OpenAPI spec and regenerate server code - Expose 'oidc' scheme via /api/v1/meta endpoint - Add SSO button to frontend auth page - Support domain restrictions for OIDC users - Default scopes: openid, profile, email Closes #3052
1 parent 4ba2c7e commit dc4c089

13 files changed

Lines changed: 464 additions & 16 deletions

File tree

api-contracts/openapi/openapi.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ paths:
101101
$ref: "./paths/user/user.yaml#/oauth-start-github"
102102
/api/v1/users/github/callback:
103103
$ref: "./paths/user/user.yaml#/oauth-callback-github"
104+
/api/v1/users/oidc/start:
105+
$ref: "./paths/user/user.yaml#/oauth-start-oidc"
106+
/api/v1/users/oidc/callback:
107+
$ref: "./paths/user/user.yaml#/oauth-callback-oidc"
104108
/api/v1/tenants/{tenant}/slack/start:
105109
$ref: "./paths/user/user.yaml#/oauth-start-slack"
106110
/api/v1/users/slack/callback:

api-contracts/openapi/paths/user/user.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,36 @@ oauth-callback-github:
228228
summary: Complete OAuth flow
229229
tags:
230230
- User
231+
oauth-start-oidc:
232+
get:
233+
description: Starts the OIDC OAuth flow
234+
operationId: user:update:oidc-oauth-start
235+
responses:
236+
"302":
237+
description: Successfully started the OAuth flow
238+
headers:
239+
location:
240+
schema:
241+
type: string
242+
security: []
243+
summary: Start OIDC OAuth flow
244+
tags:
245+
- User
246+
oauth-callback-oidc:
247+
get:
248+
description: Completes the OIDC OAuth flow
249+
operationId: user:update:oidc-oauth-callback
250+
responses:
251+
"302":
252+
description: Successfully completed the OAuth flow
253+
headers:
254+
location:
255+
schema:
256+
type: string
257+
security: []
258+
summary: Complete OIDC OAuth flow
259+
tags:
260+
- User
231261
oauth-start-slack:
232262
get:
233263
x-resources: ["tenant"]

api/v1/server/handlers/metadata/get.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ func (u *MetadataService) MetadataGet(ctx echo.Context, request gen.MetadataGetR
2121
authTypes = append(authTypes, "github")
2222
}
2323

24+
if u.config.Auth.ConfigFile.OIDC.Enabled {
25+
authTypes = append(authTypes, "oidc")
26+
}
27+
2428
pylonAppID := u.config.Pylon.AppID
2529

2630
var posthogConfig *gen.APIMetaPosthog
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package users
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
"github.com/coreos/go-oidc/v3/oidc"
9+
"github.com/jackc/pgx/v5"
10+
"github.com/labstack/echo/v4"
11+
"golang.org/x/oauth2"
12+
13+
"github.com/hatchet-dev/hatchet/api/v1/server/authn"
14+
"github.com/hatchet-dev/hatchet/api/v1/server/middleware/redirect"
15+
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
16+
"github.com/hatchet-dev/hatchet/pkg/analytics"
17+
"github.com/hatchet-dev/hatchet/pkg/config/server"
18+
v1 "github.com/hatchet-dev/hatchet/pkg/repository"
19+
"github.com/hatchet-dev/hatchet/pkg/repository/sqlcv1"
20+
)
21+
22+
// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow.
23+
func (u *UserService) UserUpdateOidcOauthCallback(ctx echo.Context, _ gen.UserUpdateOidcOauthCallbackRequestObject) (gen.UserUpdateOidcOauthCallbackResponseObject, error) {
24+
isValid, _, err := authn.NewSessionHelpers(u.config.SessionStore).ValidateOAuthState(ctx, "oidc")
25+
26+
if err != nil || !isValid {
27+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Could not log in. Please try again and make sure cookies are enabled.")
28+
}
29+
30+
token, err := u.config.Auth.OIDCOAuthConfig.Exchange(ctx.Request().Context(), ctx.Request().URL.Query().Get("code"))
31+
32+
if err != nil {
33+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Forbidden")
34+
}
35+
36+
if !token.Valid() {
37+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, fmt.Errorf("invalid token"), "Forbidden")
38+
}
39+
40+
user, err := u.upsertOIDCUserFromToken(ctx.Request().Context(), u.config, token)
41+
42+
if err != nil {
43+
if errors.Is(err, ErrNotInRestrictedDomain) {
44+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Email is not in the restricted domain group.")
45+
}
46+
47+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.")
48+
}
49+
50+
err = authn.NewSessionHelpers(u.config.SessionStore).SaveAuthenticated(ctx, user)
51+
52+
if err != nil {
53+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.")
54+
}
55+
56+
analyticsCtx := context.WithValue(ctx.Request().Context(), analytics.UserIDKey, user.ID)
57+
analyticsCtx = context.WithValue(analyticsCtx, analytics.SourceKey, analytics.SourceUI)
58+
u.config.Analytics.Enqueue(
59+
analyticsCtx,
60+
analytics.User, analytics.Login,
61+
user.ID.String(),
62+
map[string]interface{}{"provider": "oidc"},
63+
)
64+
65+
return gen.UserUpdateOidcOauthCallback302Response{
66+
Headers: gen.UserUpdateOidcOauthCallback302ResponseHeaders{
67+
Location: u.config.Runtime.ServerURL,
68+
},
69+
}, nil
70+
}
71+
72+
func (u *UserService) upsertOIDCUserFromToken(ctx context.Context, config *server.ServerConfig, tok *oauth2.Token) (*sqlcv1.User, error) {
73+
claims, err := getOIDCClaimsFromToken(ctx, config, tok)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
if err := u.checkUserRestrictionsForEmail(config, claims.Email); err != nil {
79+
return nil, err
80+
}
81+
82+
expiresAt := tok.Expiry
83+
84+
accessTokenEncrypted, err := config.Encryption.Encrypt([]byte(tok.AccessToken), "oidc_access_token")
85+
if err != nil {
86+
return nil, fmt.Errorf("failed to encrypt access token: %s", err.Error())
87+
}
88+
89+
refreshToken := tok.RefreshToken
90+
if refreshToken == "" {
91+
refreshToken = "none"
92+
}
93+
94+
refreshTokenEncrypted, err := config.Encryption.Encrypt([]byte(refreshToken), "oidc_refresh_token")
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to encrypt refresh token: %s", err.Error())
97+
}
98+
99+
oauthOpts := &v1.OAuthOpts{
100+
Provider: "oidc",
101+
ProviderUserId: claims.Sub,
102+
AccessToken: accessTokenEncrypted,
103+
RefreshToken: refreshTokenEncrypted,
104+
ExpiresAt: &expiresAt,
105+
}
106+
107+
user, err := u.config.V1.User().GetUserByEmail(ctx, claims.Email)
108+
109+
switch err {
110+
case nil:
111+
user, err = u.config.V1.User().UpdateUser(ctx, user.ID, &v1.UpdateUserOpts{
112+
EmailVerified: v1.BoolPtr(claims.EmailVerified),
113+
Name: v1.StringPtr(claims.Name),
114+
OAuth: oauthOpts,
115+
})
116+
117+
if err != nil {
118+
return nil, fmt.Errorf("failed to update user: %s", err.Error())
119+
}
120+
case pgx.ErrNoRows:
121+
user, err = u.config.V1.User().CreateUser(ctx, &v1.CreateUserOpts{
122+
Email: claims.Email,
123+
EmailVerified: v1.BoolPtr(claims.EmailVerified),
124+
Name: v1.StringPtr(claims.Name),
125+
OAuth: oauthOpts,
126+
})
127+
128+
if err != nil {
129+
return nil, fmt.Errorf("failed to create user: %s", err.Error())
130+
}
131+
default:
132+
return nil, fmt.Errorf("failed to get user: %s", err.Error())
133+
}
134+
135+
return user, nil
136+
}
137+
138+
type oidcClaims struct {
139+
Email string `json:"email"`
140+
EmailVerified bool `json:"email_verified"`
141+
Name string `json:"name"`
142+
Sub string `json:"sub"`
143+
}
144+
145+
func getOIDCClaimsFromToken(ctx context.Context, config *server.ServerConfig, tok *oauth2.Token) (*oidcClaims, error) {
146+
verifier := config.Auth.OIDCProvider.Verifier(&oidc.Config{
147+
ClientID: config.Auth.OIDCOAuthConfig.ClientID,
148+
})
149+
150+
rawIDToken, ok := tok.Extra("id_token").(string)
151+
if !ok {
152+
return nil, fmt.Errorf("no id_token in token response")
153+
}
154+
155+
idToken, err := verifier.Verify(ctx, rawIDToken)
156+
if err != nil {
157+
return nil, fmt.Errorf("failed to verify ID token: %s", err.Error())
158+
}
159+
160+
claims := &oidcClaims{}
161+
if err := idToken.Claims(claims); err != nil {
162+
return nil, fmt.Errorf("failed to parse ID token claims: %s", err.Error())
163+
}
164+
165+
if claims.Email == "" {
166+
return nil, fmt.Errorf("OIDC provider did not return an email claim")
167+
}
168+
169+
if claims.Sub == "" {
170+
return nil, fmt.Errorf("OIDC provider did not return a sub claim")
171+
}
172+
173+
return claims, nil
174+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package users
2+
3+
import (
4+
"github.com/labstack/echo/v4"
5+
6+
"github.com/hatchet-dev/hatchet/api/v1/server/authn"
7+
"github.com/hatchet-dev/hatchet/api/v1/server/middleware/redirect"
8+
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
9+
)
10+
11+
// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow.
12+
func (u *UserService) UserUpdateOidcOauthStart(ctx echo.Context, _ gen.UserUpdateOidcOauthStartRequestObject) (gen.UserUpdateOidcOauthStartResponseObject, error) {
13+
if !u.config.Runtime.AllowSignup {
14+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, nil, "User signup is disabled.")
15+
}
16+
17+
state, err := authn.NewSessionHelpers(u.config.SessionStore).SaveOAuthState(ctx, "oidc")
18+
19+
if err != nil {
20+
return nil, redirect.GetRedirectWithError(ctx, u.config.Logger, err, "Could not get cookie. Please make sure cookies are enabled.")
21+
}
22+
23+
url := u.config.Auth.OIDCOAuthConfig.AuthCodeURL(state)
24+
25+
return gen.UserUpdateOidcOauthStart302Response{
26+
Headers: gen.UserUpdateOidcOauthStart302ResponseHeaders{
27+
Location: url,
28+
},
29+
}, nil
30+
}

0 commit comments

Comments
 (0)