Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api-contracts/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions api-contracts/openapi/paths/user/user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
2 changes: 2 additions & 0 deletions api/v1/server/authz/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ roles:
- TenantResourcePolicyGet
- UserUpdateSlackOauthStart
- UserUpdateGoogleOauthStart
- UserUpdateOidcOauthStart
- UserUpdateOidcOauthCallback
- SlackWebhookDelete
- V1CelDebug
- V1WorkflowRunGetTimings
Expand Down
4 changes: 4 additions & 0 deletions api/v1/server/handlers/metadata/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
216 changes: 216 additions & 0 deletions api/v1/server/handlers/users/oidc_oauth_callback.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading