diff --git a/assets/go-licenses.json b/assets/go-licenses.json index d961444239633..b19f3f93a36ad 100644 --- a/assets/go-licenses.json +++ b/assets/go-licenses.json @@ -1269,4 +1269,4 @@ "path": "xorm.io/xorm/LICENSE", "licenseText": "Copyright (c) 2013 - 2015 The Xorm Authors\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n* Neither the name of the {organization} nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" } -] +] \ No newline at end of file diff --git a/models/auth/source.go b/models/auth/source.go index 08cfc9615b07c..0a67ebd4abc0b 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -240,13 +240,18 @@ func CreateSource(ctx context.Context, source *Source) error { err = registerableSource.RegisterSource() if err != nil { // remove the AuthSource in case of errors while registering configuration - if _, err := db.GetEngine(ctx).ID(source.ID).Delete(new(Source)); err != nil { + if err := DeleteSource(ctx, source.ID); err != nil { log.Error("CreateSource: Error while wrapOpenIDConnectInitializeError: %v", err) } } return err } +func DeleteSource(ctx context.Context, id int64) error { + _, err := db.GetEngine(ctx).ID(id).Delete(new(Source)) + return err +} + type FindSourcesOptions struct { db.ListOptions IsActive optional.Option[bool] diff --git a/modules/structs/auth.go b/modules/structs/auth.go new file mode 100644 index 0000000000000..fcfe0cfaf9cc9 --- /dev/null +++ b/modules/structs/auth.go @@ -0,0 +1,14 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// swagger:model +type AuthSourceOption struct { + ID int64 `json:"id"` + AuthenticationName string `json:"authentication_name" binding:"Required"` + TypeName string `json:"type_name"` + + IsActive bool `json:"is_active"` + IsSyncEnabled bool `json:"is_sync_enabled"` +} diff --git a/modules/structs/auth_oauth2.go b/modules/structs/auth_oauth2.go new file mode 100644 index 0000000000000..ee62b38669d0a --- /dev/null +++ b/modules/structs/auth_oauth2.go @@ -0,0 +1,52 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// CreateUserOption create user options +// swagger:model +type CreateAuthOauth2Option struct { + AuthenticationName string `json:"authentication_name" binding:"Required"` + ProviderIconURL string `json:"provider_icon_url"` + ProviderClientID string `json:"provider_client_id" binding:"Required"` + ProviderClientSecret string `json:"provider_client_secret" binding:"Required"` + ProviderAutoDiscoveryURL string `json:"provider_auto_discovery_url" binding:"Required"` + + SkipLocal2FA bool `json:"skip_local_2fa"` + AdditionalScopes string `json:"additional_scopes"` + RequiredClaimName string `json:"required_claim_name"` + RequiredClaimValue string `json:"required_claim_value"` + + ClaimNameProvidingGroupNameForSource string `json:"claim_name_providingGroupNameForSource"` + GroupClaimValueForAdministratorUsers string `json:"group_claim_value_for_administrator_users"` + GroupClaimValueForRestrictedUsers string `json:"group_claim_value_for_restricted_users"` + MapClaimedGroupsToOrganizationTeams string `json:"map_claimed_groups_to_organization_teams"` + + RemoveUsersFromSyncronizedTeams bool `json:"RemoveUsersFromSyncronizedTeams"` + EnableUserSyncronization bool `json:"EnableUserSyncronization"` + AuthenticationSourceIsActive bool `json:"AuthenticationSourceIsActive"` +} + +// EditUserOption edit user options +// swagger:model +type EditAuthOauth2Option struct { + AuthenticationName string `json:"authentication_name" binding:"Required"` + ProviderIconURL string `json:"provider_icon_url"` + ProviderClientID string `json:"provider_client_id" binding:"Required"` + ProviderClientSecret string `json:"provider_client_secret" binding:"Required"` + ProviderAutoDiscoveryURL string `json:"provider_auto_discovery_url" binding:"Required"` + + SkipLocal2FA bool `json:"skip_local_2fa"` + AdditionalScopes string `json:"additional_scopes"` + RequiredClaimName string `json:"required_claim_name"` + RequiredClaimValue string `json:"required_claim_value"` + + ClaimNameProvidingGroupNameForSource string `json:"claim_name_providingGroupNameForSource"` + GroupClaimValueForAdministratorUsers string `json:"group_claim_value_for_administrator_users"` + GroupClaimValueForRestrictedUsers string `json:"group_claim_value_for_restricted_users"` + MapClaimedGroupsToOrganizationTeams string `json:"map_claimed_groups_to_organization_teams"` + + RemoveUsersFromSyncronizedTeams bool `json:"RemoveUsersFromSyncronizedTeams"` + EnableUserSyncronization bool `json:"EnableUserSyncronization"` + AuthenticationSourceIsActive bool `json:"AuthenticationSourceIsActive"` +} diff --git a/routers/api/v1/admin/auth.go b/routers/api/v1/admin/auth.go new file mode 100644 index 0000000000000..2acfaadc75cc9 --- /dev/null +++ b/routers/api/v1/admin/auth.go @@ -0,0 +1,59 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// SearchAuth API for getting information of the configured authentication methods according the filter conditions +func SearchAuth(ctx *context.APIContext) { + // swagger:operation GET /admin/identity-auth admin adminSearchAuth + // --- + // summary: Search authentication sources + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // description: "SearchResults of authentication sources" + // schema: + // type: array + // items: + // "$ref": "#/definitions/AuthOauth2Option" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + + authSources, maxResults, err := db.FindAndCount[auth_model.Source](ctx, auth_model.FindSourcesOptions{}) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + results := make([]*api.AuthSourceOption, len(authSources)) + for i := range authSources { + results[i] = convert.ToOauthProvider(ctx, authSources[i]) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &results) +} diff --git a/routers/api/v1/admin/auth_oauth.go b/routers/api/v1/admin/auth_oauth.go new file mode 100644 index 0000000000000..b3fd3dc86185f --- /dev/null +++ b/routers/api/v1/admin/auth_oauth.go @@ -0,0 +1,270 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/auth/source/oauth2" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// CreateOauthAuth create a new external authentication for oauth2 +func CreateOauthAuth(ctx *context.APIContext) { + // swagger:operation PUT /admin/identity-auth/oauth admin adminCreateOauth2Auth + // --- + // summary: Create an OAuth2 authentication source + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateAuthOauth2Option" + // responses: + // "201": + // description: OAuth2 authentication source created successfully + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateAuthOauth2Option) + + discoveryURL, err := url.Parse(form.ProviderAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { + _ = fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", form.ProviderAutoDiscoveryURL) + ctx.HTTPError(http.StatusBadRequest, fmt.Sprintf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", form.ProviderAutoDiscoveryURL)) + } + + config := &oauth2.Source{ + Provider: "openidConnect", + ClientID: form.ProviderClientID, + ClientSecret: form.ProviderClientSecret, + OpenIDConnectAutoDiscoveryURL: form.ProviderAutoDiscoveryURL, + CustomURLMapping: nil, + IconURL: form.ProviderIconURL, + Scopes: []string{}, + RequiredClaimName: form.RequiredClaimName, + RequiredClaimValue: form.RequiredClaimValue, + SkipLocalTwoFA: form.SkipLocal2FA, + + GroupClaimName: form.ClaimNameProvidingGroupNameForSource, + RestrictedGroup: form.GroupClaimValueForRestrictedUsers, + AdminGroup: form.GroupClaimValueForAdministratorUsers, + GroupTeamMap: form.MapClaimedGroupsToOrganizationTeams, + GroupTeamMapRemoval: form.RemoveUsersFromSyncronizedTeams, + } + + createErr := auth_model.CreateSource(ctx, &auth_model.Source{ + Type: auth_model.OAuth2, + Name: form.AuthenticationName, + IsActive: true, + Cfg: config, + }) + + if createErr != nil { + ctx.APIErrorInternal(createErr) + return + } + + ctx.Status(http.StatusCreated) +} + +// EditOauthAuth api for modifying a authentication method +func EditOauthAuth(ctx *context.APIContext) { + // swagger:operation PATCH /admin/identity-auth/oauth/{id} admin adminEditOauth2Auth + // --- + // summary: Update an OAuth2 authentication source + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: authentication source ID + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateAuthOauth2Option" + // responses: + // "201": + // description: OAuth2 authentication source updated successfully + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + oauthIDString := ctx.PathParam("id") + oauthID, oauthIDErr := strconv.Atoi(oauthIDString) + if oauthIDErr != nil { + ctx.APIErrorInternal(oauthIDErr) + } + + source, sourceErr := auth_model.GetSourceByID(ctx, int64(oauthID)) + if sourceErr != nil { + ctx.APIErrorInternal(sourceErr) + return + } + + if source.Type != auth_model.OAuth2 { + ctx.APIErrorNotFound() + return + } + + form := web.GetForm(ctx).(*api.EditAuthOauth2Option) + + config := &oauth2.Source{ + Provider: "openidConnect", + ClientID: form.ProviderClientID, + ClientSecret: form.ProviderClientSecret, + OpenIDConnectAutoDiscoveryURL: form.ProviderAutoDiscoveryURL, + CustomURLMapping: nil, + IconURL: form.ProviderIconURL, + Scopes: []string{}, + RequiredClaimName: form.RequiredClaimName, + RequiredClaimValue: form.RequiredClaimValue, + SkipLocalTwoFA: form.SkipLocal2FA, + + GroupClaimName: form.ClaimNameProvidingGroupNameForSource, + RestrictedGroup: form.GroupClaimValueForRestrictedUsers, + AdminGroup: form.GroupClaimValueForAdministratorUsers, + GroupTeamMap: form.MapClaimedGroupsToOrganizationTeams, + GroupTeamMapRemoval: form.RemoveUsersFromSyncronizedTeams, + } + + updateErr := auth_model.UpdateSource(ctx, &auth_model.Source{ + ID: int64(oauthID), + Type: auth_model.OAuth2, + Name: form.AuthenticationName, + IsActive: true, + Cfg: config, + }) + + if updateErr != nil { + ctx.APIErrorInternal(updateErr) + return + } + + ctx.Status(http.StatusCreated) +} + +// DeleteOauthAuth api for deleting a authentication method +func DeleteOauthAuth(ctx *context.APIContext) { + // swagger:operation DELETE /admin/identity-auth/oauth/{id} admin adminDeleteOauth2Auth + // --- + // summary: Delete an OAuth2 authentication source + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: authentication source ID + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // description: OAuth2 authentication source deleted successfully + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + oauthIDString := ctx.PathParam("id") + oauthID, oauthIDErr := strconv.Atoi(oauthIDString) + if oauthIDErr != nil { + ctx.APIErrorInternal(oauthIDErr) + } + + source, sourceErr := auth_model.GetSourceByID(ctx, int64(oauthID)) + if sourceErr != nil { + ctx.APIErrorInternal(sourceErr) + return + } + + if source.Type != auth_model.OAuth2 { + ctx.APIErrorNotFound() + return + } + + err := auth_model.DeleteSource(ctx, int64(oauthID)) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusOK) +} + +// SearchOauthAuth API for getting information of the configured authentication methods according the filter conditions +func SearchOauthAuth(ctx *context.APIContext) { + // swagger:operation GET /admin/identity-auth/oauth admin adminSearchOauth2Auth + // --- + // summary: Search OAuth2 authentication sources + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // description: "SearchResults of OAuth2 authentication sources" + // schema: + // type: array + // items: + // "$ref": "#/definitions/AuthOauth2Option" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + + authSources, maxResults, err := db.FindAndCount[auth_model.Source](ctx, auth_model.FindSourcesOptions{ + LoginType: auth_model.OAuth2, + }) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + results := make([]*api.AuthSourceOption, len(authSources)) + for i := range authSources { + results[i] = convert.ToOauthProvider(ctx, authSources[i]) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &results) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index f412e8a06caca..f264497422377 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1710,6 +1710,16 @@ func Routes() *web.Router { }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly()) m.Group("/admin", func() { + m.Group("/identity-auth", func() { + m.Get("", admin.SearchAuth) + m.Group("/oauth", func() { + m.Get("", admin.SearchOauthAuth) + m.Put("", bind(api.CreateAuthOauth2Option{}), admin.CreateOauthAuth) + m.Patch("/{id}", bind(api.EditAuthOauth2Option{}), admin.EditOauthAuth) + m.Delete("/{id}", admin.DeleteOauthAuth) + }) + }) + m.Group("/cron", func() { m.Get("", admin.ListCronTasks) m.Post("/{task}", admin.PostCronTask) diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 00d89b3481be2..b0b07feaa5487 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -27,6 +27,7 @@ type Source struct { GroupTeamMap string GroupTeamMapRemoval bool RestrictedGroup string + SkipLocalTwoFA bool SSHPublicKeyClaimName string FullNameClaimName string diff --git a/services/convert/auth_oauth.go b/services/convert/auth_oauth.go new file mode 100644 index 0000000000000..c01b1bdeac7ca --- /dev/null +++ b/services/convert/auth_oauth.go @@ -0,0 +1,40 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" +) + +// ToOauthProvider convert auth_model.Source≤ to api.AuthOauth2Option +func ToOauthProvider(ctx context.Context, provider *auth_model.Source) *api.AuthSourceOption { + if provider == nil { + return nil + } + + return toOauthProvider(provider) +} + +// ToOauthProviders convert list of auth_model.Source to list of api.AuthOauth2Option +func ToOauthProviders(ctx context.Context, provider []*auth_model.Source) []*api.AuthSourceOption { + result := make([]*api.AuthSourceOption, len(provider)) + for i := range provider { + result[i] = ToOauthProvider(ctx, provider[i]) + } + return result +} + +func toOauthProvider(provider *auth_model.Source) *api.AuthSourceOption { + return &api.AuthSourceOption{ + ID: provider.ID, + AuthenticationName: provider.Name, + TypeName: provider.Type.String(), + + IsActive: provider.IsActive, + IsSyncEnabled: provider.IsSyncEnabled, + } +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 35c743dcd4a24..c603bcf6f7bcc 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -21,7 +21,7 @@ }, "version": "{{.SwaggerAppVer}}" }, - "basePath": "{{.SwaggerAppSubUrl}}/api/v1", + "basePath": "/{{.SwaggerAppSubUrl}}/api/v1", "paths": { "/activitypub/user-id/{user-id}": { "get": { @@ -582,6 +582,207 @@ } } }, + "/admin/identity-auth": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Search authentication sources", + "operationId": "adminSearchAuth", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "SearchResults of authentication sources", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthOauth2Option" + } + } + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, + "/admin/identity-auth/oauth": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Search OAuth2 authentication sources", + "operationId": "adminSearchOauth2Auth", + "parameters": [ + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "SearchResults of OAuth2 authentication sources", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/AuthOauth2Option" + } + } + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create an OAuth2 authentication source", + "operationId": "adminCreateOauth2Auth", + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateAuthOauth2Option" + } + } + ], + "responses": { + "201": { + "description": "OAuth2 authentication source created successfully" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, + "/admin/identity-auth/oauth/{id}": { + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete an OAuth2 authentication source", + "operationId": "adminDeleteOauth2Auth", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "authentication source ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OAuth2 authentication source deleted successfully" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update an OAuth2 authentication source", + "operationId": "adminEditOauth2Auth", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "authentication source ID", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateAuthOauth2Option" + } + } + ], + "responses": { + "201": { + "description": "OAuth2 authentication source updated successfully" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" + } + } + } + }, "/admin/orgs": { "get": { "produces": [