diff --git a/models/auth/source.go b/models/auth/source.go index a3a250cd91d7c..fe121fb9194a8 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -235,13 +235,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..2ee85a77070e3 --- /dev/null +++ b/modules/structs/auth.go @@ -0,0 +1,13 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +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..b23533fadeb06 --- /dev/null +++ b/modules/structs/auth_oauth2.go @@ -0,0 +1,50 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// CreateUserOption create user options +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 +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..9bdf4b955665b --- /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.CreateAuthOauth2Option) + + 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 58d0891ea5789..6ac90165e2853 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1662,6 +1662,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/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, + } +}