diff --git a/routers/web/admin/auths.go b/routers/web/admin/auths.go index 799b7e8a84..dcdc8e6a2a 100644 --- a/routers/web/admin/auths.go +++ b/routers/web/admin/auths.go @@ -197,6 +197,7 @@ func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source { CustomURLMapping: customURLMapping, IconURL: form.Oauth2IconURL, Scopes: scopes, + AttributeSSHPublicKey: form.Oauth2AttributeSSHPublicKey, RequiredClaimName: form.Oauth2RequiredClaimName, RequiredClaimValue: form.Oauth2RequiredClaimValue, SkipLocalTwoFA: form.SkipLocalTwoFA, diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index fbdd47479a..62b7b0b6d3 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -17,6 +17,7 @@ import ( "sort" "strings" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" @@ -1183,8 +1184,62 @@ func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) { } } +func getSSHKeys(source *oauth2.Source, gothUser *goth.User) ([]string, error) { + key := source.AttributeSSHPublicKey + value, exists := gothUser.RawData[key] + if !exists { + return []string{}, nil + } + + rawSlice, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("unexpected type for SSH public key, expected []interface{} but got %T", value) + } + + sshKeys := make([]string, 0, len(rawSlice)) + for i, v := range rawSlice { + str, ok := v.(string) + if !ok { + return nil, fmt.Errorf("unexpected element type at index %d in SSH public key array, expected string but got %T", i, v) + } + sshKeys = append(sshKeys, str) + } + + return sshKeys, nil +} + +func updateSSHPubIfNeed( + ctx *context.Context, + authSource *auth.Source, + fetchedUser *goth.User, + user *user_model.User, +) error { + oauth2Source := authSource.Cfg.(*oauth2.Source) + + if oauth2Source.ProvidesSSHKeys() { + sshKeys, err := getSSHKeys(oauth2Source, fetchedUser) + if err != nil { + return err + } + + if asymkey_model.SynchronizePublicKeys(ctx, user, authSource, sshKeys) { + err = asymkey_model.RewriteAllPublicKeys(ctx) + if err != nil { + return err + } + } + } + + return nil +} + func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model.User, gothUser goth.User) { updateAvatarIfNeed(ctx, gothUser.AvatarURL, u) + err := updateSSHPubIfNeed(ctx, source, &gothUser, u) + if err != nil { + ctx.ServerError("updateSSHPubIfNeed", err) + return + } needs2FA := false if !source.Cfg.(*oauth2.Source).SkipLocalTwoFA { diff --git a/services/auth/source/oauth2/providers_base.go b/services/auth/source/oauth2/providers_base.go index 9d4ab106e5..63318b84ef 100644 --- a/services/auth/source/oauth2/providers_base.go +++ b/services/auth/source/oauth2/providers_base.go @@ -48,4 +48,8 @@ func (b *BaseProvider) CustomURLSettings() *CustomURLSettings { return nil } +func (b *BaseProvider) CanProvideSSHKeys() bool { + return false +} + var _ Provider = &BaseProvider{} diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go index 285876d5ac..f606581271 100644 --- a/services/auth/source/oauth2/providers_openid.go +++ b/services/auth/source/oauth2/providers_openid.go @@ -51,6 +51,10 @@ func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings { return nil } +func (o *OpenIDProvider) CanProvideSSHKeys() bool { + return true +} + var _ GothProvider = &OpenIDProvider{} func init() { diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 675005e55a..3f8616c6ff 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -4,6 +4,8 @@ package oauth2 import ( + "strings" + "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/modules/json" ) @@ -17,15 +19,16 @@ type Source struct { CustomURLMapping *CustomURLMapping IconURL string - Scopes []string - RequiredClaimName string - RequiredClaimValue string - GroupClaimName string - AdminGroup string - GroupTeamMap string - GroupTeamMapRemoval bool - RestrictedGroup string - SkipLocalTwoFA bool `json:",omitempty"` + Scopes []string + AttributeSSHPublicKey string + RequiredClaimName string + RequiredClaimValue string + GroupClaimName string + AdminGroup string + GroupTeamMap string + GroupTeamMapRemoval bool + RestrictedGroup string + SkipLocalTwoFA bool `json:",omitempty"` // reference to the authSource authSource *auth.Source @@ -41,6 +44,11 @@ func (source *Source) ToDB() ([]byte, error) { return json.Marshal(source) } +// ProvidesSSHKeys returns if this source provides SSH Keys +func (source *Source) ProvidesSSHKeys() bool { + return len(strings.TrimSpace(source.AttributeSSHPublicKey)) > 0 +} + // SetAuthSource sets the related AuthSource func (source *Source) SetAuthSource(authSource *auth.Source) { source.authSource = authSource diff --git a/services/forms/auth_form.go b/services/forms/auth_form.go index f0da63155a..39aae51756 100644 --- a/services/forms/auth_form.go +++ b/services/forms/auth_form.go @@ -75,6 +75,7 @@ type AuthenticationForm struct { Oauth2RestrictedGroup string Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"` Oauth2GroupTeamMapRemoval bool + Oauth2AttributeSSHPublicKey string SkipLocalTwoFA bool SSPIAutoCreateUsers bool SSPIAutoActivateUsers bool diff --git a/templates/admin/auth/edit.tmpl b/templates/admin/auth/edit.tmpl index 8a8bd61148..34d52ed224 100644 --- a/templates/admin/auth/edit.tmpl +++ b/templates/admin/auth/edit.tmpl @@ -326,19 +326,28 @@ - {{range .OAuth2Providers}}{{if .CustomURLSettings}} - - - - - - - {{end}}{{end}} + {{range .OAuth2Providers}} + {{if .CustomURLSettings}} + + + + + + + {{end}} + {{if .CanProvideSSHKeys}} + + {{end}} + {{end}}
+
+ + +
diff --git a/templates/admin/auth/source/oauth.tmpl b/templates/admin/auth/source/oauth.tmpl index 0560cc8256..7d0a64d269 100644 --- a/templates/admin/auth/source/oauth.tmpl +++ b/templates/admin/auth/source/oauth.tmpl @@ -63,19 +63,27 @@
- {{range .OAuth2Providers}}{{if .CustomURLSettings}} - - - - - - - {{end}}{{end}} - + {{range .OAuth2Providers}} + {{if .CustomURLSettings}} + + + + + + + {{end}} + {{if .CanProvideSSHKeys}} + + {{end}} + {{end}}
+
+ + +
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index f385b99e46..6b5a1feb16 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -691,6 +691,117 @@ func TestSignInOAuthCallbackRedirectToEscaping(t *testing.T) { assert.Equal(t, "/login/oauth/authorize?redirect_uri=https://translate.example.org", test.RedirectURL(resp)) } +func setupMockOIDCServer() *httptest.Server { + var mockServer *httptest.Server + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "issuer": "` + mockServer.URL + `", + "authorization_endpoint": "` + mockServer.URL + `/authorize", + "token_endpoint": "` + mockServer.URL + `/token", + "userinfo_endpoint": "` + mockServer.URL + `/userinfo" + }`)) + default: + http.NotFound(w, r) + } + })) + return mockServer +} + +func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) { + defer tests.PrepareTestEnv(t)() + mockServer := setupMockOIDCServer() + defer mockServer.Close() + + sourceName := "oidc" + authPayload := authSourcePayloadOpenIDConnect(sourceName, mockServer.URL+"/") + authPayload["oauth2_attribute_ssh_public_key"] = "sshpubkey" + authSource := addAuthSource(t, authPayload) + + userID := "5678" + user := &user_model.User{ + Name: "oidc.user", + Email: "oidc.user@example.com", + Passwd: "oidc.userpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: authSource.ID, + LoginName: userID, + IsActive: true, + } + defer createUser(context.Background(), t, user)() + + for _, tt := range []struct { + name string + rawData map[string]any + parsedKeySets []string + }{ + { + name: "Add keys", + rawData: map[string]any{ + "sshpubkey": []any{ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINDRDoephkaFELacrNNe2fqAwedhRB1MKOpLEHlPuczO nocomment", + }, + }, + parsedKeySets: []string{ + "SHA256:X/mW7JUQ8J8yhrKBbZ/pJni8qx7zPA1DTFsi8ftpDwg", + }, + }, + { + name: "Update keys", + rawData: map[string]any{ + "sshpubkey": []any{ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMLLMOLFMouSJmzOASKKv178d+7op4utSxcugF9tVVch nocomment", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGyDh9sg1IGQGa0U363wcGXrDlGBhZI3UHvS7we/0d+T nocomment", + }, + }, + parsedKeySets: []string{ + "SHA256:gsyG4JNmY5XoLBK5lSzuwD3EXcaDBiDKBkqDkpQTH6Q", + "SHA256:bbEKB1Qpumgk6QrgiN6t/kIvtUZvIQ8rqQBz8yYPzYw", + }, + }, + { + name: "Remove keys", + rawData: map[string]any{ + "sshpubkey": []any{}, + }, + parsedKeySets: []string{}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: sourceName, + UserID: userID, + Email: user.Email, + RawData: tt.rawData, + }, nil + })() + + session := emptyTestSession(t) + + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", sourceName)) + resp := session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + + req = NewRequest(t, "GET", "/user/settings/keys") + resp = session.MakeRequest(t, req, http.StatusOK) + + htmlDoc := NewHTMLParser(t, resp.Body) + divs := htmlDoc.doc.Find("#keys-ssh .flex-item .flex-item-body:not(:last-child)") + + syncedKeys := make([]string, divs.Length()) + for i := 0; i < divs.Length(); i++ { + syncedKeys[i] = strings.TrimSpace(divs.Eq(i).Text()) + } + + assert.ElementsMatch(t, tt.parsedKeySets, syncedKeys, "Unequal number of keys") + }) + } +} + func TestSignUpViaOAuthWithMissingFields(t *testing.T) { defer tests.PrepareTestEnv(t)() // enable auto-creation of accounts via OAuth2 diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js index 1a5bd6e490..a42d8261f1 100644 --- a/web_src/js/features/admin/common.js +++ b/web_src/js/features/admin/common.js @@ -62,7 +62,7 @@ export function initAdminCommon() { } function onOAuth2Change(applyDefaultValues) { - hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url'); + hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url, .oauth2_attribute_ssh_public_key'); for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) { input.removeAttribute('required'); } @@ -85,6 +85,10 @@ export function initAdminCommon() { } } } + const canProvideSSHKeys = document.getElementById(`${provider}_canProvideSSHKeys`); + if (canProvideSSHKeys) { + showElem('.oauth2_attribute_ssh_public_key'); + } onOAuth2UseCustomURLChange(applyDefaultValues); }