From a70688ffba0fffd529951fc9ca0092c06402c889 Mon Sep 17 00:00:00 2001 From: Denis Gukov Date: Sat, 16 Sep 2023 23:10:33 +0200 Subject: [PATCH] fix(oidc): github auth --- api/login.go | 146 +++++++++++++++++++++++++++++++------------------ util/config.go | 4 +- 2 files changed, 95 insertions(+), 55 deletions(-) diff --git a/api/login.go b/api/login.go index 29755697..a64da88e 100644 --- a/api/login.go +++ b/api/login.go @@ -371,6 +371,61 @@ func generateStateOauthCookie(w http.ResponseWriter) string { return oauthState } +type oidcClaimResult struct { + username string + name string + email string +} + +func claimOidcToken(idToken *oidc.IDToken, provider util.OidcProvider) (res oidcClaimResult, err error) { + + claims := make(map[string]interface{}) + if err = idToken.Claims(&claims); err != nil { + return + } + + var ok bool + + res.email, ok = claims[provider.EmailClaim].(string) + if !ok { + err = fmt.Errorf("claim '%s' missing from id_token or not a string", provider.EmailClaim) + return + } + + res.username = getUsernameFromEmail(res.email) + + res.name, ok = claims[provider.NameClaim].(string) + if !ok || res.name == "" { + res.name = getProfileNameFromEmail(res.email) + } + + return +} + +func extractUsernameFromEmail(email string) string { + parts := strings.Split(email, "@") + if len(parts) > 0 { + return parts[0] + } + return "" +} + +func getUsernameFromEmail(email string) string { + username := extractUsernameFromEmail(email) + suffix := util.RandString(12) + return username + "_" + suffix +} + +func getProfileNameFromEmail(email string) string { + username := extractUsernameFromEmail(email) + + runes := []rune(username) + + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + + return string(runes) +} + func oidcRedirect(w http.ResponseWriter, r *http.Request) { pid := mux.Vars(r)["provider"] oauthState, err := r.Cookie("oauthstate") @@ -380,6 +435,11 @@ func oidcRedirect(w http.ResponseWriter, r *http.Request) { return } + if r.FormValue("state") != oauthState.Value { + http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) + return + } + ctx := context.Background() _oidc, oauth, err := getOidcProvider(pid, ctx) if err != nil { @@ -387,86 +447,66 @@ func oidcRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) return } + provider, ok := util.Config.OidcProviders[pid] if !ok { log.Error(fmt.Errorf("no such provider: %s", pid)) http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) return } + verifier := _oidc.Verifier(&oidc.Config{ClientID: oauth.ClientID}) - if r.FormValue("state") != oauthState.Value { - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return - } + code := r.URL.Query().Get("code") - oauth2Token, err := oauth.Exchange(ctx, r.URL.Query().Get("code")) + oauth2Token, err := oauth.Exchange(ctx, code) if err != nil { log.Error(err.Error()) http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) return } + var claims oidcClaimResult + // Extract the ID Token from OAuth2 token. rawIDToken, ok := oauth2Token.Extra("id_token").(string) - if !ok { - log.Error(fmt.Errorf("id_token is missing in token response")) - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return + + if ok && rawIDToken != "" { + var idToken *oidc.IDToken + // Parse and verify ID Token payload. + idToken, err = verifier.Verify(ctx, rawIDToken) + + if err == nil { + claims, err = claimOidcToken(idToken, provider) + } + } else { + var userInfo *oidc.UserInfo + userInfo, err = _oidc.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) + + if err == nil { + claims.email = userInfo.Email + claims.username = getUsernameFromEmail(claims.email) + + if userInfo.Profile != "" { + claims.name = userInfo.Profile + } else { + claims.name = getProfileNameFromEmail(claims.email) + } + } } - // Parse and verify ID Token payload. - idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil { log.Error(err.Error()) http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) return } - // Extract custom claims - claims := make(map[string]interface{}) - if err := idToken.Claims(&claims); err != nil { - log.Error(err.Error()) - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return - } - - //if len(provider.UsernameClaim) == 0 { - // provider.UsernameClaim = "preferred_username" - //} - usernameClaim, ok := claims[provider.UsernameClaim].(string) - if !ok { - log.Error(fmt.Errorf("claim '%s' missing from id_token or not a string", provider.UsernameClaim)) - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return - } - - //if len(provider.NameClaim) == 0 { - // provider.NameClaim = "preferred_username" - //} - nameClaim, ok := claims[provider.NameClaim].(string) - if !ok { - log.Error(fmt.Errorf("claim '%s' missing from id_token or not a string", provider.NameClaim)) - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return - } - - //if len(provider.EmailClaim) == 0 { - // provider.EmailClaim = "email" - //} - emailClaim, ok := claims[provider.EmailClaim].(string) - if !ok { - log.Error(fmt.Errorf("claim '%s' missing from id_token or not a string", provider.EmailClaim)) - http.Redirect(w, r, "/auth/login", http.StatusTemporaryRedirect) - return - } - - user, err := helpers.Store(r).GetUserByLoginOrEmail(usernameClaim, emailClaim) + user, err := helpers.Store(r).GetUserByLoginOrEmail("", claims.email) // ignore username because it creates a lot of problems if err != nil { user = db.User{ - Username: usernameClaim, - Name: nameClaim, - Email: emailClaim, + Username: claims.username, + Name: claims.name, + Email: claims.email, External: true, } user, err = helpers.Store(r).CreateUserWithoutPassword(user) diff --git a/util/config.go b/util/config.go index 45affe07..fc4ad7ee 100644 --- a/util/config.go +++ b/util/config.go @@ -59,7 +59,7 @@ type oidcEndpoint struct { Algorithms []string `json:"algorithms"` } -type oidcProvider struct { +type OidcProvider struct { ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` RedirectURL string `json:"redirect_url"` @@ -164,7 +164,7 @@ type ConfigType struct { SlackUrl string `json:"slack_url" env:"SEMAPHORE_SLACK_URL"` // oidc settings - OidcProviders map[string]oidcProvider `json:"oidc_providers"` + OidcProviders map[string]OidcProvider `json:"oidc_providers"` // task concurrency MaxParallelTasks int `json:"max_parallel_tasks" rule:"^[0-9]{1,10}$" env:"SEMAPHORE_MAX_PARALLEL_TASKS"`