Skip to content

Commit

Permalink
feat: add github sso to dashboard (#373)
Browse files Browse the repository at this point in the history
  • Loading branch information
abelanger5 authored Apr 11, 2024
1 parent a5a2629 commit f16a9cd
Show file tree
Hide file tree
Showing 22 changed files with 1,326 additions and 193 deletions.
4 changes: 4 additions & 0 deletions api-contracts/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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/github-app/start:
$ref: "./paths/user/user.yaml#/oauth-start-github-app"
/api/v1/users/github-app/callback:
$ref: "./paths/user/user.yaml#/oauth-callback-github-app"
/api/v1/github/webhook:
$ref: "./paths/github-app/github-app.yaml#/globalWebhook"
/api/v1/github/webhook/{webhook}:
Expand Down
34 changes: 32 additions & 2 deletions api-contracts/openapi/paths/user/user.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,36 @@ oauth-start-github:
get:
description: Starts the OAuth flow
operationId: user:update:github-oauth-start
responses:
"302":
description: Successfully started the OAuth flow
headers:
location:
schema:
type: string
security: []
summary: Start OAuth flow
tags:
- User
oauth-callback-github:
get:
description: Completes the OAuth flow
operationId: user:update:github-oauth-callback
responses:
"302":
description: Successfully completed the OAuth flow
headers:
location:
schema:
type: string
security: []
summary: Complete OAuth flow
tags:
- User
oauth-start-github-app:
get:
description: Starts the OAuth flow
operationId: user:update:github-app-oauth-start
responses:
"302":
description: Successfully started the OAuth flow
Expand All @@ -195,10 +225,10 @@ oauth-start-github:
summary: Start OAuth flow
tags:
- User
oauth-callback-github:
oauth-callback-github-app:
get:
description: Completes the OAuth flow
operationId: user:update:github-oauth-callback
operationId: user:update:github-app-oauth-callback
responses:
"302":
description: Successfully completed the OAuth flow
Expand Down
6 changes: 3 additions & 3 deletions api/v1/server/handlers/github-app/oauth_callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (
)

// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow.
func (g *GithubAppService) UserUpdateGithubOauthCallback(ctx echo.Context, _ gen.UserUpdateGithubOauthCallbackRequestObject) (gen.UserUpdateGithubOauthCallbackResponseObject, error) {
func (g *GithubAppService) UserUpdateGithubAppOauthCallback(ctx echo.Context, _ gen.UserUpdateGithubAppOauthCallbackRequestObject) (gen.UserUpdateGithubAppOauthCallbackResponseObject, error) {
user := ctx.Get("user").(*db.UserModel)

ghApp, err := GetGithubAppConfig(g.config)
Expand Down Expand Up @@ -86,8 +86,8 @@ func (g *GithubAppService) UserUpdateGithubOauthCallback(ctx echo.Context, _ gen
url = "/"
}

return gen.UserUpdateGithubOauthCallback302Response{
Headers: gen.UserUpdateGithubOauthCallback302ResponseHeaders{
return gen.UserUpdateGithubAppOauthCallback302Response{
Headers: gen.UserUpdateGithubAppOauthCallback302ResponseHeaders{
Location: url,
},
}, nil
Expand Down
6 changes: 3 additions & 3 deletions api/v1/server/handlers/github-app/oauth_start.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
)

// Note: we want all errors to redirect, otherwise the user will be greeted with raw JSON in the middle of the login flow.
func (g *GithubAppService) UserUpdateGithubOauthStart(ctx echo.Context, _ gen.UserUpdateGithubOauthStartRequestObject) (gen.UserUpdateGithubOauthStartResponseObject, error) {
func (g *GithubAppService) UserUpdateGithubAppOauthStart(ctx echo.Context, _ gen.UserUpdateGithubAppOauthStartRequestObject) (gen.UserUpdateGithubAppOauthStartResponseObject, error) {
ghApp, err := GetGithubAppConfig(g.config)

if err != nil {
Expand All @@ -24,8 +24,8 @@ func (g *GithubAppService) UserUpdateGithubOauthStart(ctx echo.Context, _ gen.Us

url := ghApp.AuthCodeURL(state, oauth2.AccessTypeOffline)

return gen.UserUpdateGithubOauthStart302Response{
Headers: gen.UserUpdateGithubOauthStart302ResponseHeaders{
return gen.UserUpdateGithubAppOauthStart302Response{
Headers: gen.UserUpdateGithubAppOauthStart302ResponseHeaders{
Location: url,
},
}, nil
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 @@ -17,6 +17,10 @@ func (u *MetadataService) MetadataGet(ctx echo.Context, request gen.MetadataGetR
authTypes = append(authTypes, "google")
}

if u.config.Auth.ConfigFile.Github.Enabled {
authTypes = append(authTypes, "github")
}

meta := gen.APIMeta{
Auth: &gen.APIMetaAuth{
Schemes: &authTypes,
Expand Down
11 changes: 1 addition & 10 deletions api/v1/server/handlers/users/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package users

import (
"errors"
"strings"

"github.com/labstack/echo/v4"

Expand All @@ -24,15 +23,7 @@ func (u *UserService) UserCreate(ctx echo.Context, request gen.UserCreateRequest
}

// check restricted email group
// parse domain from email
// make sure there's only one @ in the email
if strings.Count(string(request.Body.Email), "@") != 1 {
return nil, errors.New("invalid email")
}

domain := strings.Split(string(request.Body.Email), "@")[1]

if err := u.checkUserRestrictions(u.config, domain); err != nil {
if err := u.checkUserRestrictionsForEmail(u.config, string(request.Body.Email)); err != nil {
return nil, err
}

Expand Down
186 changes: 186 additions & 0 deletions api/v1/server/handlers/users/github_oauth_callback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package users

import (
"context"
"errors"
"fmt"

githubsdk "github.com/google/go-github/v57/github"
"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/oas/gen"
"github.com/hatchet-dev/hatchet/internal/config/server"
"github.com/hatchet-dev/hatchet/internal/repository"
"github.com/hatchet-dev/hatchet/internal/repository/prisma/db"
)

// 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) UserUpdateGithubOauthCallback(ctx echo.Context, _ gen.UserUpdateGithubOauthCallbackRequestObject) (gen.UserUpdateGithubOauthCallbackResponseObject, error) {
isValid, _, err := authn.NewSessionHelpers(u.config).ValidateOAuthState(ctx, "github")

if err != nil || !isValid {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Could not log in. Please try again and make sure cookies are enabled.")
}

token, err := u.config.Auth.GithubOAuthConfig.Exchange(context.Background(), ctx.Request().URL.Query().Get("code"))

if err != nil {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Forbidden")
}

if !token.Valid() {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, fmt.Errorf("invalid token"), "Forbidden")
}

user, err := u.upsertGithubUserFromToken(u.config, token)

if err != nil {
if errors.Is(err, ErrGithubNotVerified) {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Please verify your email on Github.")
}

if errors.Is(err, ErrGithubNoEmail) {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Github user must have an email.")
}

return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.")
}

err = authn.NewSessionHelpers(u.config).SaveAuthenticated(ctx, user)

if err != nil {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Internal error.")
}

return gen.UserUpdateGithubOauthCallback302Response{
Headers: gen.UserUpdateGithubOauthCallback302ResponseHeaders{
Location: u.config.Runtime.ServerURL,
},
}, nil
}

func (u *UserService) upsertGithubUserFromToken(config *server.ServerConfig, tok *oauth2.Token) (*db.UserModel, error) {
gInfo, err := u.getGithubEmailFromToken(tok)

if err != nil {
return nil, err
}

if err := u.checkUserRestrictionsForEmail(config, gInfo.Email); err != nil {
return nil, err
}

expiresAt := tok.Expiry

// use the encryption service to encrypt the access and refresh token
accessTokenEncrypted, err := config.Encryption.Encrypt([]byte(tok.AccessToken), "github_access_token")

if err != nil {
return nil, fmt.Errorf("failed to encrypt access token: %s", err.Error())
}

refreshTokenEncrypted, err := config.Encryption.Encrypt([]byte(tok.RefreshToken), "github_refresh_token")

if err != nil {
return nil, fmt.Errorf("failed to encrypt refresh token: %s", err.Error())
}

oauthOpts := &repository.OAuthOpts{
Provider: "github",
ProviderUserId: gInfo.ID,
AccessToken: accessTokenEncrypted,
RefreshToken: &refreshTokenEncrypted,
ExpiresAt: &expiresAt,
}

user, err := u.config.APIRepository.User().GetUserByEmail(gInfo.Email)

switch err {
case nil:
user, err = u.config.APIRepository.User().UpdateUser(user.ID, &repository.UpdateUserOpts{
EmailVerified: repository.BoolPtr(gInfo.EmailVerified),
Name: repository.StringPtr(gInfo.Name),
OAuth: oauthOpts,
})

if err != nil {
return nil, fmt.Errorf("failed to update user: %s", err.Error())
}
case db.ErrNotFound:
user, err = u.config.APIRepository.User().CreateUser(&repository.CreateUserOpts{
Email: gInfo.Email,
EmailVerified: repository.BoolPtr(gInfo.EmailVerified),
Name: repository.StringPtr(gInfo.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
}

var ErrGithubNotVerified = fmt.Errorf("Please verify your email on Github")
var ErrGithubNoEmail = fmt.Errorf("Github user must have an email")

type githubInfo struct {
Email string
EmailVerified bool
Name string
ID string
}

func (u *UserService) getGithubEmailFromToken(tok *oauth2.Token) (*githubInfo, error) {
client := githubsdk.NewClient(u.config.Auth.GithubOAuthConfig.Client(context.Background(), tok))

user, _, err := client.Users.Get(context.Background(), "")

if err != nil {
return nil, err
}

emails, _, err := client.Users.ListEmails(context.Background(), &githubsdk.ListOptions{})

if err != nil {
return nil, err
}

primary := ""
verified := false

// get the primary email
for _, email := range emails {
if email.GetPrimary() {
primary = email.GetEmail()
verified = email.GetVerified()
break
}
}

if primary == "" {
return nil, ErrGithubNoEmail
}

if !verified {
return nil, ErrGithubNotVerified
}

id := user.GetID()

if id == 0 {
return nil, fmt.Errorf("Github user has no ID")
}

return &githubInfo{
Email: primary,
EmailVerified: verified,
Name: user.GetName(),
ID: fmt.Sprintf("%d", id),
}, nil
}
25 changes: 25 additions & 0 deletions api/v1/server/handlers/users/github_oauth_start.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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/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) UserUpdateGithubOauthStart(ctx echo.Context, _ gen.UserUpdateGithubOauthStartRequestObject) (gen.UserUpdateGithubOauthStartResponseObject, error) {
state, err := authn.NewSessionHelpers(u.config).SaveOAuthState(ctx, "github")

if err != nil {
return nil, authn.GetRedirectWithError(ctx, u.config.Logger, err, "Could not get cookie. Please make sure cookies are enabled.")
}

url := u.config.Auth.GithubOAuthConfig.AuthCodeURL(state)

return gen.UserUpdateGithubOauthStart302Response{
Headers: gen.UserUpdateGithubOauthStart302ResponseHeaders{
Location: url,
},
}, nil
}
18 changes: 18 additions & 0 deletions api/v1/server/handlers/users/service.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package users

import (
"errors"
"fmt"
"strings"

"github.com/hatchet-dev/hatchet/internal/config/server"
)
Expand All @@ -16,6 +18,22 @@ func NewUserService(config *server.ServerConfig) *UserService {
}
}

func (u *UserService) checkUserRestrictionsForEmail(conf *server.ServerConfig, email string) error {
if len(conf.Auth.ConfigFile.RestrictedEmailDomains) == 0 {
return nil
}

// parse domain from email
// make sure there's only one @ in the email
if strings.Count(email, "@") != 1 {
return errors.New("invalid email")
}

domain := strings.Split(email, "@")[1]

return u.checkUserRestrictions(conf, domain)
}

func (u *UserService) checkUserRestrictions(conf *server.ServerConfig, emailDomain string) error {
if len(conf.Auth.ConfigFile.RestrictedEmailDomains) == 0 {
return nil
Expand Down
Loading

0 comments on commit f16a9cd

Please sign in to comment.