diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/models/auth/source.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/models/auth/source.go
new file mode 100644
index 00000000..ecd3abc3
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/models/auth/source.go
@@ -0,0 +1,381 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+
+ "forgejo.org/models/db"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/optional"
+ "forgejo.org/modules/timeutil"
+ "forgejo.org/modules/util"
+
+ "xorm.io/builder"
+ "xorm.io/xorm"
+ "xorm.io/xorm/convert"
+)
+
+// Type represents an login type.
+type Type int
+
+// Note: new type must append to the end of list to maintain compatibility.
+const (
+ NoType Type = iota
+ Plain // 1
+ LDAP // 2
+ SMTP // 3
+ PAM // 4
+ DLDAP // 5
+ OAuth2 // 6
+ _ // 7 (was SSPI)
+ Remote // 8
+)
+
+// String returns the string name of the LoginType
+func (typ Type) String() string {
+ return Names[typ]
+}
+
+// Int returns the int value of the LoginType
+func (typ Type) Int() int {
+ return int(typ)
+}
+
+// Names contains the name of LoginType values.
+var Names = map[Type]string{
+ LDAP: "LDAP (via BindDN)",
+ DLDAP: "LDAP (simple auth)", // Via direct bind
+ SMTP: "SMTP",
+ PAM: "PAM",
+ OAuth2: "OAuth2",
+ Remote: "Remote",
+}
+
+// Config represents login config as far as the db is concerned
+type Config interface {
+ convert.Conversion
+}
+
+// SkipVerifiable configurations provide a IsSkipVerify to check if SkipVerify is set
+type SkipVerifiable interface {
+ IsSkipVerify() bool
+}
+
+// HasTLSer configurations provide a HasTLS to check if TLS can be enabled
+type HasTLSer interface {
+ HasTLS() bool
+}
+
+// UseTLSer configurations provide a HasTLS to check if TLS is enabled
+type UseTLSer interface {
+ UseTLS() bool
+}
+
+// SSHKeyProvider configurations provide ProvidesSSHKeys to check if they provide SSHKeys
+type SSHKeyProvider interface {
+ ProvidesSSHKeys() bool
+}
+
+// RegisterableSource configurations provide RegisterSource which needs to be run on creation
+type RegisterableSource interface {
+ RegisterSource() error
+ UnregisterSource() error
+}
+
+var registeredConfigs = map[Type]func() Config{}
+
+// RegisterTypeConfig register a config for a provided type
+func RegisterTypeConfig(typ Type, exemplar Config) {
+ if reflect.TypeOf(exemplar).Kind() == reflect.Ptr {
+ // Pointer:
+ registeredConfigs[typ] = func() Config {
+ return reflect.New(reflect.ValueOf(exemplar).Elem().Type()).Interface().(Config)
+ }
+ return
+ }
+
+ // Not a Pointer
+ registeredConfigs[typ] = func() Config {
+ return reflect.New(reflect.TypeOf(exemplar)).Elem().Interface().(Config)
+ }
+}
+
+// SourceSettable configurations can have their authSource set on them
+type SourceSettable interface {
+ SetAuthSource(*Source)
+}
+
+// Source represents an external way for authorizing users.
+type Source struct {
+ ID int64 `xorm:"pk autoincr"`
+ Type Type
+ Name string `xorm:"UNIQUE"`
+ IsActive bool `xorm:"INDEX NOT NULL DEFAULT false"`
+ IsSyncEnabled bool `xorm:"INDEX NOT NULL DEFAULT false"`
+ Cfg convert.Conversion `xorm:"TEXT"`
+
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+// TableName xorm will read the table name from this method
+func (Source) TableName() string {
+ return "login_source"
+}
+
+func init() {
+ db.RegisterModel(new(Source))
+}
+
+// BeforeSet is invoked from XORM before setting the value of a field of this object.
+func (source *Source) BeforeSet(colName string, val xorm.Cell) {
+ if colName == "type" {
+ typ := Type(db.Cell2Int64(val))
+ constructor, ok := registeredConfigs[typ]
+ if !ok {
+ return
+ }
+ source.Cfg = constructor()
+ if settable, ok := source.Cfg.(SourceSettable); ok {
+ settable.SetAuthSource(source)
+ }
+ }
+}
+
+// TypeName return name of this login source type.
+func (source *Source) TypeName() string {
+ return Names[source.Type]
+}
+
+// IsLDAP returns true of this source is of the LDAP type.
+func (source *Source) IsLDAP() bool {
+ return source.Type == LDAP
+}
+
+// IsDLDAP returns true of this source is of the DLDAP type.
+func (source *Source) IsDLDAP() bool {
+ return source.Type == DLDAP
+}
+
+// IsSMTP returns true of this source is of the SMTP type.
+func (source *Source) IsSMTP() bool {
+ return source.Type == SMTP
+}
+
+// IsPAM returns true of this source is of the PAM type.
+func (source *Source) IsPAM() bool {
+ return source.Type == PAM
+}
+
+// IsOAuth2 returns true of this source is of the OAuth2 type.
+func (source *Source) IsOAuth2() bool {
+ return source.Type == OAuth2
+}
+
+func (source *Source) IsRemote() bool {
+ return source.Type == Remote
+}
+
+// HasTLS returns true of this source supports TLS.
+func (source *Source) HasTLS() bool {
+ hasTLSer, ok := source.Cfg.(HasTLSer)
+ return ok && hasTLSer.HasTLS()
+}
+
+// UseTLS returns true of this source is configured to use TLS.
+func (source *Source) UseTLS() bool {
+ useTLSer, ok := source.Cfg.(UseTLSer)
+ return ok && useTLSer.UseTLS()
+}
+
+// SkipVerify returns true if this source is configured to skip SSL
+// verification.
+func (source *Source) SkipVerify() bool {
+ skipVerifiable, ok := source.Cfg.(SkipVerifiable)
+ return ok && skipVerifiable.IsSkipVerify()
+}
+
+// CreateSource inserts a AuthSource in the DB if not already
+// existing with the given name.
+func CreateSource(ctx context.Context, source *Source) error {
+ has, err := db.GetEngine(ctx).Where("name=?", source.Name).Exist(new(Source))
+ if err != nil {
+ return err
+ } else if has {
+ return ErrSourceAlreadyExist{source.Name}
+ }
+ // Synchronization is only available with LDAP for now
+ if !source.IsLDAP() {
+ source.IsSyncEnabled = false
+ }
+
+ _, err = db.GetEngine(ctx).Insert(source)
+ if err != nil {
+ return err
+ }
+
+ if !source.IsActive {
+ return nil
+ }
+
+ if settable, ok := source.Cfg.(SourceSettable); ok {
+ settable.SetAuthSource(source)
+ }
+
+ registerableSource, ok := source.Cfg.(RegisterableSource)
+ if !ok {
+ return nil
+ }
+
+ 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 {
+ log.Error("CreateSource: Error while wrapOpenIDConnectInitializeError: %v", err)
+ }
+ }
+ return err
+}
+
+type FindSourcesOptions struct {
+ db.ListOptions
+ IsActive optional.Option[bool]
+ LoginType Type
+}
+
+func (opts FindSourcesOptions) ToConds() builder.Cond {
+ conds := builder.NewCond()
+ if opts.IsActive.Has() {
+ conds = conds.And(builder.Eq{"is_active": opts.IsActive.Value()})
+ }
+ if opts.LoginType != NoType {
+ conds = conds.And(builder.Eq{"`type`": opts.LoginType})
+ }
+ return conds
+}
+
+// GetSourceByID returns login source by given ID.
+func GetSourceByID(ctx context.Context, id int64) (*Source, error) {
+ source := new(Source)
+ if id == 0 {
+ source.Cfg = registeredConfigs[NoType]()
+ // Set this source to active
+ // FIXME: allow disabling of db based password authentication in future
+ source.IsActive = true
+ return source, nil
+ }
+
+ has, err := db.GetEngine(ctx).ID(id).Get(source)
+ if err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrSourceNotExist{id}
+ }
+ return source, nil
+}
+
+// UpdateSource updates a Source record in DB.
+func UpdateSource(ctx context.Context, source *Source) error {
+ var originalSource *Source
+ if source.IsOAuth2() {
+ // keep track of the original values so we can restore in case of errors while registering OAuth2 providers
+ var err error
+ if originalSource, err = GetSourceByID(ctx, source.ID); err != nil {
+ return err
+ }
+ }
+
+ has, err := db.GetEngine(ctx).Where("name=? AND id!=?", source.Name, source.ID).Exist(new(Source))
+ if err != nil {
+ return err
+ } else if has {
+ return ErrSourceAlreadyExist{source.Name}
+ }
+
+ _, err = db.GetEngine(ctx).ID(source.ID).AllCols().Update(source)
+ if err != nil {
+ return err
+ }
+
+ if !source.IsActive {
+ return nil
+ }
+
+ if settable, ok := source.Cfg.(SourceSettable); ok {
+ settable.SetAuthSource(source)
+ }
+
+ registerableSource, ok := source.Cfg.(RegisterableSource)
+ if !ok {
+ return nil
+ }
+
+ err = registerableSource.RegisterSource()
+ if err != nil {
+ // restore original values since we cannot update the provider it self
+ if _, err := db.GetEngine(ctx).ID(source.ID).AllCols().Update(originalSource); err != nil {
+ log.Error("UpdateSource: Error while wrapOpenIDConnectInitializeError: %v", err)
+ }
+ }
+ return err
+}
+
+// ErrSourceNotExist represents a "SourceNotExist" kind of error.
+type ErrSourceNotExist struct {
+ ID int64
+}
+
+// IsErrSourceNotExist checks if an error is a ErrSourceNotExist.
+func IsErrSourceNotExist(err error) bool {
+ _, ok := err.(ErrSourceNotExist)
+ return ok
+}
+
+func (err ErrSourceNotExist) Error() string {
+ return fmt.Sprintf("login source does not exist [id: %d]", err.ID)
+}
+
+// Unwrap unwraps this as a ErrNotExist err
+func (err ErrSourceNotExist) Unwrap() error {
+ return util.ErrNotExist
+}
+
+// ErrSourceAlreadyExist represents a "SourceAlreadyExist" kind of error.
+type ErrSourceAlreadyExist struct {
+ Name string
+}
+
+// IsErrSourceAlreadyExist checks if an error is a ErrSourceAlreadyExist.
+func IsErrSourceAlreadyExist(err error) bool {
+ _, ok := err.(ErrSourceAlreadyExist)
+ return ok
+}
+
+func (err ErrSourceAlreadyExist) Error() string {
+ return fmt.Sprintf("login source already exists [name: %s]", err.Name)
+}
+
+// Unwrap unwraps this as a ErrExist err
+func (err ErrSourceAlreadyExist) Unwrap() error {
+ return util.ErrAlreadyExist
+}
+
+// ErrSourceInUse represents a "SourceInUse" kind of error.
+type ErrSourceInUse struct {
+ ID int64
+}
+
+// IsErrSourceInUse checks if an error is a ErrSourceInUse.
+func IsErrSourceInUse(err error) bool {
+ _, ok := err.(ErrSourceInUse)
+ return ok
+}
+
+func (err ErrSourceInUse) Error() string {
+ return fmt.Sprintf("login source is still used by some users [id: %d]", err.ID)
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source.go
new file mode 100644
index 00000000..b13554ef
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source.go
@@ -0,0 +1,42 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "context"
+
+ "forgejo.org/models/auth"
+ "forgejo.org/models/db"
+ user_model "forgejo.org/models/user"
+)
+
+// DeleteSource deletes a AuthSource record in DB.
+func DeleteSource(ctx context.Context, source *auth.Source) error {
+ count, err := db.GetEngine(ctx).Count(&user_model.User{LoginSource: source.ID})
+ if err != nil {
+ return err
+ } else if count > 0 {
+ return auth.ErrSourceInUse{
+ ID: source.ID,
+ }
+ }
+
+ count, err = db.GetEngine(ctx).Count(&user_model.ExternalLoginUser{LoginSourceID: source.ID})
+ if err != nil {
+ return err
+ } else if count > 0 {
+ return auth.ErrSourceInUse{
+ ID: source.ID,
+ }
+ }
+
+ if registerableSource, ok := source.Cfg.(auth.RegisterableSource); ok {
+ if err := registerableSource.UnregisterSource(); err != nil {
+ return err
+ }
+ }
+
+ _, err = db.GetEngine(ctx).ID(source.ID).Delete(new(auth.Source))
+ return err
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source/oauth2/source_register.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source/oauth2/source_register.go
new file mode 100644
index 00000000..82a36aca
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/evidence/services/auth/source/oauth2/source_register.go
@@ -0,0 +1,50 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "fmt"
+)
+
+// RegisterSource causes an OAuth2 configuration to be registered
+func (source *Source) RegisterSource() error {
+ err := RegisterProviderWithGothic(source.authSource.Name, source)
+ return wrapOpenIDConnectInitializeError(err, source.authSource.Name, source)
+}
+
+// UnregisterSource causes an OAuth2 configuration to be unregistered
+func (source *Source) UnregisterSource() error {
+ RemoveProviderFromGothic(source.authSource.Name)
+ return nil
+}
+
+// ErrOpenIDConnectInitialize represents a "OpenIDConnectInitialize" kind of error.
+type ErrOpenIDConnectInitialize struct {
+ OpenIDConnectAutoDiscoveryURL string
+ ProviderName string
+ Cause error
+}
+
+// IsErrOpenIDConnectInitialize checks if an error is a ExternalLoginUserAlreadyExist.
+func IsErrOpenIDConnectInitialize(err error) bool {
+ _, ok := err.(ErrOpenIDConnectInitialize)
+ return ok
+}
+
+func (err ErrOpenIDConnectInitialize) Error() string {
+ return fmt.Sprintf("Failed to initialize OpenID Connect Provider with name '%s' with url '%s': %v", err.ProviderName, err.OpenIDConnectAutoDiscoveryURL, err.Cause)
+}
+
+func (err ErrOpenIDConnectInitialize) Unwrap() error {
+ return err.Cause
+}
+
+// wrapOpenIDConnectInitializeError is used to wrap the error but this cannot be done in modules/auth/oauth2
+// inside oauth2: import cycle not allowed models -> modules/auth/oauth2 -> models
+func wrapOpenIDConnectInitializeError(err error, providerName string, source *Source) error {
+ if err != nil && source.Provider == "openidConnect" {
+ err = ErrOpenIDConnectInitialize{ProviderName: providerName, OpenIDConnectAutoDiscoveryURL: source.OpenIDConnectAutoDiscoveryURL, Cause: err}
+ }
+ return err
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/forgejo-source.patch b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/forgejo-source.patch
new file mode 100644
index 00000000..90d5131e
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/forgejo-source.patch
@@ -0,0 +1,484 @@
+--- a/cmd/admin_auth_oauth.go
++++ b/cmd/admin_auth_oauth.go
+@@ -94,6 +94,10 @@
+ Name: "required-claim-value",
+ Value: "",
+ Usage: "Claim value that has to be set to allow users to login with this source",
++ },
++ &cli.StringSliceFlag{
++ Name: "required-claim-values",
++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)",
+ },
+ &cli.StringFlag{
+ Name: "group-claim-name",
+@@ -160,6 +164,7 @@
+ Scopes: c.StringSlice("scopes"),
+ RequiredClaimName: c.String("required-claim-name"),
+ RequiredClaimValue: c.String("required-claim-value"),
++ RequiredClaimValues: c.StringSlice("required-claim-values"),
+ GroupClaimName: c.String("group-claim-name"),
+ AdminGroup: c.String("admin-group"),
+ RestrictedGroup: c.String("restricted-group"),
+@@ -177,6 +182,12 @@
+ }
+
+ config := parseOAuth2Config(c)
++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil {
++ return err
++ }
++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" {
++ return errors.New("required-claim-values requires openidConnect")
++ }
+ if config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+@@ -246,6 +257,10 @@
+ oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+ }
+
++ if c.IsSet("required-claim-values") {
++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values")
++ }
++
+ if c.IsSet("group-claim-name") {
+ oAuth2Config.GroupClaimName = c.String("group-claim-name")
+ }
+@@ -293,6 +308,12 @@
+ }
+
+ oAuth2Config.CustomURLMapping = customURLMapping
++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil {
++ return err
++ }
++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" {
++ return errors.New("required-claim-values requires openidConnect")
++ }
+ source.Cfg = oAuth2Config
+
+ return auth_model.UpdateSource(ctx, source)
+--- a/routers/web/admin/auths.go
++++ b/routers/web/admin/auths.go
+@@ -322,6 +322,13 @@
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
++ // The legacy form has no representation for the opt-in current-UserInfo gate.
++ // Refuse rather than silently dropping it during source reconstruction.
++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil {
++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form)
++ return
++ }
++
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ return
+--- a/routers/web/auth/oauth.go
++++ b/routers/web/auth/oauth.go
+@@ -1427,7 +1427,11 @@
+ return goth.User{}, err
+ }
+
+- if oauth2Source.RequiredClaimName != "" {
++ if oauth2Source.RequiredClaimValues != nil {
++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) {
++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
++ }
++ } else if oauth2Source.RequiredClaimName != "" {
+ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+ if !has {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+--- a/services/auth/source/oauth2/providers_openid.go
++++ b/services/auth/source/oauth2/providers_openid.go
+@@ -4,6 +4,7 @@
+ package oauth2
+
+ import (
++ "errors"
+ "html/template"
+
+ "forgejo.org/modules/log"
+@@ -43,6 +44,22 @@
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
++ if err != nil {
++ return nil, err
++ }
++ if source.RequiredClaimValues != nil {
++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil {
++ return nil, err
++ }
++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest {
++ return nil, errors.New("required-claim-values needs current UserInfo")
++ }
++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName)
++ if err != nil {
++ return nil, err
++ }
++ provider.HTTPClient = guarded
++ }
+ return provider, err
+ }
+
+--- /dev/null
++++ b/services/auth/source/oauth2/required_claim_values.go
+@@ -0,0 +1,202 @@
++// Copyright 2026 The Forgejo Authors. All rights reserved.
++// SPDX-License-Identifier: MIT
++
++package oauth2
++
++import (
++ "bytes"
++ "encoding/json"
++ "errors"
++ "io"
++ "net/http"
++ "net/url"
++ "strings"
++ "unicode"
++ "unicode/utf8"
++)
++
++const maxClaimValues = 1024
++const maxUserInfoBytes = 1 << 20
++
++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil
++// list retains every legacy single-value behavior. An explicitly empty list
++// fails closed rather than turning a configured gate into claim-presence-only.
++func ValidateRequiredClaimValues(name, legacy string, values []string) error {
++ if values == nil {
++ return nil
++ }
++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 {
++ return errors.New("invalid or conflicting required-claim-values configuration")
++ }
++ seen := map[string]bool{}
++ for _, value := range values {
++ if !validClaimLiteral(value) || seen[value] {
++ return errors.New("invalid required-claim-values literal")
++ }
++ seen[value] = true
++ }
++ return nil
++}
++
++func validClaimLiteral(value string) bool {
++ return value != "" && len(value) <= 256 && utf8.ValidString(value) &&
++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") &&
++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError })
++}
++
++// strictClaimValues accepts only a well-formed claim array. No scalar parsing,
++// coercion, regex or prefix matching is introduced into the new gate.
++func strictClaimValues(raw any) ([]string, bool) {
++ var values []string
++ switch v := raw.(type) {
++ case []string:
++ values = v
++ case []any:
++ values = make([]string, len(v))
++ for i, value := range v {
++ s, ok := value.(string)
++ if !ok {
++ return nil, false
++ }
++ values[i] = s
++ }
++ default:
++ return nil, false
++ }
++ if len(values) > maxClaimValues {
++ return nil, false
++ }
++ for _, value := range values {
++ if !validClaimLiteral(value) {
++ return nil, false
++ }
++ }
++ return values, true
++}
++
++// MatchRequiredClaimValues evaluates the new consumer gate against current
++// authenticated provider data; it does not mutate or manufacture issuer claims.
++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool {
++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil {
++ return false
++ }
++ raw, present := claims[name]
++ if !present {
++ return false
++ }
++ values, ok := strictClaimValues(raw)
++ if !ok {
++ return false
++ }
++ for _, value := range values {
++ for _, wanted := range required {
++ if value == wanted {
++ return true
++ }
++ }
++ }
++ return false
++}
++
++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and
++// adds a strict response guard for this opt-in source only. A current UserInfo
++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP
++// failures cannot fall back to the ID-token map in Goth's merging code.
++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) {
++ u, err := url.Parse(endpoint)
++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) {
++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint")
++ }
++ if client == nil {
++ client = http.DefaultClient
++ }
++ result := *client
++ base := result.Transport
++ if base == nil {
++ base = http.DefaultTransport
++ }
++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim}
++ // Redirects could evade endpoint matching or send a bearer to another origin.
++ // They are unsupported for the new guarded provider; legacy clients are intact.
++ result.CheckRedirect = func(*http.Request, []*http.Request) error {
++ return errors.New("guarded OAuth redirects are not supported")
++ }
++ return &result, nil
++}
++
++type currentClaimTransport struct {
++ base http.RoundTripper
++ endpoint, claim string
++}
++
++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) {
++ response, err := t.base.RoundTrip(req)
++ if req.URL.String() != t.endpoint {
++ return response, err
++ }
++ if err != nil {
++ if response != nil && response.Body != nil {
++ response.Body.Close()
++ }
++ return nil, errors.New("current UserInfo lookup failed")
++ }
++ if response == nil || response.Body == nil {
++ return nil, errors.New("current UserInfo response missing")
++ }
++ defer response.Body.Close()
++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK {
++ return nil, errors.New("current UserInfo refused")
++ }
++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1))
++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) {
++ return nil, errors.New("current UserInfo response invalid")
++ }
++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting
++ // groups or subject claim. json.RawMessage permits unrelated normal claims.
++ decoder := json.NewDecoder(bytes.NewReader(body))
++ token, err := decoder.Token()
++ if err != nil || token != json.Delim('{') {
++ return nil, errors.New("current UserInfo object required")
++ }
++ claims := map[string]json.RawMessage{}
++ for decoder.More() {
++ keyToken, err := decoder.Token()
++ if err != nil {
++ return nil, errors.New("current UserInfo key invalid")
++ }
++ key, ok := keyToken.(string)
++ if !ok {
++ return nil, errors.New("current UserInfo key invalid")
++ }
++ if _, duplicate := claims[key]; duplicate {
++ return nil, errors.New("current UserInfo duplicate claim")
++ }
++ var value json.RawMessage
++ if decoder.Decode(&value) != nil {
++ return nil, errors.New("current UserInfo value invalid")
++ }
++ claims[key] = value
++ }
++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') {
++ return nil, errors.New("current UserInfo object invalid")
++ }
++ var trailing any
++ if decoder.Decode(&trailing) != io.EOF {
++ return nil, errors.New("current UserInfo trailing data")
++ }
++ raw, present := claims[t.claim]
++ if !present {
++ return nil, errors.New("current UserInfo required claim missing")
++ }
++ var value any
++ if json.Unmarshal(raw, &value) != nil {
++ return nil, errors.New("current UserInfo required claim invalid")
++ }
++ if _, ok := strictClaimValues(value); !ok {
++ return nil, errors.New("current UserInfo required claim array invalid")
++ }
++ // Return the original public response bytes to Goth. Subject equality and
++ // audience/issuer checks remain in its existing authenticated provider path.
++ response.Body = io.NopCloser(bytes.NewReader(body))
++ return response, nil
++}
+--- /dev/null
++++ b/services/auth/source/oauth2/required_claim_values_test.go
+@@ -0,0 +1,144 @@
++// Copyright 2026 The Forgejo Authors. All rights reserved.
++// SPDX-License-Identifier: MIT
++package oauth2
++
++import (
++ "errors"
++ "io"
++ "net/http"
++ "reflect"
++ "strings"
++ "testing"
++)
++
++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}
++
++type roundTripFunc func(*http.Request) (*http.Response, error)
++
++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
++
++func mockUserInfo(body string, status int) *http.Client {
++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
++ })}
++}
++
++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) {
++ for _, group := range caseValues {
++ groups := []any{group, "unrelated"}
++ claims := map[string]any{"groups": groups, "other": "preserved"}
++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) {
++ t.Fatal("individual allowed group rejected")
++ }
++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) {
++ t.Fatal("claims mutated")
++ }
++ }
++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) {
++ t.Fatal("multiple allowed groups rejected")
++ }
++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} {
++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) {
++ t.Fatal("malformed/nonmatching claim accepted")
++ }
++ }
++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) {
++ t.Fatal("absent claim accepted")
++ }
++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) {
++ t.Fatal("unrelated claim used")
++ }
++}
++
++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) {
++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil {
++ t.Fatal("legacy config changed")
++ }
++ for _, cfg := range []struct {
++ name, legacy string
++ values []string
++ }{
++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues},
++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}},
++ } {
++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil {
++ t.Fatal("invalid config accepted")
++ }
++ }
++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil {
++ t.Fatal("case config invalid")
++ }
++}
++
++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) {
++ for _, body := range []string{
++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`,
++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`,
++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`,
++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1),
++ } {
++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := client.Get("https://issuer.example/userinfo")
++ if err == nil {
++ response.Body.Close()
++ t.Fatal("invalid current claim did not fail closed")
++ }
++ }
++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups")
++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil {
++ response.Body.Close()
++ t.Fatal("non200 accepted")
++ }
++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })}
++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups")
++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") {
++ t.Fatal("lookup failure not contained")
++ }
++}
++
++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) {
++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} {
++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := client.Get("https://issuer.example/userinfo")
++ if err != nil {
++ t.Fatal(err)
++ }
++ b, _ := io.ReadAll(response.Body)
++ response.Body.Close()
++ if string(b) != body {
++ t.Fatal("response changed")
++ }
++ }
++}
++
++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) {
++ original := mockUserInfo(`{"unrelated":true}`, 200)
++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := original.Get("https://issuer.example/userinfo")
++ if err != nil {
++ t.Fatal("original client mutated")
++ }
++ response.Body.Close()
++ response, err = guarded.Get("https://issuer.example/unrelated")
++ if err != nil {
++ t.Fatal("unrelated endpoint changed")
++ }
++ response.Body.Close()
++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil {
++ t.Fatal("redirect can bypass guard")
++ }
++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} {
++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil {
++ t.Fatal("invalid endpoint accepted")
++ }
++ }
++}
+--- a/services/auth/source/oauth2/source.go
++++ b/services/auth/source/oauth2/source.go
+@@ -22,6 +22,7 @@
+ Scopes []string
+ AttributeSSHPublicKey string
+ RequiredClaimName string
++ RequiredClaimValues []string `json:",omitempty"`
+ RequiredClaimValue string
+ GroupClaimName string
+ AdminGroup string
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/cmd/admin_auth_oauth.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/cmd/admin_auth_oauth.go
new file mode 100644
index 00000000..5aec60f7
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/cmd/admin_auth_oauth.go
@@ -0,0 +1,320 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ auth_model "forgejo.org/models/auth"
+ "forgejo.org/services/auth/source/oauth2"
+
+ "github.com/urfave/cli/v2"
+)
+
+var (
+ oauthCLIFlags = []cli.Flag{
+ &cli.StringFlag{
+ Name: "name",
+ Value: "",
+ Usage: "Application Name",
+ },
+ &cli.StringFlag{
+ Name: "provider",
+ Value: "",
+ Usage: "OAuth2 Provider",
+ },
+ &cli.StringFlag{
+ Name: "key",
+ Value: "",
+ Usage: "Client ID (Key)",
+ },
+ &cli.StringFlag{
+ Name: "secret",
+ Value: "",
+ Usage: "Client Secret",
+ },
+ &cli.StringFlag{
+ Name: "auto-discover-url",
+ Value: "",
+ Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)",
+ },
+ &cli.StringFlag{
+ Name: "use-custom-urls",
+ Value: "false",
+ Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints",
+ },
+ &cli.StringFlag{
+ Name: "custom-tenant-id",
+ Value: "",
+ Usage: "Use custom Tenant ID for OAuth endpoints",
+ },
+ &cli.StringFlag{
+ Name: "custom-auth-url",
+ Value: "",
+ Usage: "Use a custom Authorization URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-token-url",
+ Value: "",
+ Usage: "Use a custom Token URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-profile-url",
+ Value: "",
+ Usage: "Use a custom Profile URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-email-url",
+ Value: "",
+ Usage: "Use a custom Email URL (option for GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "icon-url",
+ Value: "",
+ Usage: "Custom icon URL for OAuth2 login source",
+ },
+ &cli.BoolFlag{
+ Name: "skip-local-2fa",
+ Usage: "Set to true to skip local 2fa for users authenticated by this source",
+ },
+ &cli.StringSliceFlag{
+ Name: "scopes",
+ Value: nil,
+ Usage: "Scopes to request when to authenticate against this OAuth2 source",
+ },
+ &cli.StringFlag{
+ Name: "required-claim-name",
+ Value: "",
+ Usage: "Claim name that has to be set to allow users to login with this source",
+ },
+ &cli.StringFlag{
+ Name: "required-claim-value",
+ Value: "",
+ Usage: "Claim value that has to be set to allow users to login with this source",
+ },
+ &cli.StringSliceFlag{
+ Name: "required-claim-values",
+ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)",
+ },
+ &cli.StringFlag{
+ Name: "group-claim-name",
+ Value: "",
+ Usage: "Claim name providing group names for this source",
+ },
+ &cli.StringFlag{
+ Name: "admin-group",
+ Value: "",
+ Usage: "Group Claim value for administrator users",
+ },
+ &cli.StringFlag{
+ Name: "restricted-group",
+ Value: "",
+ Usage: "Group Claim value for restricted users",
+ },
+ &cli.StringFlag{
+ Name: "group-team-map",
+ Value: "",
+ Usage: "JSON mapping between groups and org teams",
+ },
+ &cli.BoolFlag{
+ Name: "group-team-map-removal",
+ Usage: "Activate automatic team membership removal depending on groups",
+ },
+ }
+
+ microcmdAuthAddOauth = &cli.Command{
+ Name: "add-oauth",
+ Usage: "Add new Oauth authentication source",
+ Action: runAddOauth,
+ Flags: oauthCLIFlags,
+ }
+
+ microcmdAuthUpdateOauth = &cli.Command{
+ Name: "update-oauth",
+ Usage: "Update existing Oauth authentication source",
+ Action: runUpdateOauth,
+ Flags: append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...),
+ }
+)
+
+func parseOAuth2Config(c *cli.Context) *oauth2.Source {
+ var customURLMapping *oauth2.CustomURLMapping
+ if c.IsSet("use-custom-urls") {
+ customURLMapping = &oauth2.CustomURLMapping{
+ TokenURL: c.String("custom-token-url"),
+ AuthURL: c.String("custom-auth-url"),
+ ProfileURL: c.String("custom-profile-url"),
+ EmailURL: c.String("custom-email-url"),
+ Tenant: c.String("custom-tenant-id"),
+ }
+ } else {
+ customURLMapping = nil
+ }
+ return &oauth2.Source{
+ Provider: c.String("provider"),
+ ClientID: c.String("key"),
+ ClientSecret: c.String("secret"),
+ OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"),
+ CustomURLMapping: customURLMapping,
+ IconURL: c.String("icon-url"),
+ SkipLocalTwoFA: c.Bool("skip-local-2fa"),
+ Scopes: c.StringSlice("scopes"),
+ RequiredClaimName: c.String("required-claim-name"),
+ RequiredClaimValue: c.String("required-claim-value"),
+ RequiredClaimValues: c.StringSlice("required-claim-values"),
+ GroupClaimName: c.String("group-claim-name"),
+ AdminGroup: c.String("admin-group"),
+ RestrictedGroup: c.String("restricted-group"),
+ GroupTeamMap: c.String("group-team-map"),
+ GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
+ }
+}
+
+func runAddOauth(c *cli.Context) error {
+ ctx, cancel := installSignals()
+ defer cancel()
+
+ if err := initDB(ctx); err != nil {
+ return err
+ }
+
+ config := parseOAuth2Config(c)
+ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil {
+ return err
+ }
+ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" {
+ return errors.New("required-claim-values requires openidConnect")
+ }
+ if config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL)
+ }
+ }
+
+ return auth_model.CreateSource(ctx, &auth_model.Source{
+ Type: auth_model.OAuth2,
+ Name: c.String("name"),
+ IsActive: true,
+ Cfg: config,
+ })
+}
+
+func runUpdateOauth(c *cli.Context) error {
+ if !c.IsSet("id") {
+ return errors.New("--id flag is missing")
+ }
+
+ ctx, cancel := installSignals()
+ defer cancel()
+
+ if err := initDB(ctx); err != nil {
+ return err
+ }
+
+ source, err := auth_model.GetSourceByID(ctx, c.Int64("id"))
+ if err != nil {
+ return err
+ }
+
+ oAuth2Config := source.Cfg.(*oauth2.Source)
+
+ if c.IsSet("name") {
+ source.Name = c.String("name")
+ }
+
+ if c.IsSet("provider") {
+ oAuth2Config.Provider = c.String("provider")
+ }
+
+ if c.IsSet("key") {
+ oAuth2Config.ClientID = c.String("key")
+ }
+
+ if c.IsSet("secret") {
+ oAuth2Config.ClientSecret = c.String("secret")
+ }
+
+ if c.IsSet("auto-discover-url") {
+ oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url")
+ }
+
+ if c.IsSet("icon-url") {
+ oAuth2Config.IconURL = c.String("icon-url")
+ }
+
+ if c.IsSet("scopes") {
+ oAuth2Config.Scopes = c.StringSlice("scopes")
+ }
+
+ if c.IsSet("required-claim-name") {
+ oAuth2Config.RequiredClaimName = c.String("required-claim-name")
+ }
+ if c.IsSet("required-claim-value") {
+ oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+ }
+
+ if c.IsSet("required-claim-values") {
+ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values")
+ }
+
+ if c.IsSet("group-claim-name") {
+ oAuth2Config.GroupClaimName = c.String("group-claim-name")
+ }
+ if c.IsSet("admin-group") {
+ oAuth2Config.AdminGroup = c.String("admin-group")
+ }
+ if c.IsSet("restricted-group") {
+ oAuth2Config.RestrictedGroup = c.String("restricted-group")
+ }
+ if c.IsSet("group-team-map") {
+ oAuth2Config.GroupTeamMap = c.String("group-team-map")
+ }
+ if c.IsSet("group-team-map-removal") {
+ oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
+ }
+
+ // update custom URL mapping
+ customURLMapping := &oauth2.CustomURLMapping{}
+
+ if oAuth2Config.CustomURLMapping != nil {
+ customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL
+ customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL
+ customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL
+ customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL
+ customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant
+ }
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") {
+ customURLMapping.TokenURL = c.String("custom-token-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") {
+ customURLMapping.AuthURL = c.String("custom-auth-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") {
+ customURLMapping.ProfileURL = c.String("custom-profile-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") {
+ customURLMapping.EmailURL = c.String("custom-email-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") {
+ customURLMapping.Tenant = c.String("custom-tenant-id")
+ }
+
+ oAuth2Config.CustomURLMapping = customURLMapping
+ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil {
+ return err
+ }
+ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" {
+ return errors.New("required-claim-values requires openidConnect")
+ }
+ source.Cfg = oAuth2Config
+
+ return auth_model.UpdateSource(ctx, source)
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/go.mod b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/go.mod
new file mode 100644
index 00000000..cbeed643
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/go.mod
@@ -0,0 +1,257 @@
+module forgejo.org
+
+go 1.24
+
+toolchain go1.24.3
+
+require (
+ code.forgejo.org/f3/gof3/v3 v3.10.6
+ code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251
+ code.forgejo.org/forgejo/levelqueue v1.0.0
+ code.forgejo.org/forgejo/reply v1.0.2
+ code.forgejo.org/go-chi/binding v1.0.0
+ code.forgejo.org/go-chi/cache v1.0.0
+ code.forgejo.org/go-chi/captcha v1.0.1
+ code.forgejo.org/go-chi/session v1.0.1
+ code.gitea.io/actions-proto-go v0.4.0
+ code.gitea.io/sdk/gitea v0.20.0
+ codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570
+ connectrpc.com/connect v1.17.0
+ github.com/42wim/httpsig v1.2.2
+ github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
+ github.com/ProtonMail/go-crypto v1.1.6
+ github.com/PuerkitoBio/goquery v1.10.2
+ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.7.2
+ github.com/alecthomas/chroma/v2 v2.15.0
+ github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
+ github.com/blevesearch/bleve/v2 v2.5.2
+ github.com/buildkite/terminal-to-html/v3 v3.16.8
+ github.com/caddyserver/certmagic v0.22.2
+ github.com/chi-middleware/proxy v1.1.1
+ github.com/djherbis/buffer v1.2.0
+ github.com/djherbis/nio/v3 v3.0.1
+ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
+ github.com/dustin/go-humanize v1.0.1
+ github.com/editorconfig/editorconfig-core-go/v2 v2.6.3
+ github.com/emersion/go-imap v1.2.1
+ github.com/felixge/fgprof v0.9.5
+ github.com/fsnotify/fsnotify v1.8.0
+ github.com/gliderlabs/ssh v0.3.8
+ github.com/go-ap/activitypub v0.0.0-20231114162308-e219254dc5c9
+ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
+ github.com/go-chi/chi/v5 v5.2.0
+ github.com/go-chi/cors v1.2.1
+ github.com/go-co-op/gocron v1.37.0
+ github.com/go-enry/go-enry/v2 v2.9.2
+ github.com/go-git/go-git/v5 v5.13.2
+ github.com/go-ldap/ldap/v3 v3.4.6
+ github.com/go-openapi/spec v0.20.14
+ github.com/go-sql-driver/mysql v1.9.1
+ github.com/go-webauthn/webauthn v0.12.2
+ github.com/gobwas/glob v0.2.3
+ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
+ github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
+ github.com/golang-jwt/jwt/v5 v5.2.2
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
+ github.com/google/go-github/v64 v64.0.0
+ github.com/google/pprof v0.0.0-20241017200806-017d972448fc
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/feeds v1.2.0
+ github.com/gorilla/sessions v1.4.0
+ github.com/hashicorp/go-version v1.7.0
+ github.com/hashicorp/golang-lru/v2 v2.0.7
+ github.com/huandu/xstrings v1.5.0
+ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056
+ github.com/jhillyerd/enmime/v2 v2.1.0
+ github.com/json-iterator/go v1.1.12
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
+ github.com/klauspost/compress v1.17.11
+ github.com/klauspost/cpuid/v2 v2.2.10
+ github.com/lib/pq v1.10.9
+ github.com/markbates/goth v1.80.0
+ github.com/mattn/go-isatty v0.0.20
+ github.com/mattn/go-sqlite3 v1.14.28
+ github.com/meilisearch/meilisearch-go v0.31.0
+ github.com/mholt/archiver/v3 v3.5.1
+ github.com/microcosm-cc/bluemonday v1.0.27
+ github.com/minio/minio-go/v7 v7.0.88
+ github.com/msteinert/pam/v2 v2.1.0
+ github.com/nektos/act v0.2.52
+ github.com/niklasfasching/go-org v1.7.0
+ github.com/olivere/elastic/v7 v7.0.32
+ github.com/opencontainers/go-digest v1.0.0
+ github.com/opencontainers/image-spec v1.1.1
+ github.com/pquerna/otp v1.4.0
+ github.com/prometheus/client_golang v1.21.1
+ github.com/redis/go-redis/v9 v9.7.3
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
+ github.com/sassoftware/go-rpmutils v0.4.0
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
+ github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92
+ github.com/stretchr/testify v1.10.0
+ github.com/syndtr/goleveldb v1.0.0
+ github.com/ulikunitz/xz v0.5.12
+ github.com/urfave/cli/v2 v2.27.6
+ github.com/valyala/fastjson v1.6.4
+ github.com/yohcop/openid-go v1.0.1
+ github.com/yuin/goldmark v1.7.8
+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+ gitlab.com/gitlab-org/api/client-go v0.126.0
+ go.uber.org/mock v0.5.0
+ golang.org/x/crypto v0.36.0
+ golang.org/x/image v0.25.0
+ golang.org/x/net v0.38.0
+ golang.org/x/oauth2 v0.28.0
+ golang.org/x/sync v0.12.0
+ golang.org/x/sys v0.31.0
+ golang.org/x/text v0.23.0
+ google.golang.org/protobuf v1.36.4
+ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
+ gopkg.in/ini.v1 v1.67.0
+ gopkg.in/yaml.v3 v3.0.1
+ mvdan.cc/xurls/v2 v2.5.0
+ xorm.io/builder v0.3.13
+ xorm.io/xorm v1.3.9
+)
+
+require (
+ cloud.google.com/go/compute/metadata v0.6.0 // indirect
+ dario.cat/mergo v1.0.0 // indirect
+ filippo.io/edwards25519 v1.1.0 // indirect
+ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
+ github.com/DataDog/zstd v1.5.5 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
+ github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bits-and-blooms/bitset v1.22.0 // indirect
+ github.com/blevesearch/bleve_index_api v1.2.8 // indirect
+ github.com/blevesearch/geo v0.2.3 // indirect
+ github.com/blevesearch/go-faiss v1.0.25 // indirect
+ github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
+ github.com/blevesearch/gtreap v0.1.1 // indirect
+ github.com/blevesearch/mmap-go v1.0.4 // indirect
+ github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
+ github.com/blevesearch/segment v0.9.1 // indirect
+ github.com/blevesearch/snowballstem v0.9.0 // indirect
+ github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
+ github.com/blevesearch/vellum v1.1.0 // indirect
+ github.com/blevesearch/zapx/v11 v11.4.2 // indirect
+ github.com/blevesearch/zapx/v12 v12.4.2 // indirect
+ github.com/blevesearch/zapx/v13 v13.4.2 // indirect
+ github.com/blevesearch/zapx/v14 v14.4.2 // indirect
+ github.com/blevesearch/zapx/v15 v15.4.2 // indirect
+ github.com/blevesearch/zapx/v16 v16.2.4 // indirect
+ github.com/boombuler/barcode v1.0.1 // indirect
+ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
+ github.com/caddyserver/zerossl v0.1.3 // indirect
+ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudflare/circl v1.6.1 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+ github.com/cyphar/filepath-securejoin v0.3.6 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/davidmz/go-pageant v1.0.2 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/dlclark/regexp2 v1.11.4 // indirect
+ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/fatih/color v1.16.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.8.0 // indirect
+ github.com/go-ap/errors v0.0.0-20231003111023-183eef4b31b7 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
+ github.com/go-enry/go-oniguruma v1.2.1 // indirect
+ github.com/go-fed/httpsig v1.1.0 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
+ github.com/go-ini/ini v1.67.0 // indirect
+ github.com/go-openapi/jsonpointer v0.20.2 // indirect
+ github.com/go-openapi/jsonreference v0.20.4 // indirect
+ github.com/go-openapi/swag v0.22.7 // indirect
+ github.com/go-webauthn/x v0.1.20 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/golang/snappy v0.0.4 // indirect
+ github.com/google/btree v1.1.2 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/google/go-tpm v0.9.3 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
+ github.com/gorilla/mux v1.8.1 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/klauspost/pgzip v1.2.6 // indirect
+ github.com/libdns/libdns v0.2.3 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
+ github.com/markbates/going v1.0.3 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mholt/acmez/v3 v3.1.1 // indirect
+ github.com/miekg/dns v1.1.63 // indirect
+ github.com/minio/crc64nvme v1.0.1 // indirect
+ github.com/minio/md5-simd v1.1.2 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect
+ github.com/mschoch/smat v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/nwaples/rardecode v1.1.3 // indirect
+ github.com/olekukonko/tablewriter v0.0.5 // indirect
+ github.com/onsi/ginkgo v1.16.5 // indirect
+ github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pjbgf/sha1cd v0.3.2 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/rhysd/actionlint v1.6.27 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/rogpeppe/go-internal v1.13.1 // indirect
+ github.com/rs/xid v1.6.0 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/skeema/knownhosts v1.3.0 // indirect
+ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
+ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
+ github.com/zeebo/assert v1.3.0 // indirect
+ github.com/zeebo/blake3 v0.2.4 // indirect
+ go.etcd.io/bbolt v1.4.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ go.uber.org/zap/exp v0.3.0 // indirect
+ golang.org/x/mod v0.24.0 // indirect
+ golang.org/x/time v0.10.0 // indirect
+ golang.org/x/tools v0.31.0 // indirect
+ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+)
+
+replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1
+
+replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0
+
+replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.25.1
+
+replace github.com/mholt/archiver/v3 => code.forgejo.org/forgejo/archiver/v3 v3.5.1
+
+replace github.com/gliderlabs/ssh => code.forgejo.org/forgejo/ssh v0.0.0-20241211213324-5fc306ca0616
+
+replace git.sr.ht/~mariusor/go-xsd-duration => code.forgejo.org/forgejo/go-xsd-duration v0.0.0-20220703122237-02e73435a078
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/admin/auths.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/admin/auths.go
new file mode 100644
index 00000000..e1d5e4b3
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/admin/auths.go
@@ -0,0 +1,408 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "forgejo.org/models/auth"
+ "forgejo.org/models/db"
+ "forgejo.org/modules/auth/pam"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/web"
+ auth_service "forgejo.org/services/auth"
+ "forgejo.org/services/auth/source/ldap"
+ "forgejo.org/services/auth/source/oauth2"
+ pam_service "forgejo.org/services/auth/source/pam"
+ "forgejo.org/services/auth/source/smtp"
+ "forgejo.org/services/context"
+ "forgejo.org/services/forms"
+
+ "xorm.io/xorm/convert"
+)
+
+const (
+ tplAuths base.TplName = "admin/auth/list"
+ tplAuthNew base.TplName = "admin/auth/new"
+ tplAuthEdit base.TplName = "admin/auth/edit"
+)
+
+// Authentications show authentication config page
+func Authentications(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.authentication")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ var err error
+ ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{})
+ if err != nil {
+ ctx.ServerError("auth.Sources", err)
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplAuths)
+}
+
+type dropdownItem struct {
+ Name string
+ Type any
+}
+
+var (
+ authSources = func() []dropdownItem {
+ items := []dropdownItem{
+ {auth.LDAP.String(), auth.LDAP},
+ {auth.DLDAP.String(), auth.DLDAP},
+ {auth.SMTP.String(), auth.SMTP},
+ {auth.OAuth2.String(), auth.OAuth2},
+ }
+ if pam.Supported {
+ items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM})
+ }
+ return items
+ }()
+
+ securityProtocols = []dropdownItem{
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted},
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS},
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS},
+ }
+)
+
+// NewAuthSource render adding a new auth source page
+func NewAuthSource(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.auths.new")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["type"] = auth.LDAP.Int()
+ ctx.Data["CurrentTypeName"] = auth.Names[auth.LDAP]
+ ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted]
+ ctx.Data["smtp_auth"] = "PLAIN"
+ ctx.Data["is_active"] = true
+ ctx.Data["is_sync_enabled"] = true
+ ctx.Data["AuthSources"] = authSources
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ // only the first as default
+ ctx.Data["oauth2_provider"] = oauth2providers[0].Name()
+
+ ctx.HTML(http.StatusOK, tplAuthNew)
+}
+
+func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
+ var pageSize uint32
+ if form.UsePagedSearch {
+ pageSize = uint32(form.SearchPageSize)
+ }
+ return &ldap.Source{
+ Name: form.Name,
+ Host: form.Host,
+ Port: form.Port,
+ SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol),
+ SkipVerify: form.SkipVerify,
+ BindDN: form.BindDN,
+ UserDN: form.UserDN,
+ BindPassword: form.BindPassword,
+ UserBase: form.UserBase,
+ DefaultDomainName: form.DefaultDomainName,
+ AttributeUsername: form.AttributeUsername,
+ AttributeName: form.AttributeName,
+ AttributeSurname: form.AttributeSurname,
+ AttributeMail: form.AttributeMail,
+ AttributesInBind: form.AttributesInBind,
+ AttributeSSHPublicKey: form.AttributeSSHPublicKey,
+ AttributeAvatar: form.AttributeAvatar,
+ SearchPageSize: pageSize,
+ Filter: form.Filter,
+ GroupsEnabled: form.GroupsEnabled,
+ GroupDN: form.GroupDN,
+ GroupFilter: form.GroupFilter,
+ GroupMemberUID: form.GroupMemberUID,
+ GroupTeamMap: form.GroupTeamMap,
+ GroupTeamMapRemoval: form.GroupTeamMapRemoval,
+ UserUID: form.UserUID,
+ AdminFilter: form.AdminFilter,
+ RestrictedFilter: form.RestrictedFilter,
+ AllowDeactivateAll: form.AllowDeactivateAll,
+ Enabled: true,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+}
+
+func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source {
+ return &smtp.Source{
+ Auth: form.SMTPAuth,
+ Host: form.SMTPHost,
+ Port: form.SMTPPort,
+ AllowedDomains: form.AllowedDomains,
+ ForceSMTPS: form.ForceSMTPS,
+ SkipVerify: form.SkipVerify,
+ HeloHostname: form.HeloHostname,
+ DisableHelo: form.DisableHelo,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+}
+
+func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
+ var customURLMapping *oauth2.CustomURLMapping
+ if form.Oauth2UseCustomURL {
+ customURLMapping = &oauth2.CustomURLMapping{
+ TokenURL: form.Oauth2TokenURL,
+ AuthURL: form.Oauth2AuthURL,
+ ProfileURL: form.Oauth2ProfileURL,
+ EmailURL: form.Oauth2EmailURL,
+ Tenant: form.Oauth2Tenant,
+ }
+ } else {
+ customURLMapping = nil
+ }
+ var scopes []string
+ for _, s := range strings.Split(form.Oauth2Scopes, ",") {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ scopes = append(scopes, s)
+ }
+ }
+
+ return &oauth2.Source{
+ Provider: form.Oauth2Provider,
+ ClientID: form.Oauth2Key,
+ ClientSecret: form.Oauth2Secret,
+ OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL,
+ CustomURLMapping: customURLMapping,
+ IconURL: form.Oauth2IconURL,
+ Scopes: scopes,
+ AttributeSSHPublicKey: form.Oauth2AttributeSSHPublicKey,
+ RequiredClaimName: form.Oauth2RequiredClaimName,
+ RequiredClaimValue: form.Oauth2RequiredClaimValue,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ GroupClaimName: form.Oauth2GroupClaimName,
+ RestrictedGroup: form.Oauth2RestrictedGroup,
+ AdminGroup: form.Oauth2AdminGroup,
+ GroupTeamMap: form.Oauth2GroupTeamMap,
+ GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
+ }
+}
+
+// NewAuthSourcePost response for adding an auth source
+func NewAuthSourcePost(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AuthenticationForm)
+ ctx.Data["Title"] = ctx.Tr("admin.auths.new")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["CurrentTypeName"] = auth.Type(form.Type).String()
+ ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)]
+ ctx.Data["AuthSources"] = authSources
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ hasTLS := false
+ var config convert.Conversion
+ switch auth.Type(form.Type) {
+ case auth.LDAP, auth.DLDAP:
+ config = parseLDAPConfig(form)
+ hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted
+ case auth.SMTP:
+ config = parseSMTPConfig(form)
+ hasTLS = true
+ case auth.PAM:
+ config = &pam_service.Source{
+ ServiceName: form.PAMServiceName,
+ EmailDomain: form.PAMEmailDomain,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+ case auth.OAuth2:
+ config = parseOAuth2Config(form)
+ oauth2Config := config.(*oauth2.Source)
+ if oauth2Config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form)
+ return
+ }
+ }
+ default:
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+ ctx.Data["HasTLS"] = hasTLS
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthNew)
+ return
+ }
+
+ if err := auth.CreateSource(ctx, &auth.Source{
+ Type: auth.Type(form.Type),
+ Name: form.Name,
+ IsActive: form.IsActive,
+ IsSyncEnabled: form.IsSyncEnabled,
+ Cfg: config,
+ }); err != nil {
+ if auth.IsErrSourceAlreadyExist(err) {
+ ctx.Data["Err_Name"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form)
+ } else if oauth2.IsErrOpenIDConnectInitialize(err) {
+ ctx.Data["Err_DiscoveryURL"] = true
+ unwrapped := err.(oauth2.ErrOpenIDConnectInitialize).Unwrap()
+ ctx.RenderWithErr(ctx.Tr("admin.auths.unable_to_initialize_openid", unwrapped), tplAuthNew, form)
+ } else {
+ ctx.ServerError("auth.CreateSource", err)
+ }
+ return
+ }
+
+ log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name))
+ ctx.Redirect(setting.AppSubURL + "/admin/auths")
+}
+
+// EditAuthSource render editing auth source page
+func EditAuthSource(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
+ if source.IsOAuth2() {
+ type Named interface {
+ Name() string
+ }
+
+ for _, provider := range oauth2providers {
+ if provider.Name() == source.Cfg.(Named).Name() {
+ ctx.Data["CurrentOAuth2Provider"] = provider
+ break
+ }
+ }
+ }
+
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+}
+
+// EditAuthSourcePost response for editing auth source
+func EditAuthSourcePost(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AuthenticationForm)
+ ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
+ // The legacy form has no representation for the opt-in current-UserInfo gate.
+ // Refuse rather than silently dropping it during source reconstruction.
+ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil {
+ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form)
+ return
+ }
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ return
+ }
+
+ var config convert.Conversion
+ switch auth.Type(form.Type) {
+ case auth.LDAP, auth.DLDAP:
+ config = parseLDAPConfig(form)
+ case auth.SMTP:
+ config = parseSMTPConfig(form)
+ case auth.PAM:
+ config = &pam_service.Source{
+ ServiceName: form.PAMServiceName,
+ EmailDomain: form.PAMEmailDomain,
+ }
+ case auth.OAuth2:
+ config = parseOAuth2Config(form)
+ oauth2Config := config.(*oauth2.Source)
+ if oauth2Config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form)
+ return
+ }
+ }
+ default:
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+
+ source.Name = form.Name
+ source.IsActive = form.IsActive
+ source.IsSyncEnabled = form.IsSyncEnabled
+ source.Cfg = config
+ if err := auth.UpdateSource(ctx, source); err != nil {
+ if auth.IsErrSourceAlreadyExist(err) {
+ ctx.Data["Err_Name"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form)
+ } else if oauth2.IsErrOpenIDConnectInitialize(err) {
+ ctx.Flash.Error(err.Error(), true)
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ } else {
+ ctx.ServerError("UpdateSource", err)
+ }
+ return
+ }
+ log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.update_success"))
+ ctx.Redirect(setting.AppSubURL + "/admin/auths/" + strconv.FormatInt(form.ID, 10))
+}
+
+// DeleteAuthSource response for deleting an auth source
+func DeleteAuthSource(ctx *context.Context) {
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+
+ if err = auth_service.DeleteSource(ctx, source); err != nil {
+ if auth.IsErrSourceInUse(err) {
+ ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used"))
+ } else {
+ ctx.Flash.Error(fmt.Sprintf("auth_service.DeleteSource: %v", err))
+ }
+ ctx.JSONRedirect(setting.AppSubURL + "/admin/auths/" + url.PathEscape(ctx.Params(":authid")))
+ return
+ }
+ log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/admin/auths")
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/auth/oauth.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/auth/oauth.go
new file mode 100644
index 00000000..1ed9f35a
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/routers/web/auth/oauth.go
@@ -0,0 +1,1485 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ go_context "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ asymkey_model "forgejo.org/models/asymkey"
+ "forgejo.org/models/auth"
+ org_model "forgejo.org/models/organization"
+ user_model "forgejo.org/models/user"
+ auth_module "forgejo.org/modules/auth"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/container"
+ "forgejo.org/modules/json"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/optional"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/timeutil"
+ "forgejo.org/modules/util"
+ "forgejo.org/modules/web"
+ "forgejo.org/modules/web/middleware"
+ auth_service "forgejo.org/services/auth"
+ source_service "forgejo.org/services/auth/source"
+ "forgejo.org/services/auth/source/oauth2"
+ "forgejo.org/services/context"
+ "forgejo.org/services/externalaccount"
+ "forgejo.org/services/forms"
+ remote_service "forgejo.org/services/remote"
+ user_service "forgejo.org/services/user"
+
+ "code.forgejo.org/go-chi/binding"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
+ "github.com/markbates/goth/providers/fitbit"
+ "github.com/markbates/goth/providers/openidConnect"
+ "github.com/markbates/goth/providers/zoom"
+ go_oauth2 "golang.org/x/oauth2"
+)
+
+const (
+ tplGrantAccess base.TplName = "user/auth/grant"
+ tplGrantError base.TplName = "user/auth/grant_error"
+)
+
+// TODO move error and responses to SDK or models
+
+// AuthorizeErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeErrorCode string
+
+const (
+ // ErrorCodeInvalidRequest represents the according error in RFC 6749
+ ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
+ // ErrorCodeUnauthorizedClient represents the according error in RFC 6749
+ ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
+ // ErrorCodeAccessDenied represents the according error in RFC 6749
+ ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
+ // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
+ ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
+ // ErrorCodeInvalidScope represents the according error in RFC 6749
+ ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
+ // ErrorCodeServerError represents the according error in RFC 6749
+ ErrorCodeServerError AuthorizeErrorCode = "server_error"
+ // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
+ ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
+)
+
+// AuthorizeError represents an error type specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeError struct {
+ ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
+ ErrorDescription string
+ State string
+}
+
+// Error returns the error message
+func (err AuthorizeError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// AccessTokenErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenErrorCode string
+
+const (
+ // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
+ // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidClient = "invalid_client"
+ // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidGrant = "invalid_grant"
+ // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
+ // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
+ // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidScope = "invalid_scope"
+)
+
+// AccessTokenError represents an error response specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenError struct {
+ ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+// Error returns the error message
+func (err AccessTokenError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// errCallback represents a oauth2 callback error
+type errCallback struct {
+ Code string
+ Description string
+}
+
+func (err errCallback) Error() string {
+ return err.Description
+}
+
+// TokenType specifies the kind of token
+type TokenType string
+
+const (
+ // TokenTypeBearer represents a token type specified in RFC 6749
+ TokenTypeBearer TokenType = "bearer"
+ // TokenTypeMAC represents a token type specified in RFC 6749
+ TokenTypeMAC = "mac"
+)
+
+// AccessTokenResponse represents a successful access token response
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
+type AccessTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType TokenType `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+}
+
+func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
+ if setting.OAuth2.InvalidateRefreshTokens {
+ if err := grant.IncreaseCounter(ctx); err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "cannot increase the grant counter",
+ }
+ }
+ }
+ // generate access token to access the API
+ expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
+ accessToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Type: oauth2.TypeAccessToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ },
+ }
+ signedAccessToken, err := accessToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate refresh token to request an access token after it expired later
+ refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
+ refreshToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Counter: grant.Counter,
+ Type: oauth2.TypeRefreshToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
+ },
+ }
+ signedRefreshToken, err := refreshToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate OpenID Connect id_token
+ signedIDToken := ""
+ if grant.ScopeContains("openid") {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find application",
+ }
+ }
+ user, err := user_model.GetUserByID(ctx, grant.UserID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find user",
+ }
+ }
+ log.Error("Error loading user: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+
+ idToken := &oauth2.OIDCToken{
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ Issuer: setting.AppURL,
+ Audience: []string{app.ClientID},
+ Subject: fmt.Sprint(grant.UserID),
+ },
+ Nonce: grant.Nonce,
+ }
+ if grant.ScopeContains("profile") {
+ idToken.Name = user.DisplayName()
+ idToken.PreferredUsername = user.Name
+ idToken.Profile = user.HTMLURL()
+ idToken.Picture = user.AvatarLink(ctx)
+ idToken.Website = user.Website
+ idToken.Locale = user.Language
+ idToken.UpdatedAt = user.UpdatedUnix
+ }
+ if grant.ScopeContains("email") {
+ idToken.Email = user.Email
+ idToken.EmailVerified = user.IsActive
+ }
+ if grant.ScopeContains("groups") {
+ onlyPublicGroups := ifOnlyPublicGroups(grant.Scope)
+
+ groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups)
+ if err != nil {
+ log.Error("Error getting groups: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+ idToken.Groups = groups
+ }
+
+ signedIDToken, err = idToken.SignToken(clientKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+ }
+
+ return &AccessTokenResponse{
+ AccessToken: signedAccessToken,
+ TokenType: TokenTypeBearer,
+ ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
+ RefreshToken: signedRefreshToken,
+ IDToken: signedIDToken,
+ }, nil
+}
+
+type userInfoResponse struct {
+ Sub string `json:"sub"`
+ Name string `json:"name"`
+ Username string `json:"preferred_username"`
+ Email string `json:"email"`
+ Picture string `json:"picture"`
+ Groups []string `json:"groups,omitempty"`
+}
+
+func ifOnlyPublicGroups(scopes string) bool {
+ scopes = strings.ReplaceAll(scopes, ",", " ")
+ scopesList := strings.Fields(scopes)
+ for _, scope := range scopesList {
+ if scope == "all" || scope == "read:organization" || scope == "read:admin" {
+ return false
+ }
+ }
+ return true
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+ if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ response := &userInfoResponse{
+ Sub: fmt.Sprint(ctx.Doer.ID),
+ Name: ctx.Doer.DisplayName(),
+ Username: ctx.Doer.Name,
+ Email: ctx.Doer.Email,
+ Picture: ctx.Doer.AvatarLink(ctx),
+ }
+
+ var token string
+ if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
+ auths := strings.Fields(auHead)
+ if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+ token = auths[1]
+ }
+ }
+
+ _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
+ onlyPublicGroups := ifOnlyPublicGroups(grantScopes)
+
+ groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
+ if err != nil {
+ ctx.ServerError("Oauth groups for user", err)
+ return
+ }
+ response.Groups = groups
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// returns a list of "org" and "org:team" strings,
+// that the given user is a part of.
+func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
+ orgs, err := org_model.GetUserOrgsList(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("GetUserOrgList: %w", err)
+ }
+
+ var groups []string
+ for _, org := range orgs {
+ if setting.OAuth2.EnableAdditionalGrantScopes {
+ if onlyPublicGroups {
+ public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID)
+ if !public && err == nil {
+ continue
+ }
+ }
+ }
+
+ groups = append(groups, org.Name)
+ teams, err := org.LoadTeams(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("LoadTeams: %w", err)
+ }
+ for _, team := range teams {
+ if team.IsMember(ctx, user.ID) {
+ groups = append(groups, org.Name+":"+team.LowerName)
+ }
+ }
+ }
+ return groups, nil
+}
+
+func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ return base.BasicAuthDecode(authData)
+ }
+ return "", "", errors.New("invalid basic authentication")
+}
+
+// IntrospectOAuth introspects an oauth token
+func IntrospectOAuth(ctx *context.Context) {
+ clientIDValid := false
+ if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
+ if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
+ // this is likely a database error; log it and respond without details
+ log.Error("Error retrieving client_id: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+ clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
+ }
+ if !clientIDValid {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ var response struct {
+ Active bool `json:"active"`
+ Scope string `json:"scope,omitempty"`
+ Username string `json:"username,omitempty"`
+ jwt.RegisteredClaims
+ }
+
+ form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
+ token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
+ if err == nil {
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err == nil && grant != nil {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err == nil && app != nil {
+ response.Active = true
+ response.Scope = grant.Scope
+ response.Issuer = setting.AppURL
+ response.Audience = []string{app.ClientID}
+ response.Subject = fmt.Sprint(grant.UserID)
+ }
+ if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
+ response.Username = user.Name
+ }
+ }
+ }
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// AuthorizeOAuth manages authorize requests
+func AuthorizeOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AuthorizationForm)
+ errs := binding.Errors{}
+ errs = form.Validate(ctx.Req, errs)
+ if len(errs) > 0 {
+ errstring := ""
+ for _, e := range errs {
+ errstring += e.Error() + "\n"
+ }
+ ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ if auth.IsErrOauthClientIDInvalid(err) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnauthorizedClient,
+ ErrorDescription: "Client ID not registered",
+ State: form.State,
+ }, "")
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+
+ var user *user_model.User
+ if app.UID != 0 {
+ user, err = user_model.GetUserByID(ctx, app.UID)
+ if err != nil {
+ ctx.ServerError("GetUserByID", err)
+ return
+ }
+ }
+
+ if !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "Unregistered Redirect URI",
+ State: form.State,
+ }, "")
+ return
+ }
+
+ if form.ResponseType != "code" {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnsupportedResponseType,
+ ErrorDescription: "Only code response type is supported.",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ // pkce support
+ switch form.CodeChallengeMethod {
+ case "S256":
+ case "plain":
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ case "":
+ // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
+ // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
+ if !app.ConfidentialClient {
+ // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
+ // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "PKCE is required for public clients",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ default:
+ // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
+ // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "unsupported code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+
+ // Redirect if user already granted access and the application is confidential.
+ // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2
+ if app.ConfidentialClient && grant != nil {
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ // Update nonce to reflect the new session
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+ ctx.Redirect(redirect.String())
+ return
+ }
+
+ // show authorize page to grant access
+ ctx.Data["Application"] = app
+ ctx.Data["RedirectURI"] = form.RedirectURI
+ ctx.Data["State"] = form.State
+ ctx.Data["Scope"] = form.Scope
+ ctx.Data["Nonce"] = form.Nonce
+ if user != nil {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
+ } else {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
+ }
+ ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "")
+ // TODO document SESSION <=> FORM
+ err = ctx.Session.Set("client_id", app.ClientID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("redirect_uri", form.RedirectURI)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("state", form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ ctx.HTML(http.StatusOK, tplGrantAccess)
+}
+
+// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
+func GrantApplicationOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.GrantApplicationForm)
+ if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
+ ctx.Session.Get("redirect_uri") != form.RedirectURI {
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+
+ if !form.Granted {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "the request is denied",
+ ErrorCode: ErrorCodeAccessDenied,
+ }, form.RedirectURI)
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ if grant == nil {
+ grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
+ if err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "cannot create grant for user",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+ } else if grant.Scope != form.Scope {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "a grant exists with different scope",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+
+ var codeChallenge, codeChallengeMethod string
+ codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
+ codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
+
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
+func OIDCWellKnown(ctx *context.Context) {
+ ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
+ ctx.JSONTemplate("user/auth/oidc_wellknown")
+}
+
+// OIDCKeys generates the JSON Web Key Set
+func OIDCKeys(ctx *context.Context) {
+ jwk, err := oauth2.DefaultSigningKey.ToJWK()
+ if err != nil {
+ log.Error("Error converting signing key to JWK: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+
+ jwk["use"] = "sig"
+
+ jwks := map[string][]map[string]string{
+ "keys": {
+ jwk,
+ },
+ }
+
+ ctx.Resp.Header().Set("Content-Type", "application/json")
+ enc := json.NewEncoder(ctx.Resp)
+ if err := enc.Encode(jwks); err != nil {
+ log.Error("Failed to encode representation as json. Error: %v", err)
+ }
+}
+
+// AccessTokenOAuth manages all access token requests by the client
+func AccessTokenOAuth(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AccessTokenForm)
+ // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
+ if form.ClientID == "" || form.ClientSecret == "" {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ clientID, clientSecret, err := base.BasicAuthDecode(authData)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot parse basic auth header",
+ })
+ return
+ }
+ // validate that any fields present in the form match the Basic auth header
+ if form.ClientID != "" && form.ClientID != clientID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_id in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientID = clientID
+ if form.ClientSecret != "" && form.ClientSecret != clientSecret {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_secret in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientSecret = clientSecret
+ }
+ }
+
+ serverKey := oauth2.DefaultSigningKey
+ clientKey := serverKey
+ if serverKey.IsSymmetric() {
+ var err error
+ clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "Error creating signing key",
+ })
+ return
+ }
+ }
+
+ switch form.GrantType {
+ case "refresh_token":
+ handleRefreshToken(ctx, form, serverKey, clientKey)
+ case "authorization_code":
+ handleAuthorizationCode(ctx, form, serverKey, clientKey)
+ default:
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
+ ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
+ })
+ }
+}
+
+func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
+ })
+ return
+ }
+ // "The authorization server MUST ... require client authentication for confidential clients"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-6
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ // "invalid_client ... Client authentication failed"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+
+ token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unable to parse refresh token",
+ })
+ return
+ }
+ // get grant before increasing counter
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err != nil || grant == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "grant does not exist",
+ })
+ return
+ }
+
+ // check if token got already used
+ if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "token was already used",
+ })
+ log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
+ return
+ }
+ accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ ctx.JSON(http.StatusOK, accessToken)
+}
+
+func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
+ })
+ return
+ }
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+ if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unexpected redirect URI",
+ })
+ return
+ }
+ authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
+ if err != nil || authorizationCode == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // check if code verifier authorizes the client, PKCE support
+ if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "failed PKCE code challenge",
+ })
+ return
+ }
+ // check if granted for this application
+ if authorizationCode.Grant.ApplicationID != app.ID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "invalid grant",
+ })
+ return
+ }
+ // remove token from database to deny duplicate usage
+ if err := authorizationCode.Invalidate(ctx); err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot proceed your request",
+ })
+ }
+ resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ // send successful response
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
+ ctx.JSON(http.StatusBadRequest, acErr)
+}
+
+func handleServerError(ctx *context.Context, state, redirectURI string, err error) {
+ log.Error("OAuth server error: %v", err)
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "A server error occurred",
+ State: state,
+ }, redirectURI)
+}
+
+func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
+ if redirectURI == "" {
+ log.Warn("Authorization failed: %v", authErr.ErrorDescription)
+ ctx.Data["Error"] = authErr
+ ctx.HTML(http.StatusBadRequest, tplGrantError)
+ return
+ }
+ redirect, err := url.Parse(redirectURI)
+ if err != nil {
+ ctx.ServerError("url.Parse", err)
+ return
+ }
+ q := redirect.Query()
+ q.Set("error", string(authErr.ErrorCode))
+ q.Set("error_description", authErr.ErrorDescription)
+ q.Set("state", authErr.State)
+ redirect.RawQuery = q.Encode()
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// SignInOAuth handles the OAuth2 login buttons
+func SignInOAuth(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ redirectTo := ctx.FormString("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ }
+
+ // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
+ user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err == nil && user != nil {
+ // we got the user without going through the whole OAuth2 authentication flow again
+ handleOAuth2SignIn(ctx, authSource, user, gothUser)
+ return
+ }
+
+ codeChallenge, err := generateCodeChallenge(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err))
+ return
+ }
+
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ if strings.Contains(err.Error(), "no provider for ") {
+ if err = oauth2.ResetOAuth2(ctx); err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ ctx.ServerError("SignIn", err)
+ }
+ return
+ }
+ ctx.ServerError("SignIn", err)
+ }
+ // redirect is done in oauth2.Auth
+}
+
+// SignInOAuthCallback handles the callback from the given provider
+func SignInOAuthCallback(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ if ctx.Req.FormValue("error") != "" {
+ var errorKeyValues []string
+ for k, vv := range ctx.Req.Form {
+ for _, v := range vv {
+ errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
+ }
+ }
+ sort.Strings(errorKeyValues)
+ ctx.Flash.Error(strings.Join(errorKeyValues, "
"), true)
+ }
+
+ // first look if the provider is still active
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ if authSource == nil {
+ ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
+ return
+ }
+
+ u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err != nil {
+ if user_model.IsErrUserProhibitLogin(err) {
+ uplerr := err.(user_model.ErrUserProhibitLogin)
+ log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ return
+ }
+ if callbackErr, ok := err.(errCallback); ok {
+ log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
+ switch callbackErr.Code {
+ case "access_denied":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
+ case "temporarily_unavailable":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
+ default:
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error"))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ if err, ok := err.(*go_oauth2.RetrieveError); ok {
+ ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if u == nil {
+ if ctx.Doer != nil {
+ // attach user to already logged in user
+ err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
+ if err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
+ // create new user with details from oauth2 provider
+ if gothUser.UserID == "" {
+ log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" {
+ log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
+ }
+ err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+ var missingFields []string
+ if gothUser.Email == "" {
+ missingFields = append(missingFields, "email")
+ }
+ if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
+ missingFields = append(missingFields, "nickname")
+ }
+ if len(missingFields) > 0 {
+ // we don't have enough information to create an account automatically,
+ // so we prompt the user for the remaining bits
+ log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields)
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ uname, err := getUserName(&gothUser)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ u = &user_model.User{
+ Name: uname,
+ FullName: gothUser.Name,
+ Email: gothUser.Email,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
+ }
+
+ source := authSource.Cfg.(*oauth2.Source)
+
+ isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
+ u.IsAdmin = isAdmin.ValueOrDefault(false)
+ u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
+
+ if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
+ // error already handled
+ return
+ }
+
+ if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ } else {
+ // no existing user is found, request attach or new account
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ }
+
+ handleOAuth2SignIn(ctx, authSource, u, gothUser)
+}
+
+func claimValueToStringSet(claimValue any) container.Set[string] {
+ var groups []string
+
+ switch rawGroup := claimValue.(type) {
+ case []string:
+ groups = rawGroup
+ case []any:
+ for _, group := range rawGroup {
+ groups = append(groups, fmt.Sprintf("%s", group))
+ }
+ default:
+ str := fmt.Sprintf("%s", rawGroup)
+ groups = strings.Split(str, ",")
+ }
+ return container.SetOf(groups...)
+}
+
+func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
+ if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
+ if err != nil {
+ return err
+ }
+
+ groups := getClaimedGroups(source, gothUser)
+
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
+ groupClaims, has := gothUser.RawData[source.GroupClaimName]
+ if !has {
+ return nil
+ }
+
+ return claimValueToStringSet(groupClaims)
+}
+
+func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
+ groups := getClaimedGroups(source, gothUser)
+
+ if source.AdminGroup != "" {
+ isAdmin = optional.Some(groups.Contains(source.AdminGroup))
+ }
+ if source.RestrictedGroup != "" {
+ isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
+ }
+
+ return isAdmin, isRestricted
+}
+
+func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
+ if err := updateSession(ctx, nil, map[string]any{
+ "linkAccountGothUser": gothUser,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/link_account")
+}
+
+func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
+ if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
+ resp, err := http.Get(url)
+ if err == nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ // ignore any error
+ if err == nil && resp.StatusCode == http.StatusOK {
+ data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
+ if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
+ _ = user_service.UploadAvatar(ctx, u, data)
+ }
+ }
+ }
+}
+
+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 {
+ needs2FA, err = auth.HasTwoFactorByUID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ }
+
+ oauth2Source := source.Cfg.(*oauth2.Source)
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
+ if err != nil {
+ ctx.ServerError("UnmarshalGroupTeamMapping", err)
+ return
+ }
+
+ groups := getClaimedGroups(oauth2Source, &gothUser)
+
+ // If this user is enrolled in 2FA and this source doesn't override it,
+ // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
+ if !needs2FA {
+ if err := updateSession(ctx, nil, map[string]any{
+ "uid": u.ID,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // Clear whatever CSRF cookie has right now, force to generate a new one
+ ctx.Csrf.DeleteCookie(ctx)
+
+ opts := &user_service.UpdateOptions{
+ SetLastLogin: true,
+ }
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ // update external user information
+ if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
+ if !errors.Is(err, util.ErrNotExist) {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+ }
+
+ if err := resetLocale(ctx, u); err != nil {
+ ctx.ServerError("resetLocale", err)
+ return
+ }
+
+ if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+ return
+ }
+
+ opts := &user_service.UpdateOptions{}
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ if err := updateSession(ctx, nil, map[string]any{
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ "twofaUid": u.ID,
+ "twofaRemember": false,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // If WebAuthn is enrolled -> Redirect to WebAuthn instead
+ regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/webauthn")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE
+func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) {
+ // the `code_verifier` is only forwarded by specific providers
+ // https://codeberg.org/forgejo/forgejo/issues/4033
+ p, ok := goth.GetProviders()[provider]
+ if !ok {
+ return "", nil
+ }
+ switch p.(type) {
+ default:
+ return "", nil
+ case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider:
+ // those providers forward the `code_verifier`
+ // a code_challenge can be generated
+ }
+
+ codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness)
+ if err != nil {
+ return "", err
+ }
+ if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil {
+ return "", err
+ }
+ return encodeCodeChallenge(codeVerifier)
+}
+
+func encodeCodeChallenge(codeVerifier string) (string, error) {
+ hasher := sha256.New()
+ _, err := io.WriteString(hasher, codeVerifier)
+ codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
+ return codeChallenge, err
+}
+
+// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
+// login the user
+func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
+ gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
+ if err != nil {
+ return nil, goth.User{}, err
+ }
+
+ if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
+ return nil, goth.User{}, err
+ }
+
+ u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
+ return u, gothUser, err
+}
+
+func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
+ oauth2Source := authSource.Cfg.(*oauth2.Source)
+
+ // Make sure that the response is not an error response.
+ errorName := request.FormValue("error")
+
+ if len(errorName) > 0 {
+ errorDescription := request.FormValue("error_description")
+
+ // Delete the goth session
+ err := gothic.Logout(response, request)
+ if err != nil {
+ return goth.User{}, err
+ }
+
+ return goth.User{}, errCallback{
+ Code: errorName,
+ Description: errorDescription,
+ }
+ }
+
+ // Proceed to authenticate through goth.
+ codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string)
+ _ = ctx.Session.Delete("CodeVerifier")
+ gothUser, err := oauth2Source.Callback(request, response, codeVerifier)
+ if err != nil {
+ if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
+ log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ }
+ return goth.User{}, err
+ }
+
+ if oauth2Source.RequiredClaimValues != nil {
+ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+ } else if oauth2Source.RequiredClaimName != "" {
+ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+ if !has {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+
+ if oauth2Source.RequiredClaimValue != "" {
+ groups := claimValueToStringSet(claimInterface)
+
+ if !groups.Contains(oauth2Source.RequiredClaimValue) {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+ }
+ }
+
+ return gothUser, nil
+}
+
+func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
+ user := &user_model.User{
+ LoginName: gothUser.UserID,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ }
+
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ if hasUser {
+ return user, nil
+ }
+ log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
+
+ // search in external linked users
+ externalLoginUser := &user_model.ExternalLoginUser{
+ ExternalID: gothUser.UserID,
+ LoginSourceID: authSource.ID,
+ }
+ hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
+ if err != nil {
+ return nil, err
+ }
+ if hasUser {
+ user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
+ return user, err
+ }
+
+ // no user found to login
+ return nil, nil
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/providers_openid.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/providers_openid.go
new file mode 100644
index 00000000..ff62f02a
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/providers_openid.go
@@ -0,0 +1,79 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "errors"
+ "html/template"
+
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/svg"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/openidConnect"
+)
+
+// OpenIDProvider is a GothProvider for OpenID
+type OpenIDProvider struct{}
+
+// Name provides the technical name for this provider
+func (o *OpenIDProvider) Name() string {
+ return "openidConnect"
+}
+
+// DisplayName returns the friendly name for this provider
+func (o *OpenIDProvider) DisplayName() string {
+ return "OpenID Connect"
+}
+
+// IconHTML returns icon HTML for this provider
+func (o *OpenIDProvider) IconHTML(size int) template.HTML {
+ return svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := setting.OAuth2Client.OpenIDConnectScopes
+ if len(scopes) == 0 {
+ scopes = append(scopes, source.Scopes...)
+ }
+
+ provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if source.RequiredClaimValues != nil {
+ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil {
+ return nil, err
+ }
+ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest {
+ return nil, errors.New("required-claim-values needs current UserInfo")
+ }
+ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName)
+ if err != nil {
+ return nil, err
+ }
+ provider.HTTPClient = guarded
+ }
+ return provider, err
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+func (o *OpenIDProvider) CanProvideSSHKeys() bool {
+ return true
+}
+
+var _ GothProvider = &OpenIDProvider{}
+
+func init() {
+ RegisterGothProvider(&OpenIDProvider{})
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values.go
new file mode 100644
index 00000000..1ea089d6
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values.go
@@ -0,0 +1,202 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const maxClaimValues = 1024
+const maxUserInfoBytes = 1 << 20
+
+// ValidateRequiredClaimValues validates only the opt-in list extension. A nil
+// list retains every legacy single-value behavior. An explicitly empty list
+// fails closed rather than turning a configured gate into claim-presence-only.
+func ValidateRequiredClaimValues(name, legacy string, values []string) error {
+ if values == nil {
+ return nil
+ }
+ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 {
+ return errors.New("invalid or conflicting required-claim-values configuration")
+ }
+ seen := map[string]bool{}
+ for _, value := range values {
+ if !validClaimLiteral(value) || seen[value] {
+ return errors.New("invalid required-claim-values literal")
+ }
+ seen[value] = true
+ }
+ return nil
+}
+
+func validClaimLiteral(value string) bool {
+ return value != "" && len(value) <= 256 && utf8.ValidString(value) &&
+ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") &&
+ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError })
+}
+
+// strictClaimValues accepts only a well-formed claim array. No scalar parsing,
+// coercion, regex or prefix matching is introduced into the new gate.
+func strictClaimValues(raw any) ([]string, bool) {
+ var values []string
+ switch v := raw.(type) {
+ case []string:
+ values = v
+ case []any:
+ values = make([]string, len(v))
+ for i, value := range v {
+ s, ok := value.(string)
+ if !ok {
+ return nil, false
+ }
+ values[i] = s
+ }
+ default:
+ return nil, false
+ }
+ if len(values) > maxClaimValues {
+ return nil, false
+ }
+ for _, value := range values {
+ if !validClaimLiteral(value) {
+ return nil, false
+ }
+ }
+ return values, true
+}
+
+// MatchRequiredClaimValues evaluates the new consumer gate against current
+// authenticated provider data; it does not mutate or manufacture issuer claims.
+func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool {
+ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil {
+ return false
+ }
+ raw, present := claims[name]
+ if !present {
+ return false
+ }
+ values, ok := strictClaimValues(raw)
+ if !ok {
+ return false
+ }
+ for _, value := range values {
+ for _, wanted := range required {
+ if value == wanted {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and
+// adds a strict response guard for this opt-in source only. A current UserInfo
+// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP
+// failures cannot fall back to the ID-token map in Goth's merging code.
+func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) {
+ u, err := url.Parse(endpoint)
+ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) {
+ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint")
+ }
+ if client == nil {
+ client = http.DefaultClient
+ }
+ result := *client
+ base := result.Transport
+ if base == nil {
+ base = http.DefaultTransport
+ }
+ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim}
+ // Redirects could evade endpoint matching or send a bearer to another origin.
+ // They are unsupported for the new guarded provider; legacy clients are intact.
+ result.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return errors.New("guarded OAuth redirects are not supported")
+ }
+ return &result, nil
+}
+
+type currentClaimTransport struct {
+ base http.RoundTripper
+ endpoint, claim string
+}
+
+func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ response, err := t.base.RoundTrip(req)
+ if req.URL.String() != t.endpoint {
+ return response, err
+ }
+ if err != nil {
+ if response != nil && response.Body != nil {
+ response.Body.Close()
+ }
+ return nil, errors.New("current UserInfo lookup failed")
+ }
+ if response == nil || response.Body == nil {
+ return nil, errors.New("current UserInfo response missing")
+ }
+ defer response.Body.Close()
+ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK {
+ return nil, errors.New("current UserInfo refused")
+ }
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1))
+ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) {
+ return nil, errors.New("current UserInfo response invalid")
+ }
+ // Reject duplicate top-level keys before Unmarshal can hide a conflicting
+ // groups or subject claim. json.RawMessage permits unrelated normal claims.
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ token, err := decoder.Token()
+ if err != nil || token != json.Delim('{') {
+ return nil, errors.New("current UserInfo object required")
+ }
+ claims := map[string]json.RawMessage{}
+ for decoder.More() {
+ keyToken, err := decoder.Token()
+ if err != nil {
+ return nil, errors.New("current UserInfo key invalid")
+ }
+ key, ok := keyToken.(string)
+ if !ok {
+ return nil, errors.New("current UserInfo key invalid")
+ }
+ if _, duplicate := claims[key]; duplicate {
+ return nil, errors.New("current UserInfo duplicate claim")
+ }
+ var value json.RawMessage
+ if decoder.Decode(&value) != nil {
+ return nil, errors.New("current UserInfo value invalid")
+ }
+ claims[key] = value
+ }
+ if token, err = decoder.Token(); err != nil || token != json.Delim('}') {
+ return nil, errors.New("current UserInfo object invalid")
+ }
+ var trailing any
+ if decoder.Decode(&trailing) != io.EOF {
+ return nil, errors.New("current UserInfo trailing data")
+ }
+ raw, present := claims[t.claim]
+ if !present {
+ return nil, errors.New("current UserInfo required claim missing")
+ }
+ var value any
+ if json.Unmarshal(raw, &value) != nil {
+ return nil, errors.New("current UserInfo required claim invalid")
+ }
+ if _, ok := strictClaimValues(value); !ok {
+ return nil, errors.New("current UserInfo required claim array invalid")
+ }
+ // Return the original public response bytes to Goth. Subject equality and
+ // audience/issuer checks remain in its existing authenticated provider path.
+ response.Body = io.NopCloser(bytes.NewReader(body))
+ return response, nil
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values_test.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values_test.go
new file mode 100644
index 00000000..bc79d7e5
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/required_claim_values_test.go
@@ -0,0 +1,144 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+package oauth2
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+func mockUserInfo(body string, status int) *http.Client {
+ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
+ })}
+}
+
+func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) {
+ for _, group := range caseValues {
+ groups := []any{group, "unrelated"}
+ claims := map[string]any{"groups": groups, "other": "preserved"}
+ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) {
+ t.Fatal("individual allowed group rejected")
+ }
+ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) {
+ t.Fatal("claims mutated")
+ }
+ }
+ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) {
+ t.Fatal("multiple allowed groups rejected")
+ }
+ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} {
+ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) {
+ t.Fatal("malformed/nonmatching claim accepted")
+ }
+ }
+ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) {
+ t.Fatal("absent claim accepted")
+ }
+ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) {
+ t.Fatal("unrelated claim used")
+ }
+}
+
+func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) {
+ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil {
+ t.Fatal("legacy config changed")
+ }
+ for _, cfg := range []struct {
+ name, legacy string
+ values []string
+ }{
+ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues},
+ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}},
+ } {
+ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil {
+ t.Fatal("invalid config accepted")
+ }
+ }
+ if ValidateRequiredClaimValues("groups", "", caseValues) != nil {
+ t.Fatal("case config invalid")
+ }
+}
+
+func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) {
+ for _, body := range []string{
+ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`,
+ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`,
+ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`,
+ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1),
+ } {
+ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
+ if err != nil {
+ t.Fatal(err)
+ }
+ response, err := client.Get("https://issuer.example/userinfo")
+ if err == nil {
+ response.Body.Close()
+ t.Fatal("invalid current claim did not fail closed")
+ }
+ }
+ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups")
+ if response, err := client.Get("https://issuer.example/userinfo"); err == nil {
+ response.Body.Close()
+ t.Fatal("non200 accepted")
+ }
+ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })}
+ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups")
+ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") {
+ t.Fatal("lookup failure not contained")
+ }
+}
+
+func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) {
+ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} {
+ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
+ if err != nil {
+ t.Fatal(err)
+ }
+ response, err := client.Get("https://issuer.example/userinfo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := io.ReadAll(response.Body)
+ response.Body.Close()
+ if string(b) != body {
+ t.Fatal("response changed")
+ }
+ }
+}
+
+func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) {
+ original := mockUserInfo(`{"unrelated":true}`, 200)
+ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups")
+ if err != nil {
+ t.Fatal(err)
+ }
+ response, err := original.Get("https://issuer.example/userinfo")
+ if err != nil {
+ t.Fatal("original client mutated")
+ }
+ response.Body.Close()
+ response, err = guarded.Get("https://issuer.example/unrelated")
+ if err != nil {
+ t.Fatal("unrelated endpoint changed")
+ }
+ response.Body.Close()
+ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil {
+ t.Fatal("redirect can bypass guard")
+ }
+ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} {
+ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil {
+ t.Fatal("invalid endpoint accepted")
+ }
+ }
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/source.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/source.go
new file mode 100644
index 00000000..cd4906f9
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/auth/source/oauth2/source.go
@@ -0,0 +1,60 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "strings"
+
+ "forgejo.org/models/auth"
+ "forgejo.org/modules/json"
+)
+
+// Source holds configuration for the OAuth2 login source.
+type Source struct {
+ Provider string
+ ClientID string
+ ClientSecret string
+ OpenIDConnectAutoDiscoveryURL string
+ CustomURLMapping *CustomURLMapping
+ IconURL string
+
+ Scopes []string
+ AttributeSSHPublicKey string
+ RequiredClaimName string
+ RequiredClaimValues []string `json:",omitempty"`
+ RequiredClaimValue string
+ GroupClaimName string
+ AdminGroup string
+ GroupTeamMap string
+ GroupTeamMapRemoval bool
+ RestrictedGroup string
+ SkipLocalTwoFA bool `json:",omitempty"`
+
+ // reference to the authSource
+ authSource *auth.Source
+}
+
+// FromDB fills up an OAuth2Config from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports an SMTPConfig to a serialized format.
+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
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.OAuth2, &Source{})
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/forms/auth_form.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/forms/auth_form.go
new file mode 100644
index 00000000..e665ca0d
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/postimages/services/forms/auth_form.go
@@ -0,0 +1,88 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forms
+
+import (
+ "net/http"
+
+ "forgejo.org/modules/web/middleware"
+ "forgejo.org/services/context"
+
+ "code.forgejo.org/go-chi/binding"
+)
+
+// AuthenticationForm form for authentication
+type AuthenticationForm struct {
+ ID int64
+ Type int `binding:"Range(2,7)"`
+ Name string `binding:"Required;MaxSize(30)"`
+ Host string
+ Port int
+ BindDN string
+ BindPassword string
+ UserBase string
+ UserDN string
+ AttributeUsername string
+ AttributeName string
+ AttributeSurname string
+ DefaultDomainName string
+ AttributeMail string
+ AttributeSSHPublicKey string
+ AttributeAvatar string
+ AttributesInBind bool
+ UsePagedSearch bool
+ SearchPageSize int
+ Filter string
+ AdminFilter string
+ GroupsEnabled bool
+ GroupDN string
+ GroupFilter string
+ GroupMemberUID string
+ UserUID string
+ RestrictedFilter string
+ AllowDeactivateAll bool
+ IsActive bool
+ IsSyncEnabled bool
+ SMTPAuth string
+ SMTPHost string
+ SMTPPort int
+ AllowedDomains string
+ SecurityProtocol int `binding:"Range(0,2)"`
+ TLS bool
+ SkipVerify bool
+ HeloHostname string
+ DisableHelo bool
+ ForceSMTPS bool
+ PAMServiceName string
+ PAMEmailDomain string
+ Oauth2Provider string
+ Oauth2Key string
+ Oauth2Secret string
+ OpenIDConnectAutoDiscoveryURL string
+ Oauth2UseCustomURL bool
+ Oauth2TokenURL string
+ Oauth2AuthURL string
+ Oauth2ProfileURL string
+ Oauth2EmailURL string
+ Oauth2IconURL string
+ Oauth2Tenant string
+ Oauth2Scopes string
+ Oauth2RequiredClaimName string
+ Oauth2RequiredClaimValue string
+ Oauth2GroupClaimName string
+ Oauth2AdminGroup string
+ Oauth2RestrictedGroup string
+ Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
+ Oauth2GroupTeamMapRemoval bool
+ Oauth2AttributeSSHPublicKey string
+ SkipLocalTwoFA bool
+ GroupTeamMap string `binding:"ValidGroupTeamMap"`
+ GroupTeamMapRemoval bool
+}
+
+// Validate validates fields
+func (f *AuthenticationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := context.GetValidateContext(req)
+ return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/cmd/admin_auth_oauth.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/cmd/admin_auth_oauth.go
new file mode 100644
index 00000000..58238794
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/cmd/admin_auth_oauth.go
@@ -0,0 +1,299 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package cmd
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ auth_model "forgejo.org/models/auth"
+ "forgejo.org/services/auth/source/oauth2"
+
+ "github.com/urfave/cli/v2"
+)
+
+var (
+ oauthCLIFlags = []cli.Flag{
+ &cli.StringFlag{
+ Name: "name",
+ Value: "",
+ Usage: "Application Name",
+ },
+ &cli.StringFlag{
+ Name: "provider",
+ Value: "",
+ Usage: "OAuth2 Provider",
+ },
+ &cli.StringFlag{
+ Name: "key",
+ Value: "",
+ Usage: "Client ID (Key)",
+ },
+ &cli.StringFlag{
+ Name: "secret",
+ Value: "",
+ Usage: "Client Secret",
+ },
+ &cli.StringFlag{
+ Name: "auto-discover-url",
+ Value: "",
+ Usage: "OpenID Connect Auto Discovery URL (only required when using OpenID Connect as provider)",
+ },
+ &cli.StringFlag{
+ Name: "use-custom-urls",
+ Value: "false",
+ Usage: "Use custom URLs for GitLab/GitHub OAuth endpoints",
+ },
+ &cli.StringFlag{
+ Name: "custom-tenant-id",
+ Value: "",
+ Usage: "Use custom Tenant ID for OAuth endpoints",
+ },
+ &cli.StringFlag{
+ Name: "custom-auth-url",
+ Value: "",
+ Usage: "Use a custom Authorization URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-token-url",
+ Value: "",
+ Usage: "Use a custom Token URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-profile-url",
+ Value: "",
+ Usage: "Use a custom Profile URL (option for GitLab/GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "custom-email-url",
+ Value: "",
+ Usage: "Use a custom Email URL (option for GitHub)",
+ },
+ &cli.StringFlag{
+ Name: "icon-url",
+ Value: "",
+ Usage: "Custom icon URL for OAuth2 login source",
+ },
+ &cli.BoolFlag{
+ Name: "skip-local-2fa",
+ Usage: "Set to true to skip local 2fa for users authenticated by this source",
+ },
+ &cli.StringSliceFlag{
+ Name: "scopes",
+ Value: nil,
+ Usage: "Scopes to request when to authenticate against this OAuth2 source",
+ },
+ &cli.StringFlag{
+ Name: "required-claim-name",
+ Value: "",
+ Usage: "Claim name that has to be set to allow users to login with this source",
+ },
+ &cli.StringFlag{
+ Name: "required-claim-value",
+ Value: "",
+ Usage: "Claim value that has to be set to allow users to login with this source",
+ },
+ &cli.StringFlag{
+ Name: "group-claim-name",
+ Value: "",
+ Usage: "Claim name providing group names for this source",
+ },
+ &cli.StringFlag{
+ Name: "admin-group",
+ Value: "",
+ Usage: "Group Claim value for administrator users",
+ },
+ &cli.StringFlag{
+ Name: "restricted-group",
+ Value: "",
+ Usage: "Group Claim value for restricted users",
+ },
+ &cli.StringFlag{
+ Name: "group-team-map",
+ Value: "",
+ Usage: "JSON mapping between groups and org teams",
+ },
+ &cli.BoolFlag{
+ Name: "group-team-map-removal",
+ Usage: "Activate automatic team membership removal depending on groups",
+ },
+ }
+
+ microcmdAuthAddOauth = &cli.Command{
+ Name: "add-oauth",
+ Usage: "Add new Oauth authentication source",
+ Action: runAddOauth,
+ Flags: oauthCLIFlags,
+ }
+
+ microcmdAuthUpdateOauth = &cli.Command{
+ Name: "update-oauth",
+ Usage: "Update existing Oauth authentication source",
+ Action: runUpdateOauth,
+ Flags: append(oauthCLIFlags[:1], append([]cli.Flag{idFlag}, oauthCLIFlags[1:]...)...),
+ }
+)
+
+func parseOAuth2Config(c *cli.Context) *oauth2.Source {
+ var customURLMapping *oauth2.CustomURLMapping
+ if c.IsSet("use-custom-urls") {
+ customURLMapping = &oauth2.CustomURLMapping{
+ TokenURL: c.String("custom-token-url"),
+ AuthURL: c.String("custom-auth-url"),
+ ProfileURL: c.String("custom-profile-url"),
+ EmailURL: c.String("custom-email-url"),
+ Tenant: c.String("custom-tenant-id"),
+ }
+ } else {
+ customURLMapping = nil
+ }
+ return &oauth2.Source{
+ Provider: c.String("provider"),
+ ClientID: c.String("key"),
+ ClientSecret: c.String("secret"),
+ OpenIDConnectAutoDiscoveryURL: c.String("auto-discover-url"),
+ CustomURLMapping: customURLMapping,
+ IconURL: c.String("icon-url"),
+ SkipLocalTwoFA: c.Bool("skip-local-2fa"),
+ Scopes: c.StringSlice("scopes"),
+ RequiredClaimName: c.String("required-claim-name"),
+ RequiredClaimValue: c.String("required-claim-value"),
+ GroupClaimName: c.String("group-claim-name"),
+ AdminGroup: c.String("admin-group"),
+ RestrictedGroup: c.String("restricted-group"),
+ GroupTeamMap: c.String("group-team-map"),
+ GroupTeamMapRemoval: c.Bool("group-team-map-removal"),
+ }
+}
+
+func runAddOauth(c *cli.Context) error {
+ ctx, cancel := installSignals()
+ defer cancel()
+
+ if err := initDB(ctx); err != nil {
+ return err
+ }
+
+ config := parseOAuth2Config(c)
+ if config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ return fmt.Errorf("invalid Auto Discovery URL: %s (this must be a valid URL starting with http:// or https://)", config.OpenIDConnectAutoDiscoveryURL)
+ }
+ }
+
+ return auth_model.CreateSource(ctx, &auth_model.Source{
+ Type: auth_model.OAuth2,
+ Name: c.String("name"),
+ IsActive: true,
+ Cfg: config,
+ })
+}
+
+func runUpdateOauth(c *cli.Context) error {
+ if !c.IsSet("id") {
+ return errors.New("--id flag is missing")
+ }
+
+ ctx, cancel := installSignals()
+ defer cancel()
+
+ if err := initDB(ctx); err != nil {
+ return err
+ }
+
+ source, err := auth_model.GetSourceByID(ctx, c.Int64("id"))
+ if err != nil {
+ return err
+ }
+
+ oAuth2Config := source.Cfg.(*oauth2.Source)
+
+ if c.IsSet("name") {
+ source.Name = c.String("name")
+ }
+
+ if c.IsSet("provider") {
+ oAuth2Config.Provider = c.String("provider")
+ }
+
+ if c.IsSet("key") {
+ oAuth2Config.ClientID = c.String("key")
+ }
+
+ if c.IsSet("secret") {
+ oAuth2Config.ClientSecret = c.String("secret")
+ }
+
+ if c.IsSet("auto-discover-url") {
+ oAuth2Config.OpenIDConnectAutoDiscoveryURL = c.String("auto-discover-url")
+ }
+
+ if c.IsSet("icon-url") {
+ oAuth2Config.IconURL = c.String("icon-url")
+ }
+
+ if c.IsSet("scopes") {
+ oAuth2Config.Scopes = c.StringSlice("scopes")
+ }
+
+ if c.IsSet("required-claim-name") {
+ oAuth2Config.RequiredClaimName = c.String("required-claim-name")
+ }
+ if c.IsSet("required-claim-value") {
+ oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+ }
+
+ if c.IsSet("group-claim-name") {
+ oAuth2Config.GroupClaimName = c.String("group-claim-name")
+ }
+ if c.IsSet("admin-group") {
+ oAuth2Config.AdminGroup = c.String("admin-group")
+ }
+ if c.IsSet("restricted-group") {
+ oAuth2Config.RestrictedGroup = c.String("restricted-group")
+ }
+ if c.IsSet("group-team-map") {
+ oAuth2Config.GroupTeamMap = c.String("group-team-map")
+ }
+ if c.IsSet("group-team-map-removal") {
+ oAuth2Config.GroupTeamMapRemoval = c.Bool("group-team-map-removal")
+ }
+
+ // update custom URL mapping
+ customURLMapping := &oauth2.CustomURLMapping{}
+
+ if oAuth2Config.CustomURLMapping != nil {
+ customURLMapping.TokenURL = oAuth2Config.CustomURLMapping.TokenURL
+ customURLMapping.AuthURL = oAuth2Config.CustomURLMapping.AuthURL
+ customURLMapping.ProfileURL = oAuth2Config.CustomURLMapping.ProfileURL
+ customURLMapping.EmailURL = oAuth2Config.CustomURLMapping.EmailURL
+ customURLMapping.Tenant = oAuth2Config.CustomURLMapping.Tenant
+ }
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-token-url") {
+ customURLMapping.TokenURL = c.String("custom-token-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-auth-url") {
+ customURLMapping.AuthURL = c.String("custom-auth-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-profile-url") {
+ customURLMapping.ProfileURL = c.String("custom-profile-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-email-url") {
+ customURLMapping.EmailURL = c.String("custom-email-url")
+ }
+
+ if c.IsSet("use-custom-urls") && c.IsSet("custom-tenant-id") {
+ customURLMapping.Tenant = c.String("custom-tenant-id")
+ }
+
+ oAuth2Config.CustomURLMapping = customURLMapping
+ source.Cfg = oAuth2Config
+
+ return auth_model.UpdateSource(ctx, source)
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/go.mod b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/go.mod
new file mode 100644
index 00000000..cbeed643
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/go.mod
@@ -0,0 +1,257 @@
+module forgejo.org
+
+go 1.24
+
+toolchain go1.24.3
+
+require (
+ code.forgejo.org/f3/gof3/v3 v3.10.6
+ code.forgejo.org/forgejo-contrib/go-libravatar v0.0.0-20191008002943-06d1c002b251
+ code.forgejo.org/forgejo/levelqueue v1.0.0
+ code.forgejo.org/forgejo/reply v1.0.2
+ code.forgejo.org/go-chi/binding v1.0.0
+ code.forgejo.org/go-chi/cache v1.0.0
+ code.forgejo.org/go-chi/captcha v1.0.1
+ code.forgejo.org/go-chi/session v1.0.1
+ code.gitea.io/actions-proto-go v0.4.0
+ code.gitea.io/sdk/gitea v0.20.0
+ codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570
+ connectrpc.com/connect v1.17.0
+ github.com/42wim/httpsig v1.2.2
+ github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
+ github.com/ProtonMail/go-crypto v1.1.6
+ github.com/PuerkitoBio/goquery v1.10.2
+ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.7.2
+ github.com/alecthomas/chroma/v2 v2.15.0
+ github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
+ github.com/blevesearch/bleve/v2 v2.5.2
+ github.com/buildkite/terminal-to-html/v3 v3.16.8
+ github.com/caddyserver/certmagic v0.22.2
+ github.com/chi-middleware/proxy v1.1.1
+ github.com/djherbis/buffer v1.2.0
+ github.com/djherbis/nio/v3 v3.0.1
+ github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
+ github.com/dustin/go-humanize v1.0.1
+ github.com/editorconfig/editorconfig-core-go/v2 v2.6.3
+ github.com/emersion/go-imap v1.2.1
+ github.com/felixge/fgprof v0.9.5
+ github.com/fsnotify/fsnotify v1.8.0
+ github.com/gliderlabs/ssh v0.3.8
+ github.com/go-ap/activitypub v0.0.0-20231114162308-e219254dc5c9
+ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
+ github.com/go-chi/chi/v5 v5.2.0
+ github.com/go-chi/cors v1.2.1
+ github.com/go-co-op/gocron v1.37.0
+ github.com/go-enry/go-enry/v2 v2.9.2
+ github.com/go-git/go-git/v5 v5.13.2
+ github.com/go-ldap/ldap/v3 v3.4.6
+ github.com/go-openapi/spec v0.20.14
+ github.com/go-sql-driver/mysql v1.9.1
+ github.com/go-webauthn/webauthn v0.12.2
+ github.com/gobwas/glob v0.2.3
+ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f
+ github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
+ github.com/golang-jwt/jwt/v5 v5.2.2
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
+ github.com/google/go-github/v64 v64.0.0
+ github.com/google/pprof v0.0.0-20241017200806-017d972448fc
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/feeds v1.2.0
+ github.com/gorilla/sessions v1.4.0
+ github.com/hashicorp/go-version v1.7.0
+ github.com/hashicorp/golang-lru/v2 v2.0.7
+ github.com/huandu/xstrings v1.5.0
+ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056
+ github.com/jhillyerd/enmime/v2 v2.1.0
+ github.com/json-iterator/go v1.1.12
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
+ github.com/klauspost/compress v1.17.11
+ github.com/klauspost/cpuid/v2 v2.2.10
+ github.com/lib/pq v1.10.9
+ github.com/markbates/goth v1.80.0
+ github.com/mattn/go-isatty v0.0.20
+ github.com/mattn/go-sqlite3 v1.14.28
+ github.com/meilisearch/meilisearch-go v0.31.0
+ github.com/mholt/archiver/v3 v3.5.1
+ github.com/microcosm-cc/bluemonday v1.0.27
+ github.com/minio/minio-go/v7 v7.0.88
+ github.com/msteinert/pam/v2 v2.1.0
+ github.com/nektos/act v0.2.52
+ github.com/niklasfasching/go-org v1.7.0
+ github.com/olivere/elastic/v7 v7.0.32
+ github.com/opencontainers/go-digest v1.0.0
+ github.com/opencontainers/image-spec v1.1.1
+ github.com/pquerna/otp v1.4.0
+ github.com/prometheus/client_golang v1.21.1
+ github.com/redis/go-redis/v9 v9.7.3
+ github.com/robfig/cron/v3 v3.0.1
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.1
+ github.com/sassoftware/go-rpmutils v0.4.0
+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3
+ github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92
+ github.com/stretchr/testify v1.10.0
+ github.com/syndtr/goleveldb v1.0.0
+ github.com/ulikunitz/xz v0.5.12
+ github.com/urfave/cli/v2 v2.27.6
+ github.com/valyala/fastjson v1.6.4
+ github.com/yohcop/openid-go v1.0.1
+ github.com/yuin/goldmark v1.7.8
+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
+ gitlab.com/gitlab-org/api/client-go v0.126.0
+ go.uber.org/mock v0.5.0
+ golang.org/x/crypto v0.36.0
+ golang.org/x/image v0.25.0
+ golang.org/x/net v0.38.0
+ golang.org/x/oauth2 v0.28.0
+ golang.org/x/sync v0.12.0
+ golang.org/x/sys v0.31.0
+ golang.org/x/text v0.23.0
+ google.golang.org/protobuf v1.36.4
+ gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
+ gopkg.in/ini.v1 v1.67.0
+ gopkg.in/yaml.v3 v3.0.1
+ mvdan.cc/xurls/v2 v2.5.0
+ xorm.io/builder v0.3.13
+ xorm.io/xorm v1.3.9
+)
+
+require (
+ cloud.google.com/go/compute/metadata v0.6.0 // indirect
+ dario.cat/mergo v1.0.0 // indirect
+ filippo.io/edwards25519 v1.1.0 // indirect
+ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
+ github.com/DataDog/zstd v1.5.5 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
+ github.com/andybalholm/cascadia v1.3.3 // indirect
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bits-and-blooms/bitset v1.22.0 // indirect
+ github.com/blevesearch/bleve_index_api v1.2.8 // indirect
+ github.com/blevesearch/geo v0.2.3 // indirect
+ github.com/blevesearch/go-faiss v1.0.25 // indirect
+ github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
+ github.com/blevesearch/gtreap v0.1.1 // indirect
+ github.com/blevesearch/mmap-go v1.0.4 // indirect
+ github.com/blevesearch/scorch_segment_api/v2 v2.3.10 // indirect
+ github.com/blevesearch/segment v0.9.1 // indirect
+ github.com/blevesearch/snowballstem v0.9.0 // indirect
+ github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
+ github.com/blevesearch/vellum v1.1.0 // indirect
+ github.com/blevesearch/zapx/v11 v11.4.2 // indirect
+ github.com/blevesearch/zapx/v12 v12.4.2 // indirect
+ github.com/blevesearch/zapx/v13 v13.4.2 // indirect
+ github.com/blevesearch/zapx/v14 v14.4.2 // indirect
+ github.com/blevesearch/zapx/v15 v15.4.2 // indirect
+ github.com/blevesearch/zapx/v16 v16.2.4 // indirect
+ github.com/boombuler/barcode v1.0.1 // indirect
+ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
+ github.com/caddyserver/zerossl v0.1.3 // indirect
+ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudflare/circl v1.6.1 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+ github.com/cyphar/filepath-securejoin v0.3.6 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/davidmz/go-pageant v1.0.2 // indirect
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
+ github.com/dlclark/regexp2 v1.11.4 // indirect
+ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
+ github.com/emirpasic/gods v1.18.1 // indirect
+ github.com/fatih/color v1.16.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.8.0 // indirect
+ github.com/go-ap/errors v0.0.0-20231003111023-183eef4b31b7 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
+ github.com/go-enry/go-oniguruma v1.2.1 // indirect
+ github.com/go-fed/httpsig v1.1.0 // indirect
+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
+ github.com/go-ini/ini v1.67.0 // indirect
+ github.com/go-openapi/jsonpointer v0.20.2 // indirect
+ github.com/go-openapi/jsonreference v0.20.4 // indirect
+ github.com/go-openapi/swag v0.22.7 // indirect
+ github.com/go-webauthn/x v0.1.20 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
+ github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
+ github.com/golang/protobuf v1.5.4 // indirect
+ github.com/golang/snappy v0.0.4 // indirect
+ github.com/google/btree v1.1.2 // indirect
+ github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/google/go-tpm v0.9.3 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
+ github.com/gorilla/mux v1.8.1 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/kevinburke/ssh_config v1.2.0 // indirect
+ github.com/klauspost/pgzip v1.2.6 // indirect
+ github.com/libdns/libdns v0.2.3 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
+ github.com/markbates/going v1.0.3 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mholt/acmez/v3 v3.1.1 // indirect
+ github.com/miekg/dns v1.1.63 // indirect
+ github.com/minio/crc64nvme v1.0.1 // indirect
+ github.com/minio/md5-simd v1.1.2 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 // indirect
+ github.com/mschoch/smat v0.2.0 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/nwaples/rardecode v1.1.3 // indirect
+ github.com/olekukonko/tablewriter v0.0.5 // indirect
+ github.com/onsi/ginkgo v1.16.5 // indirect
+ github.com/pierrec/lz4/v4 v4.1.21 // indirect
+ github.com/pjbgf/sha1cd v0.3.2 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_model v0.6.1 // indirect
+ github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/rhysd/actionlint v1.6.27 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/rogpeppe/go-internal v1.13.1 // indirect
+ github.com/rs/xid v1.6.0 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/skeema/knownhosts v1.3.0 // indirect
+ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/xanzy/ssh-agent v0.3.3 // indirect
+ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
+ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
+ github.com/zeebo/assert v1.3.0 // indirect
+ github.com/zeebo/blake3 v0.2.4 // indirect
+ go.etcd.io/bbolt v1.4.0 // indirect
+ go.uber.org/atomic v1.11.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ go.uber.org/zap/exp v0.3.0 // indirect
+ golang.org/x/mod v0.24.0 // indirect
+ golang.org/x/time v0.10.0 // indirect
+ golang.org/x/tools v0.31.0 // indirect
+ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+ gopkg.in/warnings.v0 v0.1.2 // indirect
+)
+
+replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1
+
+replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142115-2c99e1ffdfa0
+
+replace github.com/nektos/act => code.forgejo.org/forgejo/act v1.25.1
+
+replace github.com/mholt/archiver/v3 => code.forgejo.org/forgejo/archiver/v3 v3.5.1
+
+replace github.com/gliderlabs/ssh => code.forgejo.org/forgejo/ssh v0.0.0-20241211213324-5fc306ca0616
+
+replace git.sr.ht/~mariusor/go-xsd-duration => code.forgejo.org/forgejo/go-xsd-duration v0.0.0-20220703122237-02e73435a078
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/admin/auths.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/admin/auths.go
new file mode 100644
index 00000000..2c6dc763
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/admin/auths.go
@@ -0,0 +1,401 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package admin
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "forgejo.org/models/auth"
+ "forgejo.org/models/db"
+ "forgejo.org/modules/auth/pam"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/web"
+ auth_service "forgejo.org/services/auth"
+ "forgejo.org/services/auth/source/ldap"
+ "forgejo.org/services/auth/source/oauth2"
+ pam_service "forgejo.org/services/auth/source/pam"
+ "forgejo.org/services/auth/source/smtp"
+ "forgejo.org/services/context"
+ "forgejo.org/services/forms"
+
+ "xorm.io/xorm/convert"
+)
+
+const (
+ tplAuths base.TplName = "admin/auth/list"
+ tplAuthNew base.TplName = "admin/auth/new"
+ tplAuthEdit base.TplName = "admin/auth/edit"
+)
+
+// Authentications show authentication config page
+func Authentications(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.authentication")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ var err error
+ ctx.Data["Sources"], ctx.Data["Total"], err = db.FindAndCount[auth.Source](ctx, auth.FindSourcesOptions{})
+ if err != nil {
+ ctx.ServerError("auth.Sources", err)
+ return
+ }
+
+ ctx.HTML(http.StatusOK, tplAuths)
+}
+
+type dropdownItem struct {
+ Name string
+ Type any
+}
+
+var (
+ authSources = func() []dropdownItem {
+ items := []dropdownItem{
+ {auth.LDAP.String(), auth.LDAP},
+ {auth.DLDAP.String(), auth.DLDAP},
+ {auth.SMTP.String(), auth.SMTP},
+ {auth.OAuth2.String(), auth.OAuth2},
+ }
+ if pam.Supported {
+ items = append(items, dropdownItem{auth.Names[auth.PAM], auth.PAM})
+ }
+ return items
+ }()
+
+ securityProtocols = []dropdownItem{
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted], ldap.SecurityProtocolUnencrypted},
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolLDAPS], ldap.SecurityProtocolLDAPS},
+ {ldap.SecurityProtocolNames[ldap.SecurityProtocolStartTLS], ldap.SecurityProtocolStartTLS},
+ }
+)
+
+// NewAuthSource render adding a new auth source page
+func NewAuthSource(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.auths.new")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["type"] = auth.LDAP.Int()
+ ctx.Data["CurrentTypeName"] = auth.Names[auth.LDAP]
+ ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocolUnencrypted]
+ ctx.Data["smtp_auth"] = "PLAIN"
+ ctx.Data["is_active"] = true
+ ctx.Data["is_sync_enabled"] = true
+ ctx.Data["AuthSources"] = authSources
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ // only the first as default
+ ctx.Data["oauth2_provider"] = oauth2providers[0].Name()
+
+ ctx.HTML(http.StatusOK, tplAuthNew)
+}
+
+func parseLDAPConfig(form forms.AuthenticationForm) *ldap.Source {
+ var pageSize uint32
+ if form.UsePagedSearch {
+ pageSize = uint32(form.SearchPageSize)
+ }
+ return &ldap.Source{
+ Name: form.Name,
+ Host: form.Host,
+ Port: form.Port,
+ SecurityProtocol: ldap.SecurityProtocol(form.SecurityProtocol),
+ SkipVerify: form.SkipVerify,
+ BindDN: form.BindDN,
+ UserDN: form.UserDN,
+ BindPassword: form.BindPassword,
+ UserBase: form.UserBase,
+ DefaultDomainName: form.DefaultDomainName,
+ AttributeUsername: form.AttributeUsername,
+ AttributeName: form.AttributeName,
+ AttributeSurname: form.AttributeSurname,
+ AttributeMail: form.AttributeMail,
+ AttributesInBind: form.AttributesInBind,
+ AttributeSSHPublicKey: form.AttributeSSHPublicKey,
+ AttributeAvatar: form.AttributeAvatar,
+ SearchPageSize: pageSize,
+ Filter: form.Filter,
+ GroupsEnabled: form.GroupsEnabled,
+ GroupDN: form.GroupDN,
+ GroupFilter: form.GroupFilter,
+ GroupMemberUID: form.GroupMemberUID,
+ GroupTeamMap: form.GroupTeamMap,
+ GroupTeamMapRemoval: form.GroupTeamMapRemoval,
+ UserUID: form.UserUID,
+ AdminFilter: form.AdminFilter,
+ RestrictedFilter: form.RestrictedFilter,
+ AllowDeactivateAll: form.AllowDeactivateAll,
+ Enabled: true,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+}
+
+func parseSMTPConfig(form forms.AuthenticationForm) *smtp.Source {
+ return &smtp.Source{
+ Auth: form.SMTPAuth,
+ Host: form.SMTPHost,
+ Port: form.SMTPPort,
+ AllowedDomains: form.AllowedDomains,
+ ForceSMTPS: form.ForceSMTPS,
+ SkipVerify: form.SkipVerify,
+ HeloHostname: form.HeloHostname,
+ DisableHelo: form.DisableHelo,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+}
+
+func parseOAuth2Config(form forms.AuthenticationForm) *oauth2.Source {
+ var customURLMapping *oauth2.CustomURLMapping
+ if form.Oauth2UseCustomURL {
+ customURLMapping = &oauth2.CustomURLMapping{
+ TokenURL: form.Oauth2TokenURL,
+ AuthURL: form.Oauth2AuthURL,
+ ProfileURL: form.Oauth2ProfileURL,
+ EmailURL: form.Oauth2EmailURL,
+ Tenant: form.Oauth2Tenant,
+ }
+ } else {
+ customURLMapping = nil
+ }
+ var scopes []string
+ for _, s := range strings.Split(form.Oauth2Scopes, ",") {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ scopes = append(scopes, s)
+ }
+ }
+
+ return &oauth2.Source{
+ Provider: form.Oauth2Provider,
+ ClientID: form.Oauth2Key,
+ ClientSecret: form.Oauth2Secret,
+ OpenIDConnectAutoDiscoveryURL: form.OpenIDConnectAutoDiscoveryURL,
+ CustomURLMapping: customURLMapping,
+ IconURL: form.Oauth2IconURL,
+ Scopes: scopes,
+ AttributeSSHPublicKey: form.Oauth2AttributeSSHPublicKey,
+ RequiredClaimName: form.Oauth2RequiredClaimName,
+ RequiredClaimValue: form.Oauth2RequiredClaimValue,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ GroupClaimName: form.Oauth2GroupClaimName,
+ RestrictedGroup: form.Oauth2RestrictedGroup,
+ AdminGroup: form.Oauth2AdminGroup,
+ GroupTeamMap: form.Oauth2GroupTeamMap,
+ GroupTeamMapRemoval: form.Oauth2GroupTeamMapRemoval,
+ }
+}
+
+// NewAuthSourcePost response for adding an auth source
+func NewAuthSourcePost(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AuthenticationForm)
+ ctx.Data["Title"] = ctx.Tr("admin.auths.new")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["CurrentTypeName"] = auth.Type(form.Type).String()
+ ctx.Data["CurrentSecurityProtocol"] = ldap.SecurityProtocolNames[ldap.SecurityProtocol(form.SecurityProtocol)]
+ ctx.Data["AuthSources"] = authSources
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ hasTLS := false
+ var config convert.Conversion
+ switch auth.Type(form.Type) {
+ case auth.LDAP, auth.DLDAP:
+ config = parseLDAPConfig(form)
+ hasTLS = ldap.SecurityProtocol(form.SecurityProtocol) > ldap.SecurityProtocolUnencrypted
+ case auth.SMTP:
+ config = parseSMTPConfig(form)
+ hasTLS = true
+ case auth.PAM:
+ config = &pam_service.Source{
+ ServiceName: form.PAMServiceName,
+ EmailDomain: form.PAMEmailDomain,
+ SkipLocalTwoFA: form.SkipLocalTwoFA,
+ }
+ case auth.OAuth2:
+ config = parseOAuth2Config(form)
+ oauth2Config := config.(*oauth2.Source)
+ if oauth2Config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthNew, form)
+ return
+ }
+ }
+ default:
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+ ctx.Data["HasTLS"] = hasTLS
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthNew)
+ return
+ }
+
+ if err := auth.CreateSource(ctx, &auth.Source{
+ Type: auth.Type(form.Type),
+ Name: form.Name,
+ IsActive: form.IsActive,
+ IsSyncEnabled: form.IsSyncEnabled,
+ Cfg: config,
+ }); err != nil {
+ if auth.IsErrSourceAlreadyExist(err) {
+ ctx.Data["Err_Name"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthNew, form)
+ } else if oauth2.IsErrOpenIDConnectInitialize(err) {
+ ctx.Data["Err_DiscoveryURL"] = true
+ unwrapped := err.(oauth2.ErrOpenIDConnectInitialize).Unwrap()
+ ctx.RenderWithErr(ctx.Tr("admin.auths.unable_to_initialize_openid", unwrapped), tplAuthNew, form)
+ } else {
+ ctx.ServerError("auth.CreateSource", err)
+ }
+ return
+ }
+
+ log.Trace("Authentication created by admin(%s): %s", ctx.Doer.Name, form.Name)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.new_success", form.Name))
+ ctx.Redirect(setting.AppSubURL + "/admin/auths")
+}
+
+// EditAuthSource render editing auth source page
+func EditAuthSource(ctx *context.Context) {
+ ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["SecurityProtocols"] = securityProtocols
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
+ if source.IsOAuth2() {
+ type Named interface {
+ Name() string
+ }
+
+ for _, provider := range oauth2providers {
+ if provider.Name() == source.Cfg.(Named).Name() {
+ ctx.Data["CurrentOAuth2Provider"] = provider
+ break
+ }
+ }
+ }
+
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+}
+
+// EditAuthSourcePost response for editing auth source
+func EditAuthSourcePost(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AuthenticationForm)
+ ctx.Data["Title"] = ctx.Tr("admin.auths.edit")
+ ctx.Data["PageIsAdminAuthentications"] = true
+
+ ctx.Data["SMTPAuths"] = smtp.Authenticators
+ oauth2providers := oauth2.GetSupportedOAuth2Providers()
+ ctx.Data["OAuth2Providers"] = oauth2providers
+
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ return
+ }
+
+ var config convert.Conversion
+ switch auth.Type(form.Type) {
+ case auth.LDAP, auth.DLDAP:
+ config = parseLDAPConfig(form)
+ case auth.SMTP:
+ config = parseSMTPConfig(form)
+ case auth.PAM:
+ config = &pam_service.Source{
+ ServiceName: form.PAMServiceName,
+ EmailDomain: form.PAMEmailDomain,
+ }
+ case auth.OAuth2:
+ config = parseOAuth2Config(form)
+ oauth2Config := config.(*oauth2.Source)
+ if oauth2Config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(oauth2Config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.invalid_openIdConnectAutoDiscoveryURL"), tplAuthEdit, form)
+ return
+ }
+ }
+ default:
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+
+ source.Name = form.Name
+ source.IsActive = form.IsActive
+ source.IsSyncEnabled = form.IsSyncEnabled
+ source.Cfg = config
+ if err := auth.UpdateSource(ctx, source); err != nil {
+ if auth.IsErrSourceAlreadyExist(err) {
+ ctx.Data["Err_Name"] = true
+ ctx.RenderWithErr(ctx.Tr("admin.auths.login_source_exist", err.(auth.ErrSourceAlreadyExist).Name), tplAuthEdit, form)
+ } else if oauth2.IsErrOpenIDConnectInitialize(err) {
+ ctx.Flash.Error(err.Error(), true)
+ ctx.Data["Err_DiscoveryURL"] = true
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ } else {
+ ctx.ServerError("UpdateSource", err)
+ }
+ return
+ }
+ log.Trace("Authentication changed by admin(%s): %d", ctx.Doer.Name, source.ID)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.update_success"))
+ ctx.Redirect(setting.AppSubURL + "/admin/auths/" + strconv.FormatInt(form.ID, 10))
+}
+
+// DeleteAuthSource response for deleting an auth source
+func DeleteAuthSource(ctx *context.Context) {
+ source, err := auth.GetSourceByID(ctx, ctx.ParamsInt64(":authid"))
+ if err != nil {
+ ctx.ServerError("auth.GetSourceByID", err)
+ return
+ }
+
+ if err = auth_service.DeleteSource(ctx, source); err != nil {
+ if auth.IsErrSourceInUse(err) {
+ ctx.Flash.Error(ctx.Tr("admin.auths.still_in_used"))
+ } else {
+ ctx.Flash.Error(fmt.Sprintf("auth_service.DeleteSource: %v", err))
+ }
+ ctx.JSONRedirect(setting.AppSubURL + "/admin/auths/" + url.PathEscape(ctx.Params(":authid")))
+ return
+ }
+ log.Trace("Authentication deleted by admin(%s): %d", ctx.Doer.Name, source.ID)
+
+ ctx.Flash.Success(ctx.Tr("admin.auths.deletion_success"))
+ ctx.JSONRedirect(setting.AppSubURL + "/admin/auths")
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/auth/oauth.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/auth/oauth.go
new file mode 100644
index 00000000..aa599bd2
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/routers/web/auth/oauth.go
@@ -0,0 +1,1481 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ go_context "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+
+ asymkey_model "forgejo.org/models/asymkey"
+ "forgejo.org/models/auth"
+ org_model "forgejo.org/models/organization"
+ user_model "forgejo.org/models/user"
+ auth_module "forgejo.org/modules/auth"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/container"
+ "forgejo.org/modules/json"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/optional"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/timeutil"
+ "forgejo.org/modules/util"
+ "forgejo.org/modules/web"
+ "forgejo.org/modules/web/middleware"
+ auth_service "forgejo.org/services/auth"
+ source_service "forgejo.org/services/auth/source"
+ "forgejo.org/services/auth/source/oauth2"
+ "forgejo.org/services/context"
+ "forgejo.org/services/externalaccount"
+ "forgejo.org/services/forms"
+ remote_service "forgejo.org/services/remote"
+ user_service "forgejo.org/services/user"
+
+ "code.forgejo.org/go-chi/binding"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
+ "github.com/markbates/goth/providers/fitbit"
+ "github.com/markbates/goth/providers/openidConnect"
+ "github.com/markbates/goth/providers/zoom"
+ go_oauth2 "golang.org/x/oauth2"
+)
+
+const (
+ tplGrantAccess base.TplName = "user/auth/grant"
+ tplGrantError base.TplName = "user/auth/grant_error"
+)
+
+// TODO move error and responses to SDK or models
+
+// AuthorizeErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeErrorCode string
+
+const (
+ // ErrorCodeInvalidRequest represents the according error in RFC 6749
+ ErrorCodeInvalidRequest AuthorizeErrorCode = "invalid_request"
+ // ErrorCodeUnauthorizedClient represents the according error in RFC 6749
+ ErrorCodeUnauthorizedClient AuthorizeErrorCode = "unauthorized_client"
+ // ErrorCodeAccessDenied represents the according error in RFC 6749
+ ErrorCodeAccessDenied AuthorizeErrorCode = "access_denied"
+ // ErrorCodeUnsupportedResponseType represents the according error in RFC 6749
+ ErrorCodeUnsupportedResponseType AuthorizeErrorCode = "unsupported_response_type"
+ // ErrorCodeInvalidScope represents the according error in RFC 6749
+ ErrorCodeInvalidScope AuthorizeErrorCode = "invalid_scope"
+ // ErrorCodeServerError represents the according error in RFC 6749
+ ErrorCodeServerError AuthorizeErrorCode = "server_error"
+ // ErrorCodeTemporaryUnavailable represents the according error in RFC 6749
+ ErrorCodeTemporaryUnavailable AuthorizeErrorCode = "temporarily_unavailable"
+)
+
+// AuthorizeError represents an error type specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2.1
+type AuthorizeError struct {
+ ErrorCode AuthorizeErrorCode `json:"error" form:"error"`
+ ErrorDescription string
+ State string
+}
+
+// Error returns the error message
+func (err AuthorizeError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// AccessTokenErrorCode represents an error code specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenErrorCode string
+
+const (
+ // AccessTokenErrorCodeInvalidRequest represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidRequest AccessTokenErrorCode = "invalid_request"
+ // AccessTokenErrorCodeInvalidClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidClient = "invalid_client"
+ // AccessTokenErrorCodeInvalidGrant represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidGrant = "invalid_grant"
+ // AccessTokenErrorCodeUnauthorizedClient represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnauthorizedClient = "unauthorized_client"
+ // AccessTokenErrorCodeUnsupportedGrantType represents an error code specified in RFC 6749
+ AccessTokenErrorCodeUnsupportedGrantType = "unsupported_grant_type"
+ // AccessTokenErrorCodeInvalidScope represents an error code specified in RFC 6749
+ AccessTokenErrorCodeInvalidScope = "invalid_scope"
+)
+
+// AccessTokenError represents an error response specified in RFC 6749
+// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+type AccessTokenError struct {
+ ErrorCode AccessTokenErrorCode `json:"error" form:"error"`
+ ErrorDescription string `json:"error_description"`
+}
+
+// Error returns the error message
+func (err AccessTokenError) Error() string {
+ return fmt.Sprintf("%s: %s", err.ErrorCode, err.ErrorDescription)
+}
+
+// errCallback represents a oauth2 callback error
+type errCallback struct {
+ Code string
+ Description string
+}
+
+func (err errCallback) Error() string {
+ return err.Description
+}
+
+// TokenType specifies the kind of token
+type TokenType string
+
+const (
+ // TokenTypeBearer represents a token type specified in RFC 6749
+ TokenTypeBearer TokenType = "bearer"
+ // TokenTypeMAC represents a token type specified in RFC 6749
+ TokenTypeMAC = "mac"
+)
+
+// AccessTokenResponse represents a successful access token response
+// https://datatracker.ietf.org/doc/html/rfc6749#section-4.2.2
+type AccessTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType TokenType `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+}
+
+func newAccessTokenResponse(ctx go_context.Context, grant *auth.OAuth2Grant, serverKey, clientKey oauth2.JWTSigningKey) (*AccessTokenResponse, *AccessTokenError) {
+ if setting.OAuth2.InvalidateRefreshTokens {
+ if err := grant.IncreaseCounter(ctx); err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "cannot increase the grant counter",
+ }
+ }
+ }
+ // generate access token to access the API
+ expirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.AccessTokenExpirationTime)
+ accessToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Type: oauth2.TypeAccessToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ },
+ }
+ signedAccessToken, err := accessToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate refresh token to request an access token after it expired later
+ refreshExpirationDate := timeutil.TimeStampNow().Add(setting.OAuth2.RefreshTokenExpirationTime * 60 * 60).AsTime()
+ refreshToken := &oauth2.Token{
+ GrantID: grant.ID,
+ Counter: grant.Counter,
+ Type: oauth2.TypeRefreshToken,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(refreshExpirationDate),
+ },
+ }
+ signedRefreshToken, err := refreshToken.SignToken(serverKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+
+ // generate OpenID Connect id_token
+ signedIDToken := ""
+ if grant.ScopeContains("openid") {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find application",
+ }
+ }
+ user, err := user_model.GetUserByID(ctx, grant.UserID)
+ if err != nil {
+ if user_model.IsErrUserNotExist(err) {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot find user",
+ }
+ }
+ log.Error("Error loading user: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+
+ idToken := &oauth2.OIDCToken{
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(expirationDate.AsTime()),
+ Issuer: setting.AppURL,
+ Audience: []string{app.ClientID},
+ Subject: fmt.Sprint(grant.UserID),
+ },
+ Nonce: grant.Nonce,
+ }
+ if grant.ScopeContains("profile") {
+ idToken.Name = user.DisplayName()
+ idToken.PreferredUsername = user.Name
+ idToken.Profile = user.HTMLURL()
+ idToken.Picture = user.AvatarLink(ctx)
+ idToken.Website = user.Website
+ idToken.Locale = user.Language
+ idToken.UpdatedAt = user.UpdatedUnix
+ }
+ if grant.ScopeContains("email") {
+ idToken.Email = user.Email
+ idToken.EmailVerified = user.IsActive
+ }
+ if grant.ScopeContains("groups") {
+ onlyPublicGroups := ifOnlyPublicGroups(grant.Scope)
+
+ groups, err := getOAuthGroupsForUser(ctx, user, onlyPublicGroups)
+ if err != nil {
+ log.Error("Error getting groups: %v", err)
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "server error",
+ }
+ }
+ idToken.Groups = groups
+ }
+
+ signedIDToken, err = idToken.SignToken(clientKey)
+ if err != nil {
+ return nil, &AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot sign token",
+ }
+ }
+ }
+
+ return &AccessTokenResponse{
+ AccessToken: signedAccessToken,
+ TokenType: TokenTypeBearer,
+ ExpiresIn: setting.OAuth2.AccessTokenExpirationTime,
+ RefreshToken: signedRefreshToken,
+ IDToken: signedIDToken,
+ }, nil
+}
+
+type userInfoResponse struct {
+ Sub string `json:"sub"`
+ Name string `json:"name"`
+ Username string `json:"preferred_username"`
+ Email string `json:"email"`
+ Picture string `json:"picture"`
+ Groups []string `json:"groups,omitempty"`
+}
+
+func ifOnlyPublicGroups(scopes string) bool {
+ scopes = strings.ReplaceAll(scopes, ",", " ")
+ scopesList := strings.Fields(scopes)
+ for _, scope := range scopesList {
+ if scope == "all" || scope == "read:organization" || scope == "read:admin" {
+ return false
+ }
+ }
+ return true
+}
+
+// InfoOAuth manages request for userinfo endpoint
+func InfoOAuth(ctx *context.Context) {
+ if ctx.Doer == nil || ctx.Data["AuthedMethod"] != (&auth_service.OAuth2{}).Name() {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Bearer realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ response := &userInfoResponse{
+ Sub: fmt.Sprint(ctx.Doer.ID),
+ Name: ctx.Doer.DisplayName(),
+ Username: ctx.Doer.Name,
+ Email: ctx.Doer.Email,
+ Picture: ctx.Doer.AvatarLink(ctx),
+ }
+
+ var token string
+ if auHead := ctx.Req.Header.Get("Authorization"); auHead != "" {
+ auths := strings.Fields(auHead)
+ if len(auths) == 2 && (auths[0] == "token" || strings.ToLower(auths[0]) == "bearer") {
+ token = auths[1]
+ }
+ }
+
+ _, grantScopes := auth_service.CheckOAuthAccessToken(ctx, token)
+ onlyPublicGroups := ifOnlyPublicGroups(grantScopes)
+
+ groups, err := getOAuthGroupsForUser(ctx, ctx.Doer, onlyPublicGroups)
+ if err != nil {
+ ctx.ServerError("Oauth groups for user", err)
+ return
+ }
+ response.Groups = groups
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// returns a list of "org" and "org:team" strings,
+// that the given user is a part of.
+func getOAuthGroupsForUser(ctx go_context.Context, user *user_model.User, onlyPublicGroups bool) ([]string, error) {
+ orgs, err := org_model.GetUserOrgsList(ctx, user)
+ if err != nil {
+ return nil, fmt.Errorf("GetUserOrgList: %w", err)
+ }
+
+ var groups []string
+ for _, org := range orgs {
+ if setting.OAuth2.EnableAdditionalGrantScopes {
+ if onlyPublicGroups {
+ public, err := org_model.IsPublicMembership(ctx, org.ID, user.ID)
+ if !public && err == nil {
+ continue
+ }
+ }
+ }
+
+ groups = append(groups, org.Name)
+ teams, err := org.LoadTeams(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("LoadTeams: %w", err)
+ }
+ for _, team := range teams {
+ if team.IsMember(ctx, user.ID) {
+ groups = append(groups, org.Name+":"+team.LowerName)
+ }
+ }
+ }
+ return groups, nil
+}
+
+func parseBasicAuth(ctx *context.Context) (username, password string, err error) {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ return base.BasicAuthDecode(authData)
+ }
+ return "", "", errors.New("invalid basic authentication")
+}
+
+// IntrospectOAuth introspects an oauth token
+func IntrospectOAuth(ctx *context.Context) {
+ clientIDValid := false
+ if clientID, clientSecret, err := parseBasicAuth(ctx); err == nil {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, clientID)
+ if err != nil && !auth.IsErrOauthClientIDInvalid(err) {
+ // this is likely a database error; log it and respond without details
+ log.Error("Error retrieving client_id: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+ clientIDValid = err == nil && app.ValidateClientSecret([]byte(clientSecret))
+ }
+ if !clientIDValid {
+ ctx.Resp.Header().Set("WWW-Authenticate", `Basic realm=""`)
+ ctx.PlainText(http.StatusUnauthorized, "no valid authorization")
+ return
+ }
+
+ var response struct {
+ Active bool `json:"active"`
+ Scope string `json:"scope,omitempty"`
+ Username string `json:"username,omitempty"`
+ jwt.RegisteredClaims
+ }
+
+ form := web.GetForm(ctx).(*forms.IntrospectTokenForm)
+ token, err := oauth2.ParseToken(form.Token, oauth2.DefaultSigningKey)
+ if err == nil {
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err == nil && grant != nil {
+ app, err := auth.GetOAuth2ApplicationByID(ctx, grant.ApplicationID)
+ if err == nil && app != nil {
+ response.Active = true
+ response.Scope = grant.Scope
+ response.Issuer = setting.AppURL
+ response.Audience = []string{app.ClientID}
+ response.Subject = fmt.Sprint(grant.UserID)
+ }
+ if user, err := user_model.GetUserByID(ctx, grant.UserID); err == nil {
+ response.Username = user.Name
+ }
+ }
+ }
+
+ ctx.JSON(http.StatusOK, response)
+}
+
+// AuthorizeOAuth manages authorize requests
+func AuthorizeOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.AuthorizationForm)
+ errs := binding.Errors{}
+ errs = form.Validate(ctx.Req, errs)
+ if len(errs) > 0 {
+ errstring := ""
+ for _, e := range errs {
+ errstring += e.Error() + "\n"
+ }
+ ctx.ServerError("AuthorizeOAuth: Validate: ", fmt.Errorf("errors occurred during validation: %s", errstring))
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ if auth.IsErrOauthClientIDInvalid(err) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnauthorizedClient,
+ ErrorDescription: "Client ID not registered",
+ State: form.State,
+ }, "")
+ return
+ }
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+
+ var user *user_model.User
+ if app.UID != 0 {
+ user, err = user_model.GetUserByID(ctx, app.UID)
+ if err != nil {
+ ctx.ServerError("GetUserByID", err)
+ return
+ }
+ }
+
+ if !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "Unregistered Redirect URI",
+ State: form.State,
+ }, "")
+ return
+ }
+
+ if form.ResponseType != "code" {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeUnsupportedResponseType,
+ ErrorDescription: "Only code response type is supported.",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ // pkce support
+ switch form.CodeChallengeMethod {
+ case "S256":
+ case "plain":
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallengeMethod); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ if err := ctx.Session.Set("CodeChallengeMethod", form.CodeChallenge); err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "cannot set code challenge",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ case "":
+ // "Authorization servers SHOULD reject authorization requests from native apps that don't use PKCE by returning an error message"
+ // https://datatracker.ietf.org/doc/html/rfc8252#section-8.1
+ if !app.ConfidentialClient {
+ // "the authorization endpoint MUST return the authorization error response with the "error" value set to "invalid_request""
+ // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "PKCE is required for public clients",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+ default:
+ // "If the server supporting PKCE does not support the requested transformation, the authorization endpoint MUST return the authorization error response with "error" value set to "invalid_request"."
+ // https://www.rfc-editor.org/rfc/rfc7636#section-4.4.1
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeInvalidRequest,
+ ErrorDescription: "unsupported code challenge method",
+ State: form.State,
+ }, form.RedirectURI)
+ return
+ }
+
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+
+ // Redirect if user already granted access and the application is confidential.
+ // I.e. always require authorization for public clients as recommended by RFC 6749 Section 10.2
+ if app.ConfidentialClient && grant != nil {
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, form.CodeChallenge, form.CodeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ // Update nonce to reflect the new session
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+ ctx.Redirect(redirect.String())
+ return
+ }
+
+ // show authorize page to grant access
+ ctx.Data["Application"] = app
+ ctx.Data["RedirectURI"] = form.RedirectURI
+ ctx.Data["State"] = form.State
+ ctx.Data["Scope"] = form.Scope
+ ctx.Data["Nonce"] = form.Nonce
+ if user != nil {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`@%s`, html.EscapeString(user.HomeLink()), html.EscapeString(user.Name)))
+ } else {
+ ctx.Data["ApplicationCreatorLinkHTML"] = template.HTML(fmt.Sprintf(`%s`, html.EscapeString(setting.AppSubURL+"/"), html.EscapeString(setting.AppName)))
+ }
+ ctx.Data["ApplicationRedirectDomainHTML"] = template.HTML("" + html.EscapeString(form.RedirectURI) + "")
+ // TODO document SESSION <=> FORM
+ err = ctx.Session.Set("client_id", app.ClientID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("redirect_uri", form.RedirectURI)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ err = ctx.Session.Set("state", form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ log.Error(err.Error())
+ return
+ }
+ // Here we're just going to try to release the session early
+ if err := ctx.Session.Release(); err != nil {
+ // we'll tolerate errors here as they *should* get saved elsewhere
+ log.Error("Unable to save changes to the session: %v", err)
+ }
+ ctx.HTML(http.StatusOK, tplGrantAccess)
+}
+
+// GrantApplicationOAuth manages the post request submitted when a user grants access to an application
+func GrantApplicationOAuth(ctx *context.Context) {
+ form := web.GetForm(ctx).(*forms.GrantApplicationForm)
+ if ctx.Session.Get("client_id") != form.ClientID || ctx.Session.Get("state") != form.State ||
+ ctx.Session.Get("redirect_uri") != form.RedirectURI {
+ ctx.Error(http.StatusBadRequest)
+ return
+ }
+
+ if !form.Granted {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "the request is denied",
+ ErrorCode: ErrorCodeAccessDenied,
+ }, form.RedirectURI)
+ return
+ }
+
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ ctx.ServerError("GetOAuth2ApplicationByClientID", err)
+ return
+ }
+ grant, err := app.GetGrantByUserID(ctx, ctx.Doer.ID)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ if grant == nil {
+ grant, err = app.CreateGrant(ctx, ctx.Doer.ID, form.Scope)
+ if err != nil {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "cannot create grant for user",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+ } else if grant.Scope != form.Scope {
+ handleAuthorizeError(ctx, AuthorizeError{
+ State: form.State,
+ ErrorDescription: "a grant exists with different scope",
+ ErrorCode: ErrorCodeServerError,
+ }, form.RedirectURI)
+ return
+ }
+
+ if len(form.Nonce) > 0 {
+ err := grant.SetNonce(ctx, form.Nonce)
+ if err != nil {
+ log.Error("Unable to update nonce: %v", err)
+ }
+ }
+
+ var codeChallenge, codeChallengeMethod string
+ codeChallenge, _ = ctx.Session.Get("CodeChallenge").(string)
+ codeChallengeMethod, _ = ctx.Session.Get("CodeChallengeMethod").(string)
+
+ code, err := grant.GenerateNewAuthorizationCode(ctx, form.RedirectURI, codeChallenge, codeChallengeMethod)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ redirect, err := code.GenerateRedirectURI(form.State)
+ if err != nil {
+ handleServerError(ctx, form.State, form.RedirectURI, err)
+ return
+ }
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// OIDCWellKnown generates JSON so OIDC clients know Gitea's capabilities
+func OIDCWellKnown(ctx *context.Context) {
+ ctx.Data["SigningKey"] = oauth2.DefaultSigningKey
+ ctx.JSONTemplate("user/auth/oidc_wellknown")
+}
+
+// OIDCKeys generates the JSON Web Key Set
+func OIDCKeys(ctx *context.Context) {
+ jwk, err := oauth2.DefaultSigningKey.ToJWK()
+ if err != nil {
+ log.Error("Error converting signing key to JWK: %v", err)
+ ctx.Error(http.StatusInternalServerError)
+ return
+ }
+
+ jwk["use"] = "sig"
+
+ jwks := map[string][]map[string]string{
+ "keys": {
+ jwk,
+ },
+ }
+
+ ctx.Resp.Header().Set("Content-Type", "application/json")
+ enc := json.NewEncoder(ctx.Resp)
+ if err := enc.Encode(jwks); err != nil {
+ log.Error("Failed to encode representation as json. Error: %v", err)
+ }
+}
+
+// AccessTokenOAuth manages all access token requests by the client
+func AccessTokenOAuth(ctx *context.Context) {
+ form := *web.GetForm(ctx).(*forms.AccessTokenForm)
+ // if there is no ClientID or ClientSecret in the request body, fill these fields by the Authorization header and ensure the provided field matches the Authorization header
+ if form.ClientID == "" || form.ClientSecret == "" {
+ authHeader := ctx.Req.Header.Get("Authorization")
+ if authType, authData, ok := strings.Cut(authHeader, " "); ok && strings.EqualFold(authType, "Basic") {
+ clientID, clientSecret, err := base.BasicAuthDecode(authData)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot parse basic auth header",
+ })
+ return
+ }
+ // validate that any fields present in the form match the Basic auth header
+ if form.ClientID != "" && form.ClientID != clientID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_id in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientID = clientID
+ if form.ClientSecret != "" && form.ClientSecret != clientSecret {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "client_secret in request body inconsistent with Authorization header",
+ })
+ return
+ }
+ form.ClientSecret = clientSecret
+ }
+ }
+
+ serverKey := oauth2.DefaultSigningKey
+ clientKey := serverKey
+ if serverKey.IsSymmetric() {
+ var err error
+ clientKey, err = oauth2.CreateJWTSigningKey(serverKey.SigningMethod().Alg(), []byte(form.ClientSecret))
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "Error creating signing key",
+ })
+ return
+ }
+ }
+
+ switch form.GrantType {
+ case "refresh_token":
+ handleRefreshToken(ctx, form, serverKey, clientKey)
+ case "authorization_code":
+ handleAuthorizationCode(ctx, form, serverKey, clientKey)
+ default:
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnsupportedGrantType,
+ ErrorDescription: "Only refresh_token or authorization_code grant type is supported",
+ })
+ }
+}
+
+func handleRefreshToken(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: %q", form.ClientID),
+ })
+ return
+ }
+ // "The authorization server MUST ... require client authentication for confidential clients"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-6
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ // "invalid_client ... Client authentication failed"
+ // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+
+ token, err := oauth2.ParseToken(form.RefreshToken, serverKey)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unable to parse refresh token",
+ })
+ return
+ }
+ // get grant before increasing counter
+ grant, err := auth.GetOAuth2GrantByID(ctx, token.GrantID)
+ if err != nil || grant == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "grant does not exist",
+ })
+ return
+ }
+
+ // check if token got already used
+ if setting.OAuth2.InvalidateRefreshTokens && (grant.Counter != token.Counter || token.Counter == 0) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "token was already used",
+ })
+ log.Warn("A client tried to use a refresh token for grant_id = %d was used twice!", grant.ID)
+ return
+ }
+ accessToken, tokenErr := newAccessTokenResponse(ctx, grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ ctx.JSON(http.StatusOK, accessToken)
+}
+
+func handleAuthorizationCode(ctx *context.Context, form forms.AccessTokenForm, serverKey, clientKey oauth2.JWTSigningKey) {
+ app, err := auth.GetOAuth2ApplicationByClientID(ctx, form.ClientID)
+ if err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidClient,
+ ErrorDescription: fmt.Sprintf("cannot load client with client id: '%s'", form.ClientID),
+ })
+ return
+ }
+ if app.ConfidentialClient && !app.ValidateClientSecret([]byte(form.ClientSecret)) {
+ errorDescription := "invalid client secret"
+ if form.ClientSecret == "" {
+ errorDescription = "invalid empty client secret"
+ }
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: errorDescription,
+ })
+ return
+ }
+ if form.RedirectURI != "" && !app.ContainsRedirectURI(form.RedirectURI) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "unexpected redirect URI",
+ })
+ return
+ }
+ authorizationCode, err := auth.GetOAuth2AuthorizationByCode(ctx, form.Code)
+ if err != nil || authorizationCode == nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "client is not authorized",
+ })
+ return
+ }
+ // check if code verifier authorizes the client, PKCE support
+ if !authorizationCode.ValidateCodeChallenge(form.CodeVerifier) {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeUnauthorizedClient,
+ ErrorDescription: "failed PKCE code challenge",
+ })
+ return
+ }
+ // check if granted for this application
+ if authorizationCode.Grant.ApplicationID != app.ID {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidGrant,
+ ErrorDescription: "invalid grant",
+ })
+ return
+ }
+ // remove token from database to deny duplicate usage
+ if err := authorizationCode.Invalidate(ctx); err != nil {
+ handleAccessTokenError(ctx, AccessTokenError{
+ ErrorCode: AccessTokenErrorCodeInvalidRequest,
+ ErrorDescription: "cannot proceed your request",
+ })
+ }
+ resp, tokenErr := newAccessTokenResponse(ctx, authorizationCode.Grant, serverKey, clientKey)
+ if tokenErr != nil {
+ handleAccessTokenError(ctx, *tokenErr)
+ return
+ }
+ // send successful response
+ ctx.JSON(http.StatusOK, resp)
+}
+
+func handleAccessTokenError(ctx *context.Context, acErr AccessTokenError) {
+ ctx.JSON(http.StatusBadRequest, acErr)
+}
+
+func handleServerError(ctx *context.Context, state, redirectURI string, err error) {
+ log.Error("OAuth server error: %v", err)
+ handleAuthorizeError(ctx, AuthorizeError{
+ ErrorCode: ErrorCodeServerError,
+ ErrorDescription: "A server error occurred",
+ State: state,
+ }, redirectURI)
+}
+
+func handleAuthorizeError(ctx *context.Context, authErr AuthorizeError, redirectURI string) {
+ if redirectURI == "" {
+ log.Warn("Authorization failed: %v", authErr.ErrorDescription)
+ ctx.Data["Error"] = authErr
+ ctx.HTML(http.StatusBadRequest, tplGrantError)
+ return
+ }
+ redirect, err := url.Parse(redirectURI)
+ if err != nil {
+ ctx.ServerError("url.Parse", err)
+ return
+ }
+ q := redirect.Query()
+ q.Set("error", string(authErr.ErrorCode))
+ q.Set("error_description", authErr.ErrorDescription)
+ q.Set("state", authErr.State)
+ redirect.RawQuery = q.Encode()
+ ctx.Redirect(redirect.String(), http.StatusSeeOther)
+}
+
+// SignInOAuth handles the OAuth2 login buttons
+func SignInOAuth(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ redirectTo := ctx.FormString("redirect_to")
+ if len(redirectTo) > 0 {
+ middleware.SetRedirectToCookie(ctx.Resp, redirectTo)
+ }
+
+ // try to do a direct callback flow, so we don't authenticate the user again but use the valid accesstoken to get the user
+ user, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err == nil && user != nil {
+ // we got the user without going through the whole OAuth2 authentication flow again
+ handleOAuth2SignIn(ctx, authSource, user, gothUser)
+ return
+ }
+
+ codeChallenge, err := generateCodeChallenge(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", fmt.Errorf("could not generate code_challenge: %w", err))
+ return
+ }
+
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ if strings.Contains(err.Error(), "no provider for ") {
+ if err = oauth2.ResetOAuth2(ctx); err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+ if err = authSource.Cfg.(*oauth2.Source).Callout(ctx.Req, ctx.Resp, codeChallenge); err != nil {
+ ctx.ServerError("SignIn", err)
+ }
+ return
+ }
+ ctx.ServerError("SignIn", err)
+ }
+ // redirect is done in oauth2.Auth
+}
+
+// SignInOAuthCallback handles the callback from the given provider
+func SignInOAuthCallback(ctx *context.Context) {
+ provider := ctx.Params(":provider")
+
+ if ctx.Req.FormValue("error") != "" {
+ var errorKeyValues []string
+ for k, vv := range ctx.Req.Form {
+ for _, v := range vv {
+ errorKeyValues = append(errorKeyValues, fmt.Sprintf("%s = %s", html.EscapeString(k), html.EscapeString(v)))
+ }
+ }
+ sort.Strings(errorKeyValues)
+ ctx.Flash.Error(strings.Join(errorKeyValues, "
"), true)
+ }
+
+ // first look if the provider is still active
+ authSource, err := auth.GetActiveOAuth2SourceByName(ctx, provider)
+ if err != nil {
+ ctx.ServerError("SignIn", err)
+ return
+ }
+
+ if authSource == nil {
+ ctx.ServerError("SignIn", errors.New("no valid provider found, check configured callback url in provider"))
+ return
+ }
+
+ u, gothUser, err := oAuth2UserLoginCallback(ctx, authSource, ctx.Req, ctx.Resp)
+ if err != nil {
+ if user_model.IsErrUserProhibitLogin(err) {
+ uplerr := err.(user_model.ErrUserProhibitLogin)
+ log.Info("Failed authentication attempt for %s from %s: %v", uplerr.Name, ctx.RemoteAddr(), err)
+ ctx.Data["Title"] = ctx.Tr("auth.prohibit_login")
+ ctx.HTML(http.StatusOK, "user/auth/prohibit_login")
+ return
+ }
+ if callbackErr, ok := err.(errCallback); ok {
+ log.Info("Failed OAuth callback: (%v) %v", callbackErr.Code, callbackErr.Description)
+ switch callbackErr.Code {
+ case "access_denied":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.access_denied"))
+ case "temporarily_unavailable":
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error.temporarily_unavailable"))
+ default:
+ ctx.Flash.Error(ctx.Tr("auth.oauth.signin.error"))
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ if err, ok := err.(*go_oauth2.RetrieveError); ok {
+ ctx.Flash.Error("OAuth2 RetrieveError: "+err.Error(), true)
+ ctx.Redirect(setting.AppSubURL + "/user/login")
+ return
+ }
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+
+ if u == nil {
+ if ctx.Doer != nil {
+ // attach user to already logged in user
+ err = externalaccount.LinkAccountToUser(ctx, ctx.Doer, gothUser)
+ if err != nil {
+ ctx.ServerError("UserLinkAccount", err)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/settings/security")
+ return
+ } else if !setting.Service.AllowOnlyInternalRegistration && setting.OAuth2Client.EnableAutoRegistration {
+ // create new user with details from oauth2 provider
+ if gothUser.UserID == "" {
+ log.Error("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ if authSource.IsOAuth2() && authSource.Cfg.(*oauth2.Source).Provider == "openidConnect" {
+ log.Error("You may need to change the 'OPENID_CONNECT_SCOPES' setting to request all required fields")
+ }
+ err = fmt.Errorf("OAuth2 Provider %s returned empty or missing field: UserID", authSource.Name)
+ ctx.ServerError("CreateUser", err)
+ return
+ }
+ var missingFields []string
+ if gothUser.Email == "" {
+ missingFields = append(missingFields, "email")
+ }
+ if setting.OAuth2Client.Username == setting.OAuth2UsernameNickname && gothUser.NickName == "" {
+ missingFields = append(missingFields, "nickname")
+ }
+ if len(missingFields) > 0 {
+ // we don't have enough information to create an account automatically,
+ // so we prompt the user for the remaining bits
+ log.Trace("OAuth2 Provider %s returned empty or missing fields: %s, prompting the user for them", authSource.Name, missingFields)
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ uname, err := getUserName(&gothUser)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ u = &user_model.User{
+ Name: uname,
+ FullName: gothUser.Name,
+ Email: gothUser.Email,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ LoginName: gothUser.UserID,
+ }
+
+ overwriteDefault := &user_model.CreateUserOverwriteOptions{
+ IsActive: optional.Some(!setting.OAuth2Client.RegisterEmailConfirm && !setting.Service.RegisterManualConfirm),
+ }
+
+ source := authSource.Cfg.(*oauth2.Source)
+
+ isAdmin, isRestricted := getUserAdminAndRestrictedFromGroupClaims(source, &gothUser)
+ u.IsAdmin = isAdmin.ValueOrDefault(false)
+ u.IsRestricted = isRestricted.ValueOrDefault(setting.Service.DefaultUserIsRestricted)
+
+ if !createAndHandleCreatedUser(ctx, base.TplName(""), nil, u, overwriteDefault, &gothUser, setting.OAuth2Client.AccountLinking != setting.OAuth2AccountLinkingDisabled) {
+ // error already handled
+ return
+ }
+
+ if err := syncGroupsToTeams(ctx, source, &gothUser, u); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ } else {
+ // no existing user is found, request attach or new account
+ showLinkingLogin(ctx, gothUser)
+ return
+ }
+ }
+
+ handleOAuth2SignIn(ctx, authSource, u, gothUser)
+}
+
+func claimValueToStringSet(claimValue any) container.Set[string] {
+ var groups []string
+
+ switch rawGroup := claimValue.(type) {
+ case []string:
+ groups = rawGroup
+ case []any:
+ for _, group := range rawGroup {
+ groups = append(groups, fmt.Sprintf("%s", group))
+ }
+ default:
+ str := fmt.Sprintf("%s", rawGroup)
+ groups = strings.Split(str, ",")
+ }
+ return container.SetOf(groups...)
+}
+
+func syncGroupsToTeams(ctx *context.Context, source *oauth2.Source, gothUser *goth.User, u *user_model.User) error {
+ if source.GroupTeamMap != "" || source.GroupTeamMapRemoval {
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(source.GroupTeamMap)
+ if err != nil {
+ return err
+ }
+
+ groups := getClaimedGroups(source, gothUser)
+
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, source.GroupTeamMapRemoval); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func getClaimedGroups(source *oauth2.Source, gothUser *goth.User) container.Set[string] {
+ groupClaims, has := gothUser.RawData[source.GroupClaimName]
+ if !has {
+ return nil
+ }
+
+ return claimValueToStringSet(groupClaims)
+}
+
+func getUserAdminAndRestrictedFromGroupClaims(source *oauth2.Source, gothUser *goth.User) (isAdmin, isRestricted optional.Option[bool]) {
+ groups := getClaimedGroups(source, gothUser)
+
+ if source.AdminGroup != "" {
+ isAdmin = optional.Some(groups.Contains(source.AdminGroup))
+ }
+ if source.RestrictedGroup != "" {
+ isRestricted = optional.Some(groups.Contains(source.RestrictedGroup))
+ }
+
+ return isAdmin, isRestricted
+}
+
+func showLinkingLogin(ctx *context.Context, gothUser goth.User) {
+ if err := updateSession(ctx, nil, map[string]any{
+ "linkAccountGothUser": gothUser,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+ ctx.Redirect(setting.AppSubURL + "/user/link_account")
+}
+
+func updateAvatarIfNeed(ctx *context.Context, url string, u *user_model.User) {
+ if setting.OAuth2Client.UpdateAvatar && len(url) > 0 {
+ resp, err := http.Get(url)
+ if err == nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ // ignore any error
+ if err == nil && resp.StatusCode == http.StatusOK {
+ data, err := io.ReadAll(io.LimitReader(resp.Body, setting.Avatar.MaxFileSize+1))
+ if err == nil && int64(len(data)) <= setting.Avatar.MaxFileSize {
+ _ = user_service.UploadAvatar(ctx, u, data)
+ }
+ }
+ }
+}
+
+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 {
+ needs2FA, err = auth.HasTwoFactorByUID(ctx, u.ID)
+ if err != nil {
+ ctx.ServerError("UserSignIn", err)
+ return
+ }
+ }
+
+ oauth2Source := source.Cfg.(*oauth2.Source)
+ groupTeamMapping, err := auth_module.UnmarshalGroupTeamMapping(oauth2Source.GroupTeamMap)
+ if err != nil {
+ ctx.ServerError("UnmarshalGroupTeamMapping", err)
+ return
+ }
+
+ groups := getClaimedGroups(oauth2Source, &gothUser)
+
+ // If this user is enrolled in 2FA and this source doesn't override it,
+ // we can't sign the user in just yet. Instead, redirect them to the 2FA authentication page.
+ if !needs2FA {
+ if err := updateSession(ctx, nil, map[string]any{
+ "uid": u.ID,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // Clear whatever CSRF cookie has right now, force to generate a new one
+ ctx.Csrf.DeleteCookie(ctx)
+
+ opts := &user_service.UpdateOptions{
+ SetLastLogin: true,
+ }
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ // update external user information
+ if err := externalaccount.UpdateExternalUser(ctx, u, gothUser); err != nil {
+ if !errors.Is(err, util.ErrNotExist) {
+ log.Error("UpdateExternalUser failed: %v", err)
+ }
+ }
+
+ if err := resetLocale(ctx, u); err != nil {
+ ctx.ServerError("resetLocale", err)
+ return
+ }
+
+ if redirectTo := ctx.GetSiteCookie("redirect_to"); len(redirectTo) > 0 {
+ middleware.DeleteRedirectToCookie(ctx.Resp)
+ ctx.RedirectToFirst(redirectTo)
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/")
+ return
+ }
+
+ opts := &user_service.UpdateOptions{}
+ opts.IsAdmin, opts.IsRestricted = getUserAdminAndRestrictedFromGroupClaims(oauth2Source, &gothUser)
+ if opts.IsAdmin.Has() || opts.IsRestricted.Has() {
+ if err := user_service.UpdateUser(ctx, u, opts); err != nil {
+ ctx.ServerError("UpdateUser", err)
+ return
+ }
+ }
+
+ if oauth2Source.GroupTeamMap != "" || oauth2Source.GroupTeamMapRemoval {
+ if err := source_service.SyncGroupsToTeams(ctx, u, groups, groupTeamMapping, oauth2Source.GroupTeamMapRemoval); err != nil {
+ ctx.ServerError("SyncGroupsToTeams", err)
+ return
+ }
+ }
+
+ if err := updateSession(ctx, nil, map[string]any{
+ // User needs to use 2FA, save data and redirect to 2FA page.
+ "twofaUid": u.ID,
+ "twofaRemember": false,
+ }); err != nil {
+ ctx.ServerError("updateSession", err)
+ return
+ }
+
+ // If WebAuthn is enrolled -> Redirect to WebAuthn instead
+ regs, err := auth.GetWebAuthnCredentialsByUID(ctx, u.ID)
+ if err == nil && len(regs) > 0 {
+ ctx.Redirect(setting.AppSubURL + "/user/webauthn")
+ return
+ }
+
+ ctx.Redirect(setting.AppSubURL + "/user/two_factor")
+}
+
+// generateCodeChallenge stores a code verifier in the session and returns a S256 code challenge for PKCE
+func generateCodeChallenge(ctx *context.Context, provider string) (codeChallenge string, err error) {
+ // the `code_verifier` is only forwarded by specific providers
+ // https://codeberg.org/forgejo/forgejo/issues/4033
+ p, ok := goth.GetProviders()[provider]
+ if !ok {
+ return "", nil
+ }
+ switch p.(type) {
+ default:
+ return "", nil
+ case *openidConnect.Provider, *fitbit.Provider, *zoom.Provider:
+ // those providers forward the `code_verifier`
+ // a code_challenge can be generated
+ }
+
+ codeVerifier, err := util.CryptoRandomString(43) // 256/log2(62) = 256 bits of entropy (each char having log2(62) of randomness)
+ if err != nil {
+ return "", err
+ }
+ if err = ctx.Session.Set("CodeVerifier", codeVerifier); err != nil {
+ return "", err
+ }
+ return encodeCodeChallenge(codeVerifier)
+}
+
+func encodeCodeChallenge(codeVerifier string) (string, error) {
+ hasher := sha256.New()
+ _, err := io.WriteString(hasher, codeVerifier)
+ codeChallenge := base64.RawURLEncoding.EncodeToString(hasher.Sum(nil))
+ return codeChallenge, err
+}
+
+// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful
+// login the user
+func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) {
+ gothUser, err := oAuth2FetchUser(ctx, authSource, request, response)
+ if err != nil {
+ return nil, goth.User{}, err
+ }
+
+ if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
+ return nil, goth.User{}, err
+ }
+
+ u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser)
+ return u, gothUser, err
+}
+
+func oAuth2FetchUser(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) {
+ oauth2Source := authSource.Cfg.(*oauth2.Source)
+
+ // Make sure that the response is not an error response.
+ errorName := request.FormValue("error")
+
+ if len(errorName) > 0 {
+ errorDescription := request.FormValue("error_description")
+
+ // Delete the goth session
+ err := gothic.Logout(response, request)
+ if err != nil {
+ return goth.User{}, err
+ }
+
+ return goth.User{}, errCallback{
+ Code: errorName,
+ Description: errorDescription,
+ }
+ }
+
+ // Proceed to authenticate through goth.
+ codeVerifier, _ := ctx.Session.Get("CodeVerifier").(string)
+ _ = ctx.Session.Delete("CodeVerifier")
+ gothUser, err := oauth2Source.Callback(request, response, codeVerifier)
+ if err != nil {
+ if err.Error() == "securecookie: the value is too long" || strings.Contains(err.Error(), "Data too long") {
+ log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength)
+ }
+ return goth.User{}, err
+ }
+
+ if oauth2Source.RequiredClaimName != "" {
+ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+ if !has {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+
+ if oauth2Source.RequiredClaimValue != "" {
+ groups := claimValueToStringSet(claimInterface)
+
+ if !groups.Contains(oauth2Source.RequiredClaimValue) {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+ }
+ }
+ }
+
+ return gothUser, nil
+}
+
+func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) {
+ user := &user_model.User{
+ LoginName: gothUser.UserID,
+ LoginType: auth.OAuth2,
+ LoginSource: authSource.ID,
+ }
+
+ hasUser, err := user_model.GetUser(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+
+ if hasUser {
+ return user, nil
+ }
+ log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType)
+
+ // search in external linked users
+ externalLoginUser := &user_model.ExternalLoginUser{
+ ExternalID: gothUser.UserID,
+ LoginSourceID: authSource.ID,
+ }
+ hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
+ if err != nil {
+ return nil, err
+ }
+ if hasUser {
+ user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
+ return user, err
+ }
+
+ // no user found to login
+ return nil, nil
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/providers_openid.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/providers_openid.go
new file mode 100644
index 00000000..7950506a
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/providers_openid.go
@@ -0,0 +1,62 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "html/template"
+
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/svg"
+
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/providers/openidConnect"
+)
+
+// OpenIDProvider is a GothProvider for OpenID
+type OpenIDProvider struct{}
+
+// Name provides the technical name for this provider
+func (o *OpenIDProvider) Name() string {
+ return "openidConnect"
+}
+
+// DisplayName returns the friendly name for this provider
+func (o *OpenIDProvider) DisplayName() string {
+ return "OpenID Connect"
+}
+
+// IconHTML returns icon HTML for this provider
+func (o *OpenIDProvider) IconHTML(size int) template.HTML {
+ return svg.RenderHTML("gitea-openid", size, "tw-mr-2")
+}
+
+// CreateGothProvider creates a GothProvider from this Provider
+func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, source *Source) (goth.Provider, error) {
+ scopes := setting.OAuth2Client.OpenIDConnectScopes
+ if len(scopes) == 0 {
+ scopes = append(scopes, source.Scopes...)
+ }
+
+ provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...)
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
+ return provider, err
+}
+
+// CustomURLSettings returns the custom url settings for this provider
+func (o *OpenIDProvider) CustomURLSettings() *CustomURLSettings {
+ return nil
+}
+
+func (o *OpenIDProvider) CanProvideSSHKeys() bool {
+ return true
+}
+
+var _ GothProvider = &OpenIDProvider{}
+
+func init() {
+ RegisterGothProvider(&OpenIDProvider{})
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/source.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/source.go
new file mode 100644
index 00000000..5245f882
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/auth/source/oauth2/source.go
@@ -0,0 +1,59 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package oauth2
+
+import (
+ "strings"
+
+ "forgejo.org/models/auth"
+ "forgejo.org/modules/json"
+)
+
+// Source holds configuration for the OAuth2 login source.
+type Source struct {
+ Provider string
+ ClientID string
+ ClientSecret string
+ OpenIDConnectAutoDiscoveryURL string
+ CustomURLMapping *CustomURLMapping
+ IconURL string
+
+ 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
+}
+
+// FromDB fills up an OAuth2Config from serialized format.
+func (source *Source) FromDB(bs []byte) error {
+ return json.UnmarshalHandleDoubleEncode(bs, &source)
+}
+
+// ToDB exports an SMTPConfig to a serialized format.
+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
+}
+
+func init() {
+ auth.RegisterTypeConfig(auth.OAuth2, &Source{})
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/forms/auth_form.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/forms/auth_form.go
new file mode 100644
index 00000000..e665ca0d
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/preimages/services/forms/auth_form.go
@@ -0,0 +1,88 @@
+// Copyright 2014 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forms
+
+import (
+ "net/http"
+
+ "forgejo.org/modules/web/middleware"
+ "forgejo.org/services/context"
+
+ "code.forgejo.org/go-chi/binding"
+)
+
+// AuthenticationForm form for authentication
+type AuthenticationForm struct {
+ ID int64
+ Type int `binding:"Range(2,7)"`
+ Name string `binding:"Required;MaxSize(30)"`
+ Host string
+ Port int
+ BindDN string
+ BindPassword string
+ UserBase string
+ UserDN string
+ AttributeUsername string
+ AttributeName string
+ AttributeSurname string
+ DefaultDomainName string
+ AttributeMail string
+ AttributeSSHPublicKey string
+ AttributeAvatar string
+ AttributesInBind bool
+ UsePagedSearch bool
+ SearchPageSize int
+ Filter string
+ AdminFilter string
+ GroupsEnabled bool
+ GroupDN string
+ GroupFilter string
+ GroupMemberUID string
+ UserUID string
+ RestrictedFilter string
+ AllowDeactivateAll bool
+ IsActive bool
+ IsSyncEnabled bool
+ SMTPAuth string
+ SMTPHost string
+ SMTPPort int
+ AllowedDomains string
+ SecurityProtocol int `binding:"Range(0,2)"`
+ TLS bool
+ SkipVerify bool
+ HeloHostname string
+ DisableHelo bool
+ ForceSMTPS bool
+ PAMServiceName string
+ PAMEmailDomain string
+ Oauth2Provider string
+ Oauth2Key string
+ Oauth2Secret string
+ OpenIDConnectAutoDiscoveryURL string
+ Oauth2UseCustomURL bool
+ Oauth2TokenURL string
+ Oauth2AuthURL string
+ Oauth2ProfileURL string
+ Oauth2EmailURL string
+ Oauth2IconURL string
+ Oauth2Tenant string
+ Oauth2Scopes string
+ Oauth2RequiredClaimName string
+ Oauth2RequiredClaimValue string
+ Oauth2GroupClaimName string
+ Oauth2AdminGroup string
+ Oauth2RestrictedGroup string
+ Oauth2GroupTeamMap string `binding:"ValidGroupTeamMap"`
+ Oauth2GroupTeamMapRemoval bool
+ Oauth2AttributeSSHPublicKey string
+ SkipLocalTwoFA bool
+ GroupTeamMap string `binding:"ValidGroupTeamMap"`
+ GroupTeamMapRemoval bool
+}
+
+// Validate validates fields
+func (f *AuthenticationForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
+ ctx := context.GetValidateContext(req)
+ return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/test-output.txt b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/test-output.txt
new file mode 100644
index 00000000..d8a377f8
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/test-output.txt
@@ -0,0 +1,18 @@
+=== RUN TestRequiredClaimExactAnyOfAndNoMutation
+--- PASS: TestRequiredClaimExactAnyOfAndNoMutation (0.00s)
+=== RUN TestRequiredClaimConfigurationRefusalsAndLegacyIsolation
+--- PASS: TestRequiredClaimConfigurationRefusalsAndLegacyIsolation (0.00s)
+=== RUN TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable
+--- PASS: TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable (0.03s)
+=== RUN TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval
+--- PASS: TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval (0.00s)
+=== RUN TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse
+--- PASS: TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse (0.00s)
+=== RUN TestActualGothMergeRejectsOmissionProposal
+--- PASS: TestActualGothMergeRejectsOmissionProposal (0.00s)
+=== RUN TestActualGothCurrentGroupsOverrideStaleIDToken
+--- PASS: TestActualGothCurrentGroupsOverrideStaleIDToken (0.00s)
+=== RUN TestActualGothIdentityBindingAndLookupFailure
+--- PASS: TestActualGothIdentityBindingAndLookupFailure (0.00s)
+PASS
+ok command-line-arguments 1.058s
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/merge_upstream_test.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/merge_upstream_test.go
new file mode 100644
index 00000000..fede37ad
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/merge_upstream_test.go
@@ -0,0 +1,86 @@
+package oauth2
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func providerFor(body string, guarded bool) *Provider {
+ client := mockUserInfo(body, 200)
+ if guarded {
+ var err error
+ client, err = RequireCurrentUserInfoClaim(client, "https://issuer.example/userinfo", "groups")
+ if err != nil {
+ panic(err)
+ }
+ }
+ return &Provider{HTTPClient: client, ClientKey: "forgejo", OpenIDConfig: &OpenIDConfig{UserInfoEndpoint: "https://issuer.example/userinfo", Issuer: "https://issuer.example"}}
+}
+
+func TestActualGothMergeRejectsOmissionProposal(t *testing.T) {
+ // Retain the failed earlier proposal: omitted negative does NOT clear a
+ // previously issued positive. This is why no KeyCape omission patch ships.
+ p := providerFor(`{"sub":"alice","groups":[]}`, false)
+ claims := map[string]any{"sub": "alice", "forgejo_access": "allowed"}
+ if err := p.getUserInfo("synthetic", claims); err != nil {
+ t.Fatal(err)
+ }
+ if claims["forgejo_access"] != "allowed" {
+ t.Fatal("upstream merge behavior changed")
+ }
+ p = providerFor(`{"sub":"alice","groups":[],"forgejo_access":"denied"}`, false)
+ if err := p.getUserInfo("synthetic", claims); err != nil {
+ t.Fatal(err)
+ }
+ if claims["forgejo_access"] != "denied" {
+ t.Fatal("explicit negative did not replace positive")
+ }
+}
+
+func TestActualGothCurrentGroupsOverrideStaleIDToken(t *testing.T) {
+ for _, current := range [][]string{{}, {"unrelated"}, {"forge-admins"}, {"forge-coulomb-owners"}, {"forge-coulomb-readers"}, {"forge-admins", "forge-coulomb-readers"}} {
+ body, _ := json.Marshal(map[string]any{"sub": "alice", "groups": current})
+ p := providerFor(string(body), true)
+ claims := map[string]any{"sub": "alice", "groups": []string{"forge-admins"}, "preserved": "value"}
+ if err := p.getUserInfo("synthetic", claims); err != nil {
+ t.Fatal(err)
+ }
+ want := len(current) > 0 && current[0] != "unrelated"
+ if MatchRequiredClaimValues(claims, "groups", "", caseValues) != want {
+ t.Fatal("current group gate result differs")
+ }
+ if claims["preserved"] != "value" {
+ t.Fatal("unrelated ID claim lost")
+ }
+ }
+ // Removal and re-add are current lookups, independent of old ID positives.
+ for _, body := range []string{`{"sub":"alice"}`, `{"sub":"alice","groups":null}`, `{"sub":"alice","groups":"forge-admins"}`, `{"sub":"alice","groups":["forge-admins",false]}`} {
+ p := providerFor(body, true)
+ claims := map[string]any{"sub": "alice", "groups": []string{"forge-admins"}}
+ if p.getUserInfo("synthetic", claims) == nil {
+ t.Fatal("missing/malformed current data allowed ID fallback")
+ }
+ }
+}
+
+func TestActualGothIdentityBindingAndLookupFailure(t *testing.T) {
+ p := providerFor(`{"sub":"other-user","groups":["forge-admins"]}`, true)
+ if p.getUserInfo("synthetic", map[string]any{"sub": "alice"}) == nil {
+ t.Fatal("subject mismatch accepted")
+ }
+ p = providerFor(`{"groups":["forge-admins"]}`, true)
+ if p.getUserInfo("synthetic", map[string]any{"sub": "alice"}) == nil {
+ t.Fatal("missing subject accepted")
+ }
+ p = providerFor(`{"sub":"alice","groups":["forge-admins"]}`, true)
+ for _, claims := range []map[string]any{
+ {"aud": "other-client", "iss": "https://issuer.example", "exp": float64(time.Now().Add(time.Hour).Unix())},
+ {"aud": "forgejo", "iss": "https://other-issuer.example", "exp": float64(time.Now().Add(time.Hour).Unix())},
+ {"aud": "forgejo", "iss": "https://issuer.example", "exp": float64(time.Now().Add(-time.Hour).Unix())},
+ } {
+ if _, err := p.validateClaims(claims); err == nil {
+ t.Fatal("foreign/expired client claims accepted")
+ }
+ }
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/render_candidate.py b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/render_candidate.py
new file mode 100644
index 00000000..5b287c73
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/render_candidate.py
@@ -0,0 +1,54 @@
+"""Render the real chart in temporary storage; never emit deployable values.
+
+The deployed stock image remains incompatible. This is a source/flag compatibility
+control, not an image build, workload change or accepted first-case projection.
+"""
+import sys
+sys.dont_write_bytecode = True
+import importlib.util
+import json
+from pathlib import Path
+import re
+import shlex
+import subprocess
+import tempfile
+import yaml
+
+ROOT=Path(__file__).resolve().parents[1]
+packet=ROOT.parent/'apps-chart-projection-r1'
+spec=importlib.util.spec_from_file_location('old_projection',packet/'tools/forgejo_chart_projection.py')
+p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
+base=yaml.safe_load((packet/'preimages/helm/forgejo-values.yaml').read_bytes())
+values=p.chart_projection(base,('synthetic-control',))
+source=values['gitea']['oauth'][-1]
+source.pop('requiredClaimValue')
+source['requiredClaimValues']=','.join(p.CASE_GROUPS)
+assert values['image']==base['image'] # Never pretend that an image was selected.
+with tempfile.TemporaryDirectory(prefix='forgejo-new-flag-render-') as td:
+ t=Path(td);(t/'home').mkdir();f=t/'render-control.yaml';f.write_text(yaml.safe_dump(values))
+ env={'PATH':'/usr/bin:/bin','HOME':str(t/'home'),'HELM_CONFIG_HOME':str(t/'config'),
+ 'HELM_CACHE_HOME':str(t/'cache'),'HELM_DATA_HOME':str(t/'data'),'HELM_PLUGINS':str(t/'plugins')}
+ r=subprocess.run(['/home/worsch/.local/bin/helm','template','forgejo',str(packet/'upstream/gitea-12.5.0.tgz'),
+ '--namespace','forgejo','-f',str(f),'-f',str(packet/'preimages/helm/forgejo-registry-values.yaml')],
+ cwd=td,env=env,capture_output=True,text=True,check=True)
+ docs=list(yaml.safe_load_all(r.stdout))
+ script=next(x['stringData']['configure_gitea.sh'] for x in docs if x and x['kind']=='Secret' and 'configure_gitea.sh' in x.get('stringData',{}))
+ argv=shlex.split(next(x.strip() for x in script.splitlines() if x.strip().startswith('gitea admin auth add-oauth')))
+ flags={a[2:] for a in argv if a.startswith('--')}
+ assert argv[argv.index('--required-claim-values')+1]==','.join(p.CASE_GROUPS)
+ assert '--required-claim-value' not in argv
+ owner_cli=(ROOT/'postimages/cmd/admin_auth_oauth.go').read_text()
+ known=set(re.findall(r'Name:\s*"([a-z0-9-]+)"',owner_cli))
+ assert not flags-known
+ assert 'RequiredClaimValues: c.StringSlice("required-claim-values")' in owner_cli
+ dep=next(x for x in docs if x and x['kind']=='Deployment')
+ init=next(c for c in dep['spec']['template']['spec']['initContainers'] if c['name']=='configure-gitea')
+ envmap={e['name']:e for e in init['env']}
+ assert envmap['GITEA_OAUTH_SECRET_0']['valueFrom']['secretKeyRef']=={'name':'forgejo-netkingdom-oidc-client','key':'CLIENT_SECRET'}
+receipt={'successful':True,'chart':'12.5.0','candidate_cli_flags_match':True,
+ 'group_values_exact':list(p.CASE_GROUPS),'existing_secret_reference_preserved':True,
+ 'current_stock_image_supports_feature':False,'accepted_image_selected':False,
+ 'deployable_values_emitted':False,'first_case_still_blocked':True,
+ 'private_helper_executed':False,'production_calls':False}
+(ROOT/'chart-validation.json').write_text(json.dumps(receipt,indent=2)+'\n')
+print(json.dumps(receipt,sort_keys=True))
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/run_review.py b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/run_review.py
new file mode 100644
index 00000000..ca03393b
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/tests/run_review.py
@@ -0,0 +1,71 @@
+"""Compile the actual new source plus byte-extracted pinned Goth methods offline.
+
+Only HTTP-client/provider dependency wiring is a local fixture. Merge, subject,
+audience, issuer, expiry and HTTP decoding method bodies are upstream bytes.
+No socket/native target, dependency download or ambient configuration is used.
+"""
+from pathlib import Path
+import hashlib
+import json
+import shutil
+import subprocess
+import tempfile
+
+ROOT=Path(__file__).resolve().parents[1]
+POST=ROOT/'postimages'
+upstream=ROOT/'evidence/goth-v1.80.0-openid-connect.go'
+EXPECTED='274cfeb3bfcb5a09334fb7b7aa29b9806b41ca56f4d61b983bd2d98599a2fb0c'
+body=upstream.read_text()
+assert hashlib.sha256(upstream.read_bytes()).hexdigest()==EXPECTED
+
+def extract(signature):
+ start=body.index(signature)
+ end=body.index('\n}',start)+2
+ return body[start:end]+'\n'
+
+methods={name:extract(sig) for name,sig in {
+ 'merge':'func (p *Provider) getUserInfo(',
+ 'http':'func (p *Provider) fetchUserInfo(',
+ 'claim':'func getClaimValue(',
+ 'claim_list':'func getClaimValues(',
+ 'json':'func unMarshal(',
+ 'binding':'func (p *Provider) validateClaims(',
+}.items()}
+fixture='''package oauth2
+import("bytes";"encoding/json";"errors";"fmt";"io/ioutil";"net/http";"time")
+const subjectClaim="sub"
+const audienceClaim="aud"
+const issuerClaim="iss"
+const expiryClaim="exp"
+const clockSkew=10*time.Second
+type OpenIDConfig struct{UserInfoEndpoint,Issuer string}
+type Provider struct{HTTPClient *http.Client;OpenIDConfig *OpenIDConfig;SkipUserInfoRequest bool;ClientKey string}
+func(p *Provider)Client()*http.Client{return p.HTTPClient}
+'''+ '\n'.join(methods.values())
+
+with tempfile.TemporaryDirectory(prefix='forgejo-claim-review-') as td:
+ tmp=Path(td)
+ for name in ['required_claim_values.go','required_claim_values_test.go']:
+ shutil.copyfile(POST/'services/auth/source/oauth2'/name,tmp/name)
+ shutil.copyfile(ROOT/'tests/merge_upstream_test.go',tmp/'merge_upstream_test.go')
+ (tmp/'goth_extracted_test.go').write_text(fixture)
+ (tmp/'home').mkdir()
+ env={'PATH':'/usr/bin:/bin','HOME':str(tmp/'home'),'GO111MODULE':'off',
+ 'GOPROXY':'off','GOSUMDB':'off','GOTOOLCHAIN':'local',
+ 'GOCACHE':'/tmp/forgejo-required-claim-values-r1-go-cache',
+ 'GOPATH':str(tmp/'gopath')}
+ result=subprocess.run(['/usr/bin/go','test','-v','-race',
+ 'required_claim_values.go','required_claim_values_test.go',
+ 'goth_extracted_test.go','merge_upstream_test.go'],cwd=tmp,env=env,capture_output=True,text=True)
+ (ROOT/'test-output.txt').write_text(result.stdout+result.stderr)
+ print(result.stdout+result.stderr)
+ assert result.returncode==0
+receipt={'successful':True,'test_functions':8,'race_detector':True,
+ 'actual_source_compiled':['required_claim_values.go','required_claim_values_test.go'],
+ 'upstream_extracted_method_hashes':{n:hashlib.sha256(v.encode()).hexdigest() for n,v in methods.items()},
+ 'goth_version':'v1.80.0','production_calls':False,'network_sockets':False,
+ 'dependency_downloads':False,'ambient_environment':False,
+ 'full_forgejo_build':False,'deployed_image_unchanged':True,
+ 'keycape_derived_claim_implemented':False,'architecture_approval':False}
+(ROOT/'validation.json').write_text(json.dumps(receipt,indent=2)+'\n')
+print(json.dumps(receipt,sort_keys=True))
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/validation.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/validation.json
new file mode 100644
index 00000000..277de6ad
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-r1/validation.json
@@ -0,0 +1,26 @@
+{
+ "successful": true,
+ "test_functions": 8,
+ "race_detector": true,
+ "actual_source_compiled": [
+ "required_claim_values.go",
+ "required_claim_values_test.go"
+ ],
+ "upstream_extracted_method_hashes": {
+ "merge": "edbb20746dc5539714ff1818083ef33315a59a09a7c5234d3c281a33bc7d6210",
+ "http": "683bd23a72b214660dce44435a475c65fad726f979dc7e7dc311ceb927d48c68",
+ "claim": "ff1020491f34c829af45e0313ff58225468c5a7a01ea2ce4387b0c18f0508570",
+ "claim_list": "3329bc53eba553ccd8c76f7e44338baed9a0b3a5d77e807c1cc77a25bc51eb22",
+ "json": "131c42eef4fc0cd4c7c5f36c44c38eafa059f5ff2d06df9e721d7307a789ea75",
+ "binding": "f2388ac16ba26ccd9898487edd837f70b53dc431d479d3edacad569f4faf008a"
+ },
+ "goth_version": "v1.80.0",
+ "production_calls": false,
+ "network_sockets": false,
+ "dependency_downloads": false,
+ "ambient_environment": false,
+ "full_forgejo_build": false,
+ "deployed_image_unchanged": true,
+ "keycape_derived_claim_implemented": false,
+ "architecture_approval": false
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-offline.py b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-offline.py
new file mode 100644
index 00000000..dc9538a6
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-offline.py
@@ -0,0 +1,12 @@
+import json,subprocess,pathlib,time
+r=pathlib.Path('/tmp/forgejo-independent-r1')
+env=json.loads((r/'download-env.json').read_text());env.update(GOPROXY='off',GOSUMDB='off',CGO_ENABLED='1')
+cmd=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(r),str(r),'--chdir',str(r/'forgejo'),'--','env','-i']+[k+'='+v for k,v in env.items()]
+commands=[('whole-backend-build',['go','build','-p','4','-tags','sqlite sqlite_unlock_notify','-o',str(r/'forgejo-review-binary'),'.']),('actual-package-tests',['go','test','-p','4','-tags','sqlite sqlite_unlock_notify','./services/auth/source/oauth2','-run','TestRequiredClaim|TestCurrentUserInfo','-count=1','-v'])]
+results=[]
+for name,args in commands:
+ start=time.time()
+ with (r/(name+'.log')).open('w') as out:result=subprocess.run(cmd+args,stdout=out,stderr=subprocess.STDOUT)
+ results.append({'name':name,'argv':args,'returncode':result.returncode,'seconds':round(time.time()-start,2),'network':'bwrap --unshare-net; GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local'})
+ print(name,result.returncode,(r/(name+'.log')).read_text()[-4000:],flush=True)
+(r/'build-results.json').write_text(json.dumps(results,indent=2))
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-results.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-results.json
new file mode 100644
index 00000000..19589176
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/build-results.json
@@ -0,0 +1,38 @@
+[
+ {
+ "name": "whole-backend-build",
+ "argv": [
+ "go",
+ "build",
+ "-p",
+ "4",
+ "-tags",
+ "sqlite sqlite_unlock_notify",
+ "-o",
+ "/tmp/forgejo-independent-r1/forgejo-review-binary",
+ "."
+ ],
+ "returncode": 0,
+ "seconds": 304.79,
+ "network": "bwrap --unshare-net; GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local"
+ },
+ {
+ "name": "actual-package-tests",
+ "argv": [
+ "go",
+ "test",
+ "-p",
+ "4",
+ "-tags",
+ "sqlite sqlite_unlock_notify",
+ "./services/auth/source/oauth2",
+ "-run",
+ "TestRequiredClaim|TestCurrentUserInfo",
+ "-count=1",
+ "-v"
+ ],
+ "returncode": 0,
+ "seconds": 37.39,
+ "network": "bwrap --unshare-net; GOPROXY=off GOSUMDB=off GOTOOLCHAIN=local"
+ }
+]
\ No newline at end of file
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-deps.py b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-deps.py
new file mode 100644
index 00000000..23b88544
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-deps.py
@@ -0,0 +1,8 @@
+import subprocess,pathlib,json
+r=pathlib.Path('/tmp/forgejo-independent-r1')
+env={'PATH':str(r/'go/bin')+':/usr/bin:/bin','HOME':str(r/'home'),'GOTOOLCHAIN':'local','GOPROXY':'https://proxy.golang.org','GOSUMDB':'sum.golang.org','GOPATH':str(r/'gopath'),'GOMODCACHE':str(r/'gomodcache'),'GOCACHE':str(r/'gocache'),'GOVCS':'*:off','GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0','GOMAXPROCS':'4'}
+(r/'download-env.json').write_text(json.dumps(env,indent=2))
+with (r/'download.log').open('w') as out:
+ result=subprocess.run([str(r/'go/bin/go'),'mod','download'],cwd=r/'forgejo',env=env,stdout=out,stderr=subprocess.STDOUT)
+print('download result',result.returncode,flush=True)
+print((r/'download.log').read_text()[-6000:])
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-env.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-env.json
new file mode 100644
index 00000000..dadfef0e
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/download-env.json
@@ -0,0 +1,15 @@
+{
+ "PATH": "/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin",
+ "HOME": "/tmp/forgejo-independent-r1/home",
+ "GOTOOLCHAIN": "local",
+ "GOPROXY": "https://proxy.golang.org",
+ "GOSUMDB": "sum.golang.org",
+ "GOPATH": "/tmp/forgejo-independent-r1/gopath",
+ "GOMODCACHE": "/tmp/forgejo-independent-r1/gomodcache",
+ "GOCACHE": "/tmp/forgejo-independent-r1/gocache",
+ "GOVCS": "*:off",
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_CONFIG_GLOBAL": "/dev/null",
+ "GIT_TERMINAL_PROMPT": "0",
+ "GOMAXPROCS": "4"
+}
\ No newline at end of file
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/provenance.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/provenance.json
new file mode 100644
index 00000000..4cdaf099
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/provenance.json
@@ -0,0 +1,7 @@
+{
+ "forgejo_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c",
+ "forgejo_url": "https://codeberg.org/forgejo/forgejo/archive/v11.0.3.tar.gz",
+ "go_url": "https://go.dev/dl/go1.24.3.linux-amd64.tar.gz",
+ "go_official_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8",
+ "all_pre_postimages_exact": true
+}
\ No newline at end of file
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/review.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/review.json
new file mode 100644
index 00000000..55689444
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/review.json
@@ -0,0 +1,124 @@
+{
+ "schema": "custodian.forgejo-required-claim-values-independent-review.v1",
+ "reviewed_on": "2026-10-17",
+ "verdict": "accept exact source implementation for owner source integration review; no blocking source defect identified",
+ "candidate_patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257",
+ "reviewed_outputs": [
+ {
+ "path": "cmd/admin_auth_oauth.go",
+ "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f"
+ },
+ {
+ "path": "routers/web/admin/auths.go",
+ "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923"
+ },
+ {
+ "path": "routers/web/auth/oauth.go",
+ "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b"
+ },
+ {
+ "path": "services/auth/source/oauth2/providers_openid.go",
+ "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf"
+ },
+ {
+ "path": "services/auth/source/oauth2/required_claim_values.go",
+ "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda"
+ },
+ {
+ "path": "services/auth/source/oauth2/required_claim_values_test.go",
+ "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0"
+ },
+ {
+ "path": "services/auth/source/oauth2/source.go",
+ "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642"
+ }
+ ],
+ "all_33_frozen_packet_hashes_verified": true,
+ "frozen_original_unchanged": true,
+ "upstream": {
+ "forgejo_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c",
+ "forgejo_url": "https://codeberg.org/forgejo/forgejo/archive/v11.0.3.tar.gz",
+ "go_url": "https://go.dev/dl/go1.24.3.linux-amd64.tar.gz",
+ "go_official_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8",
+ "all_pre_postimages_exact": true,
+ "archive_pax_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237",
+ "go_mod_sum_unchanged": true
+ },
+ "current_review_inputs": {
+ "apps_head": "944b30ad63ab054cc24617e374e177d34cd572da",
+ "nk_packet_head": "befb0bc68cd25b625804fce559476eefd702521d",
+ "nk_current_head": "0241070b914d62b6ee483d61d587ee9a8e7152f0",
+ "current_NK_security_companion_matches_packet": true,
+ "Apps_AGENTS_INTENT_read": true
+ },
+ "compilation": {
+ "whole_actual_patched_backend_executable": "PASS",
+ "actual_source_not_extracted_method_fixture": true,
+ "argv": [
+ "go",
+ "build",
+ "-p",
+ "4",
+ "-tags",
+ "sqlite sqlite_unlock_notify",
+ "-o",
+ "/tmp/forgejo-independent-r1/forgejo-review-binary",
+ "."
+ ],
+ "binary_bytes": 132365872,
+ "binary_sha256": "b918c080d6ee00b3e5ee459ef1ce4e40c6268339c3b6e36a12d303b15f41fc79",
+ "toolchain": "official Go1.24.3 under /tmp, digest independently verified",
+ "elapsed_seconds": 304.79,
+ "frontend_release_container_image_build": false,
+ "notes": "Read upstream Makefile backend/generate/release shapes. Direct root Go build proves the full backend dependency graph and executable, without bindata/frontend assets or image provenance. No binary copied into canonical repository or published."
+ },
+ "tests": {
+ "packet_source_extracted_Goth_race_tests": 8,
+ "packet_tests_reproduced_network_disabled": true,
+ "actual_oauth2_package_candidate_tests": 5,
+ "independent_actual_package_test_functions": 3,
+ "independent_named_subtests": 14,
+ "independent_controls": [
+ "actual UrfaveCLI nil default retains legacy",
+ "actual old singular comma literal unchanged",
+ "actual new chart comma and repeated flags parse expected list",
+ "duplicate/empty/conflicting new CLI configuration refused",
+ "actual Source ToDB/FromDB preserves extension, synthetic secret, group/admin/scopes and other configuration",
+ "legacy Source remains nil-list; malformed scalar persisted list refuses decoding",
+ "escaped duplicate JSON keys and duplicate subject refused",
+ "unrelated nested JSON preserved byte-for-byte",
+ "case lookalike never intersects allowset",
+ "null entry, invalid Unicode surrogate and over1024 group entries refused",
+ "body over1MiB and POST endpoint refused"
+ ],
+ "first_actual_cmd_attempt": "Harness failed before selected cmd test: GIT_CONFIG_GLOBAL=/dev/null prevented upstream TestMain git.Init writing test-only config. Retained failure log/results; reran under same network boundary with an initially absent /tmp empty-home reviewer.gitconfig. All selected cmd/oauth2 tests then passed. No candidate correction needed.",
+ "chart_render": "PASS chart12.5.0; exact three-group flag shape and CLIENT_SECRET reference preserved"
+ },
+ "isolation": {
+ "public_dependencies": "allowlisted env; empty HOME; GOPROXY=https://proxy.golang.org; GOSUMDB=sum.golang.org; GOVCS=*:off; GOTOOLCHAIN=local; no automatic toolchain download",
+ "decisive_build_tests": "bwrap --unshare-net; no host home/config; GOPROXY=off GOSUMDB=off; fixed Go1.24.3; fixture race tests use existing Go1.22.2; in-memory HTTP transport; local SQLite setup only for upstream cmd TestMain",
+ "private_Git_SSH_config_credentials_native_model_image_publication_calls": false
+ },
+ "behavior_review": {
+ "new_gate": "Exact intersection of current incoming group array with configured original three-group allowset; no claim mutation or issuer forgejo_access shortcut. Missing/null/scalar/mixed/malformed/duplicate top-level JSON refuses; empty current array overwrites stale ID-token groups then denies. Duplicate configured literals refuse; duplicate incoming array members do not add authority.",
+ "legacy": "Nil-list config uses unchanged singular callback, including literal commas; regular providers and local sign-in not rewired.",
+ "update_UI": "CLI replaces list only if flag explicitly set; omission preserves guarded list. Empty/new conflicting fields refuse. UI POST on guarded source returns error before reconstructed source/config writes; it cannot erase list. No new clear/disable/recovery capability added.",
+ "Goth": "Guard observes successful current HTTPS UserInfo before Goth merges; original bytes retained. Retained actual upstream methods prove stale omission issue and subject/audience/issuer/expiry failures. All normal identity validation remains upstream.",
+ "architectural_limit": "Existing/new consumer gate itself is still an authorization enforcement architecture question under current NK SECURITY-COMPANION3.1-3.2 and Apps INTENT. Technical passing tests are not estate architecture acceptance. No derived KeyCape claim/model/config change or single-PDP exception is admitted."
+ },
+ "inverse_and_release_gates": [
+ "No accepted replacement image, reproducible release/frontend/container build, registry publication or deployed compatibility established; stock11.0.3 cannot accept new flag.",
+ "Helm chart omission leaves persisted OAuth source; old binary ignores unknown RequiredClaimValues field and presence-only singular config weakens gate. Downgrade before authenticated application-source inverse is unsafe.",
+ "Existing source identity/config/secret preservation, exact readback/CAS/writer exclusion, durable authenticated journal, current operation and inverse sessions remain unimplemented owner capability contracts.",
+ "Linked users or external logins cause existing DeleteSource refusal; never delete/detach users or preexisting/shared sources to force inverse. Retain extended image and reconcile.",
+ "Actual runtime chart CLIENT_SECRET expands into --secret argv; reference-only source does not prove process/audit/crash confidentiality. Owner admission remains required.",
+ "Real browser/consumer probe, source persistence and inverse operation evidence, architecture acceptance, owner formal assent, exact type/case/descriptor re-freeze and image admission remain separate."
+ ],
+ "formal_owner_assent": false,
+ "architecture_acknowledgment": false,
+ "image_admission": false,
+ "case_or_type_refreeze": false,
+ "first_case_still_blocked": true,
+ "correction_patch": "none; no blocking implementation flaw found",
+ "T37_complete": false
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results-attempt1.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results-attempt1.json
new file mode 100644
index 00000000..6e832765
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results-attempt1.json
@@ -0,0 +1,19 @@
+{
+ "argv": [
+ "go",
+ "test",
+ "-p",
+ "4",
+ "-tags",
+ "sqlite sqlite_unlock_notify",
+ "./cmd",
+ "./services/auth/source/oauth2",
+ "-run",
+ "TestReviewer",
+ "-count=1",
+ "-v"
+ ],
+ "returncode": 1,
+ "seconds": 21.66,
+ "network": "disabled by bwrap"
+}
\ No newline at end of file
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results.json
new file mode 100644
index 00000000..d3475b21
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-test-results.json
@@ -0,0 +1,19 @@
+{
+ "argv": [
+ "go",
+ "test",
+ "-p",
+ "4",
+ "-tags",
+ "sqlite sqlite_unlock_notify",
+ "./cmd",
+ "./services/auth/source/oauth2",
+ "-run",
+ "TestReviewer",
+ "-count=1",
+ "-v"
+ ],
+ "returncode": 0,
+ "seconds": 9.86,
+ "network": "disabled by bwrap"
+}
\ No newline at end of file
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-tests-offline.py b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-tests-offline.py
new file mode 100644
index 00000000..85f4fea7
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer-tests-offline.py
@@ -0,0 +1,8 @@
+import json,subprocess,pathlib,time
+r=pathlib.Path('/tmp/forgejo-independent-r1');env=json.loads((r/'download-env.json').read_text());env.update(GOPROXY='off',GOSUMDB='off',CGO_ENABLED='1',GIT_CONFIG_GLOBAL=str(r/'home/reviewer.gitconfig'))
+base=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(r),str(r),'--chdir',str(r/'forgejo'),'--','env','-i']+[k+'='+v for k,v in env.items()]
+args=['go','test','-p','4','-tags','sqlite sqlite_unlock_notify','./cmd','./services/auth/source/oauth2','-run','TestReviewer','-count=1','-v']
+start=time.time()
+with (r/'reviewer-actual-package-tests.log').open('w') as out:result=subprocess.run(base+args,stdout=out,stderr=subprocess.STDOUT)
+(r/'reviewer-test-results.json').write_text(json.dumps({'argv':args,'returncode':result.returncode,'seconds':round(time.time()-start,2),'network':'disabled by bwrap'},indent=2))
+print('reviewer actual package tests',result.returncode,flush=True);print((r/'reviewer-actual-package-tests.log').read_text()[-8000:])
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_cmd_test.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_cmd_test.go
new file mode 100644
index 00000000..b3212a85
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_cmd_test.go
@@ -0,0 +1,30 @@
+package cmd
+
+import (
+ "reflect"
+ "testing"
+ "forgejo.org/services/auth/source/oauth2"
+ "github.com/urfave/cli/v2"
+)
+
+func TestReviewerActualCLIListAndLegacyParsing(t *testing.T) {
+ tests:=[]struct {name string;args []string;want []string;legacy string;invalid bool}{
+ {name:"unset keeps nil",args:[]string{"--provider","openidConnect"}},
+ {name:"old comma literal unchanged",args:[]string{"--required-claim-name","groups","--required-claim-value","forge-admins,forge-coulomb-owners"},legacy:"forge-admins,forge-coulomb-owners"},
+ {name:"chart comma list",args:[]string{"--provider","openidConnect","--required-claim-name","groups","--required-claim-values","forge-admins,forge-coulomb-owners,forge-coulomb-readers"},want:[]string{"forge-admins","forge-coulomb-owners","forge-coulomb-readers"}},
+ {name:"repeated list",args:[]string{"--required-claim-name","groups","--required-claim-values","forge-admins","--required-claim-values","forge-coulomb-readers"},want:[]string{"forge-admins","forge-coulomb-readers"}},
+ {name:"duplicate flag list refused",args:[]string{"--required-claim-name","groups","--required-claim-values","forge-admins,forge-admins"},want:[]string{"forge-admins","forge-admins"},invalid:true},
+ {name:"explicit empty refused",args:[]string{"--required-claim-name","groups","--required-claim-values",""},want:[]string{""},invalid:true},
+ {name:"conflicting singular refused",args:[]string{"--required-claim-name","groups","--required-claim-value","old","--required-claim-values","forge-admins"},legacy:"old",want:[]string{"forge-admins"},invalid:true},
+ }
+ for _,tt:=range tests {t.Run(tt.name,func(t *testing.T){
+ app:=cli.NewApp();app.Flags=oauthCLIFlags
+ app.Action=func(ctx *cli.Context)error {source:=parseOAuth2Config(ctx)
+ if !reflect.DeepEqual(source.RequiredClaimValues,tt.want)||source.RequiredClaimValue!=tt.legacy {t.Fatalf("CLI parse mismatch: list=%q legacy=%q",source.RequiredClaimValues,source.RequiredClaimValue)}
+ err:=oauth2.ValidateRequiredClaimValues(source.RequiredClaimName,source.RequiredClaimValue,source.RequiredClaimValues)
+ if (err!=nil)!=tt.invalid {t.Fatalf("config validation mismatch: %v",err)}
+ return nil
+ }
+ if err:=app.Run(append([]string{"review"},tt.args...));err!=nil {t.Fatal(err)}
+ })}
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_oauth2_test.go b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_oauth2_test.go
new file mode 100644
index 00000000..4d09a406
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/reviewer_oauth2_test.go
@@ -0,0 +1,55 @@
+package oauth2
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestReviewerSourcePersistenceAndLegacy(t *testing.T) {
+ original:=&Source{Provider:"openidConnect", ClientID:"forgejo", ClientSecret:"synthetic-private-value", RequiredClaimName:"groups", RequiredClaimValues:[]string{"forge-admins","forge-coulomb-owners","forge-coulomb-readers"}, AdminGroup:"forge-admins", GroupClaimName:"groups",Scopes:[]string{"openid","groups"}}
+ raw,err:=original.ToDB(); if err!=nil {t.Fatal(err)}
+ var decoded Source; if err=decoded.FromDB(raw);err!=nil {t.Fatal(err)}
+ if !reflect.DeepEqual(original,&decoded) {t.Fatal("persisted extension or unrelated source config changed")}
+ var legacy Source
+ if err=legacy.FromDB([]byte(`{"Provider":"openidConnect","RequiredClaimName":"groups","RequiredClaimValue":"legacy,literal"}`));err!=nil {t.Fatal(err)}
+ if legacy.RequiredClaimValues!=nil || legacy.RequiredClaimValue!="legacy,literal" {t.Fatal("legacy semantics changed")}
+ if err=ValidateRequiredClaimValues(legacy.RequiredClaimName,legacy.RequiredClaimValue,legacy.RequiredClaimValues);err!=nil {t.Fatal(err)}
+ if err=decoded.FromDB([]byte(`{"RequiredClaimValues":"forge-admins"}`));err==nil {t.Fatal("scalar persisted list accepted")}
+}
+
+func TestReviewerGuardBoundaryControls(t *testing.T) {
+ endpoint:="https://issuer.example/userinfo"
+ cases:=[]struct {name,body string; allowed bool}{
+ {"escaped duplicate key",`{"groups":[],"gro\u0075ps":["forge-admins"]}`,false},
+ {"duplicate subject",`{"sub":"alice","sub":"bob","groups":["forge-admins"]}`,false},
+ {"unknown nested JSON allowed",`{"sub":"alice","unrelated":{"a":1},"groups":["forge-coulomb-readers"]}`,true},
+ {"exact case","{\"groups\":[\"Forge-admins\"]}",true},
+ {"null entry",`{"groups":["forge-admins",null]}`,false},
+ {"unicode surrogate",`{"groups":["forge-admins","\ud800"]}`,false},
+ {"too many groups",`{"groups":[`+strings.TrimSuffix(strings.Repeat(`"forge-admins",`,1025),",")+`]}`,false},
+ }
+ for _,tt:=range cases {t.Run(tt.name,func(t *testing.T){
+ client,err:=RequireCurrentUserInfoClaim(mockUserInfo(tt.body,200),endpoint,"groups");if err!=nil {t.Fatal(err)}
+ response,err:=client.Get(endpoint)
+ if (err==nil)!=tt.allowed {t.Fatalf("allowed=%v err=%v",tt.allowed,err)}
+ if err==nil {b,_:=io.ReadAll(response.Body);response.Body.Close();if !bytes.Equal(b,[]byte(tt.body)){t.Fatal("response modified")}
+ var claims map[string]any;if json.Unmarshal(b,&claims)!=nil {t.Fatal("invalid test body")}
+ matched:=MatchRequiredClaimValues(claims,"groups","",caseValues)
+ if matched!=(tt.name=="unknown nested JSON allowed") {t.Fatal("consumer allowset mismatch")}
+ }
+ })}
+ // Upper bounds are enforced without truncation; an allowed value cannot rescue malformed data.
+ if MatchRequiredClaimValues(map[string]any{"groups":[]any{"forge-admins",true}},"groups","",caseValues) {t.Fatal("mixed malformed claim admitted")}
+ // Body limit must refuse before a trailing allowed group could be trusted.
+ client,_:=RequireCurrentUserInfoClaim(mockUserInfo(`{"padding":"`+strings.Repeat("a",maxUserInfoBytes)+`","groups":["forge-admins"]}`,200),endpoint,"groups")
+ if response,err:=client.Get(endpoint);err==nil {response.Body.Close();t.Fatal("oversized body admitted")}
+ // POST at the exact endpoint must not bypass method guard.
+ client,_=RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":["forge-admins"]}`,200),endpoint,"groups")
+ req,_:=http.NewRequest(http.MethodPost,endpoint,nil)
+ if response,err:=client.Do(req);err==nil {response.Body.Close();t.Fatal("unexpected method admitted")}
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/status.json b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/status.json
new file mode 100644
index 00000000..00b03848
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/forgejo-required-claim-values-review-r1/status.json
@@ -0,0 +1,35 @@
+{
+ "status": "complete",
+ "phase": "review frozen; all owned exec sessions completed; whole backend compile and selected tests passed",
+ "exec_session_id": null,
+ "driver": "/tmp/forgejo-independent-r1/build-offline.py",
+ "workspace": "/tmp/forgejo-independent-r1/forgejo",
+ "log": "/tmp/forgejo-independent-r1/whole-backend-build.log",
+ "log_progress": "Go build cache grew from 156MB to644MB without diagnostics; do not restart on observation timeout",
+ "next_driver": "/tmp/forgejo-independent-r1/reviewer-tests-offline.py",
+ "completed": [
+ "33 frozen packet hashes verified",
+ "all official archive preimages/postimages verified; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237",
+ "official Go1.24.3 digest verified",
+ "public dependency acquisition successful with fixed proxy+sumdb and VCS disabled; go.mod/go.sum unchanged",
+ "8 packet source/Goth fixture tests pass with race detector",
+ "chart12.5.0 render reproduced"
+ ],
+ "current_Apps_head": "944b30ad63ab054cc24617e374e177d34cd572da",
+ "current_NK_head": "0241070b914d62b6ee483d61d587ee9a8e7152f0",
+ "NK_companion_equals_frozen_packet": true,
+ "full_compile_claimed": true,
+ "formal_owner_assent": false,
+ "frozen_original_unchanged": true,
+ "timestamp_utc": "2026-10-17T21:14:53.967922+00:00",
+ "full_backend_compile_succeeded": true,
+ "frontend_container_image_build_claimed": false,
+ "reviewer_tests_exec_session_id": null,
+ "reviewer_tests_log": "/tmp/forgejo-independent-r1/reviewer-actual-package-tests.log",
+ "completed_exec_sessions": [
+ 58181,
+ 83343,
+ 97349,
+ 10513
+ ]
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-image-inspection.json b/docs/changes/netkingdom-target-2026-10-17/guard-image-inspection.json
new file mode 100644
index 00000000..e3f4fe80
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-image-inspection.json
@@ -0,0 +1,774 @@
+{
+ "schema": "statehub.guard-image-inspection.v1",
+ "status": "verified-selected-public-image-bytes",
+ "source_commit": "a25b62f75aab6165ee9872ee53ac90c679ec746a",
+ "tag": "main-a25b62f",
+ "image_digest": "sha256:226086b1acaec3fc77e30014a101c96de687e38c9eb52cd522e5e69efb1980a0",
+ "selected_source_hashes": {
+ "app/api/services/historical_projection.py": "sha256:e24e57c58f4aaa4ec9d9bf088009ac69006e53f75b9477ee53dc26c4e850b975",
+ "app/api/routers/workstreams.py": "sha256:9c9bf4376dd323b52109912bb3f615949e89ac02c8b88b4720f569f8565ad1d6",
+ "app/scripts/consistency_check.py": "sha256:eaee916f46a11f49d2621fc9386717d73b7fd517acbe4b695e7b551d67bfa8f7",
+ "app/api/services/forge_projection.py": "sha256:b980e62cbd85c3e7df42b01abf14354e04133374db94af6531dcd91629c3487d",
+ "app/migrations/versions/c4d5e6f7a8b9_inbox_feed_revision.py": "sha256:48f1730037be99b5623e878d7f20a06bec243ce27317fde81c82e16006d7b3ce",
+ "tmp/.ci-build-id": "sha256:ea57ead67d610c44a6237a7d96be66a8f9882f026de544b8efc69dbd7d57315d"
+ },
+ "observed_files": {
+ "tmp/.ci-build-id": "sha256:ea57ead67d610c44a6237a7d96be66a8f9882f026de544b8efc69dbd7d57315d",
+ "tmp/hub-core/hub_core/__init__.py": "sha256:098578bae76f7d97354b8f3dfe8ddc63b15806a46d1691d70ed12d9839d69aec",
+ "tmp/hub-core/hub_core/conformance/__init__.py": "sha256:f7ce0a651913f157e586afd6e953b483add0dd5257ee9f2406c47e8d46ee9587",
+ "tmp/hub-core/hub_core/conformance/harness.py": "sha256:ca870fbbec7b7f5db6521d615fd76dfcbb9e97cbdfb037ffa993851a663b3c40",
+ "tmp/hub-core/hub_core/contracts/__init__.py": "sha256:ffb7a6b79ce15ddd57943bfbc40799e078c6f57c6690018237baa3f3a755ac49",
+ "tmp/hub-core/hub_core/contracts/helixforge_hub_extension/__init__.py": "sha256:32c095a16f777127ec0d2cd5e8373889866b81e9955d01e5adaf9503f6864da4",
+ "tmp/hub-core/hub_core/contracts/helixforge_hub_extension/v0_1_0/__init__.py": "sha256:e176b86b24d79c11f78ad714878bc3fca41c7821654fa766278b697360a41201",
+ "tmp/hub-core/hub_core/contracts/repository_navigation/__init__.py": "sha256:64912fe6673b72c3c0d8a2da28673d58bdf6f4185a1376ffe43d3735350adb60",
+ "tmp/hub-core/hub_core/contracts/repository_navigation/v1_0_0/__init__.py": "sha256:574cf1fee8433e0b11d9475cfbcc3837730b55f6ea1faa4920cba106d959464f",
+ "tmp/hub-core/hub_core/contracts/workload_projection/__init__.py": "sha256:c01198d23571442c1c3a596c7cd16be17dc1a3efdcfe3e8f9725b7dba16fc103",
+ "tmp/hub-core/hub_core/contracts/workload_projection/v1_0_0/__init__.py": "sha256:a9ecfecbf44893d32268288c872cdede0440d2a1373d37a47f69ba8c6431de0d",
+ "tmp/hub-core/hub_core/database.py": "sha256:a7704e97509433e90655b0ea22d5fa626d75b8b6470015ef8b9e5997e7a7f5ea",
+ "tmp/hub-core/hub_core/events.py": "sha256:c7125a242ed638a3ede29129e4ad826cd188cf76239b59a8587a40d182bbd0e7",
+ "tmp/hub-core/hub_core/mcp/__init__.py": "sha256:f97b25193573e56886b66f91cb8c86ef4a39f2100d709869e279e17456959859",
+ "tmp/hub-core/hub_core/mcp/credentials.py": "sha256:f5433d29848bd5a399a5e31695e0d908748e0a0856d26b2d691b667c50c95c34",
+ "tmp/hub-core/hub_core/mcp/server.py": "sha256:fc6a6de0a29ad9b6da176302a7d24e4ba987f667b6a7fb001029f00cb0d0c582",
+ "tmp/hub-core/hub_core/message_identity.py": "sha256:0ab996ba50f236c1342ce72ca8ae6ff28102ef41216972a745f7260e0c93fb24",
+ "tmp/hub-core/hub_core/migrations/__init__.py": "sha256:ccf4820e270c97fd5d4ac02f0c96362a772b8f4d1bcccc3f5e0077b77f575d36",
+ "tmp/hub-core/hub_core/migrations/env.py": "sha256:282e6e1a9e4a3cfdc111887f04dc5c1645307b3943a421852abe1709ce712e57",
+ "tmp/hub-core/hub_core/migrations/roles.py": "sha256:91e85297d296b0c4b9c78b16ae67d3a4707232d8f32b2cac2b2e84f31fa80e89",
+ "tmp/hub-core/hub_core/migrations/versions/0001_core_schema.py": "sha256:c65f076a7b56fb93ee67869e04d922ea396eab72a4c2d2f0dabd706c918aa081",
+ "tmp/hub-core/hub_core/migrations/versions/0002_runtime_ports.py": "sha256:d3ce15463841b3c13eaafdd137a65d4cf2839c0b8e8fddcb2cb2f1adbceb9f00",
+ "tmp/hub-core/hub_core/migrations/versions/0003_repository_navigation.py": "sha256:fb2d2cb5ed10f1760ab789ced8d43c657c1bedc29ba666231a70a18636160902",
+ "tmp/hub-core/hub_core/migrations/versions/0004_workload_projection.py": "sha256:87ce668bfef5cf4aaf0cfd3b48d498f2370f68e06a6e66375e446af6fbbb9c96",
+ "tmp/hub-core/hub_core/migrations/versions/0005_message_identity_aliases.py": "sha256:ec05d2c3cb906b78cdec4d220a7ce09264c38904c792cdad3cd1082e7eace7c0",
+ "tmp/hub-core/hub_core/migrations/versions/0006_outcome_outbox.py": "sha256:05effdf6f258836d1cd223f9d9fc3447d264484021d3a51cfa42517453d613e1",
+ "tmp/hub-core/hub_core/migrations/versions/__init__.py": "sha256:569f6d7e5cc1c17b7b1eee3eec3b82774b745d01ee1dfdf851a5c6f5c337f0bb",
+ "tmp/hub-core/hub_core/models/__init__.py": "sha256:c0b6de4986614a163dbb2467d15882eadbd66800478a50de4d6404198d1f773a",
+ "tmp/hub-core/hub_core/models/agent_message.py": "sha256:23d16eeaffe780f326b08e6c441100e6ae7b4075bbb6421734c06156a87c4375",
+ "tmp/hub-core/hub_core/models/base.py": "sha256:85bb40bde6f4cf8e6c5700a3a016a711fab4adff77c5eb78f89827fe28b9537b",
+ "tmp/hub-core/hub_core/models/capability_catalog.py": "sha256:eebbb31f264821a61662d3c9e8c7eba74f46d7f68b946e35f8127343bf2ff9c4",
+ "tmp/hub-core/hub_core/models/capability_request.py": "sha256:70a867c82aaa061a4e1c671b023d213032464e776226280f99e2ca192e2a6eba",
+ "tmp/hub-core/hub_core/models/domain.py": "sha256:ddf45c37e3c37014ddd9148be01cf104658e1601abba2a8ba96f7680cdb581b3",
+ "tmp/hub-core/hub_core/models/managed_repo.py": "sha256:835c5d44581daf7d7dd95325e3f7fdf0add4d529b83779ed83b5e812dcb3aa61",
+ "tmp/hub-core/hub_core/models/message_identity_alias.py": "sha256:d07d59cd9c9351669e8319727d08bc0e9bb7e0ee36bf8a34c0c258f0b4ea61e4",
+ "tmp/hub-core/hub_core/models/progress_event.py": "sha256:ee750127db2f49c5b9b94773a408f7f965d834b02e6d962220f4bc762309161f",
+ "tmp/hub-core/hub_core/models/tpsc.py": "sha256:cb28cbe3512486adb73c0bce633b3c542a41a64e7ec673e86a5a25ab20d94afe",
+ "tmp/hub-core/hub_core/routers/__init__.py": "sha256:800d1c45f597fcb4970ca7a83acd2b36016006efbdf638e14a3482a954d9046d",
+ "tmp/hub-core/hub_core/routers/capabilities.py": "sha256:18a403ad6ac961bc490543b698e079336f60ef9902a6cd71ef9899076660d5c0",
+ "tmp/hub-core/hub_core/routers/domains.py": "sha256:d4c4061d2ab0d2d6390a10f4f815deb0b93b3979345aee965b666110ba32e6e0",
+ "tmp/hub-core/hub_core/routers/messages.py": "sha256:903bd4f8c8ca599b606cf2229315de21097360816fcda907159dac4f48cee512",
+ "tmp/hub-core/hub_core/routers/policy.py": "sha256:358271263295fdebb5bcb95c3a27bd3ea456964a77bc01c3f784fb7a8754aacf",
+ "tmp/hub-core/hub_core/routers/progress.py": "sha256:680cf4dea00a1734d7d70ef7880acc8fd39f045521baa47f415c17934779d18a",
+ "tmp/hub-core/hub_core/routers/repos.py": "sha256:1bca6c9b2b5bbe172f5b935b763ac1e399b1d403cdc2c463000ae79f843257a2",
+ "tmp/hub-core/hub_core/routers/tpsc.py": "sha256:706815b01be1c0320bf73cf9cff3e9415c1f27b8449ee9e6fea67b12d3042fd1",
+ "tmp/hub-core/hub_core/runtime/__init__.py": "sha256:c522e626373407bf9bcd727dac7bb812cbdc8c3ef5bb6509a74949e5b49ab4c2",
+ "tmp/hub-core/hub_core/runtime/app.py": "sha256:87b077498bbe321c73139e51f7ca689fdc7963a98c4d72a5a3bbcc564f47ee76",
+ "tmp/hub-core/hub_core/runtime/cli.py": "sha256:98ec00f2a12911245827f515877472ae1c37cea65a42454494450ffbe7380e57",
+ "tmp/hub-core/hub_core/runtime/compat.py": "sha256:9f1c4575bb406ae1174da1a005ec16f44ace63768617e11164b44b4051162d0d",
+ "tmp/hub-core/hub_core/runtime/compat_catalogs.py": "sha256:250f61ea39cced32c8cd932c4e770ae8be54ff1cf1e257ee2c82324bde0d7a26",
+ "tmp/hub-core/hub_core/runtime/config.py": "sha256:009b2fc5d1f8fc390d478b01444f610ada91e5fee30534936382be2529a843fc",
+ "tmp/hub-core/hub_core/runtime/inbox_projection.py": "sha256:84499238654310b86f7a97f2817163ff4c4da0dfce562c677294fa3dfef7f398",
+ "tmp/hub-core/hub_core/runtime/migration.py": "sha256:d12731fa581c48bf58deec19405ece977c89d08993b2a2ad047cb1fd17318ea8",
+ "tmp/hub-core/hub_core/runtime/models.py": "sha256:367ca4aa29f59f8e3f59eab22f12782146cf623fa7ac7c22346c794b85a88185",
+ "tmp/hub-core/hub_core/runtime/ports.py": "sha256:ab70a401deae73389761a0e88dc8ee843df00a671fae6919b1f7eed344cfb542",
+ "tmp/hub-core/hub_core/runtime/postgres_store.py": "sha256:9286e7845009b66f00069d67177733dabe372d79986c33ca9b43e00dfa5a2341",
+ "tmp/hub-core/hub_core/runtime/repo_manager_client.py": "sha256:8fa305277c5f3ddfdbe1daf4023ce6c3cde03b5c18c1292dd6aac3fe995aabe1",
+ "tmp/hub-core/hub_core/runtime/repository_navigation.py": "sha256:f3503596781f3e7f7d53dd28dd3aaf563905f1398575b40c8080e2b1b9978b1e",
+ "tmp/hub-core/hub_core/runtime/repository_navigation_routes.py": "sha256:eab78ab418a7b542dcdcab5da023bacc65fb50520853f8d179accb2105e373cf",
+ "tmp/hub-core/hub_core/runtime/store.py": "sha256:2fb78f14bab8df08807322d6f6416745856d69e6001381246abbac879891858d",
+ "tmp/hub-core/hub_core/runtime/tables.py": "sha256:a5ff72a9c54eedcc8bb81330e62ee72578a383a1d03a1acb598c72c96cdfbe9e",
+ "tmp/hub-core/hub_core/runtime/validation.py": "sha256:2add804d95d5d0469d6d8d605fbd1013885da972489a017260c6fddef7b80c79",
+ "tmp/hub-core/hub_core/runtime/workload_projection.py": "sha256:8500260404b0c566a62e51fef63ee320d4e3dafe5e2467f44db884296b9e3bf4",
+ "tmp/hub-core/hub_core/runtime/workload_projection_routes.py": "sha256:5c71189b98867f2f1f7612f47ee69c0ec3ddf836b9b1c7e134b98a2be45791de",
+ "tmp/hub-core/hub_core/schemas/__init__.py": "sha256:dfa2ec2d125f03a532637d2fbef3058ebe1f85c515ff42a930198e164b7812f7",
+ "tmp/hub-core/hub_core/schemas/agent_message.py": "sha256:d6a7f40dc9a3c61be761f4d2e2c08f726fb0a423ef73e273a7cd97c69ae6c6a3",
+ "tmp/hub-core/hub_core/schemas/capability.py": "sha256:949c55cb1f2694de4617ad53dc9cb7bc0e72addd7b9d0f104b93d783e4edc647",
+ "tmp/hub-core/hub_core/schemas/doi.py": "sha256:73d675aecabd5a62ae13184494a4b9db122ad24b3132e1d0e908eef802382f36",
+ "tmp/hub-core/hub_core/schemas/domain.py": "sha256:872a704fea959d2e4d0ff771770a97160457282218f4748ba4f830c489543aac",
+ "tmp/hub-core/hub_core/schemas/managed_repo.py": "sha256:a7ab3b1ab399da81324693dcd5d3feeccb211642b10f11cd3f03c7fe6ba751d4",
+ "tmp/hub-core/hub_core/schemas/policy.py": "sha256:bdaef1640783ac325a050cadf214e731d7c8e5e3a35dbb8f7562d5abbd209a4c",
+ "tmp/hub-core/hub_core/schemas/progress_event.py": "sha256:9ac7b7fd4881d424bfe59c5001554fd7302243e8489d5e777584d11ead1db328",
+ "tmp/hub-core/hub_core/schemas/tpsc.py": "sha256:5640d96a7fa862a468eeb0d6da002697b1f58e4cd675a4603b91e448d4db851e",
+ "tmp/hub-core/hub_core/security/__init__.py": "sha256:2754e25ec0f5725c33cb0b4dff1b7d10b364ebd828e609bb5b44eee3309d2d60",
+ "tmp/hub-core/hub_core/security/account_reader.py": "sha256:27019616b84cf030969a8e3c0850b5ae52f336008fd9daa4c26360566e74a39b",
+ "tmp/hub-core/hub_core/security/audit.py": "sha256:94cb85d71f065443c6423871b59d41e929af0441c95928273caf3e38dad8125c",
+ "tmp/hub-core/hub_core/security/boundary.py": "sha256:a1629ac717672276f61015c83f7af6e375bbd8c1234c712dd3d225da307ae0d6",
+ "tmp/hub-core/hub_core/security/browser.py": "sha256:fd474ee4b9611fe8dd2c60111402c2fce67d680a7da3519b5d4a495a6b559b7a",
+ "tmp/hub-core/hub_core/security/config.py": "sha256:7d604f6aeebde393a371ec3e0ab174934e10808c29fb370acba031486e9c02a3",
+ "tmp/hub-core/hub_core/security/context.py": "sha256:1181bbf12ba86bfd5046dab2f3a22a64ee3686be6dd52ab3e843303349b13a52",
+ "tmp/hub-core/hub_core/security/facts.py": "sha256:3de4db38ffd3f86f4918d76f811289cd501be3c28dcba16f5088aa2a2b7529ce",
+ "tmp/hub-core/hub_core/security/identity.py": "sha256:d7a0bab9d59a5f342fdedc9723d818a9d2537a59c6a9415455786af748f912e9",
+ "tmp/hub-core/hub_core/security/policy.py": "sha256:a2370e9ea5b04b1d5a429644d7f81edb1cbe8e15214c23b16cd6f949613eebd6",
+ "tmp/hub-core/hub_core/utils/__init__.py": "sha256:ce4ddb30ccc4fd2c4ee59e8a3c088a12165171af02aea957f17f72cd868e1899",
+ "tmp/hub-core/hub_core/utils/pagination.py": "sha256:366250b85052c7aabc2a2262521d34233124ba3a41026042792f8eda706ecb4f",
+ "tmp/hub-core/hub_core/utils/paths.py": "sha256:988a889be74f0626ca3c27b1d6677bd4a556e35de389c060d07ab64fa1c22fc8",
+ "tmp/hub-core/hub_core/utils/routing.py": "sha256:c94b44c5f1ab723e123503e20e0309f28abeeabea6d51beda79247d99852ad1e",
+ "tmp/hub-core/hub_core/utils/slugs.py": "sha256:eda7a7d18ff21a4d949223b75a2b615a949e615f461152a9cb9a2cf74b491e2d",
+ "app/.venv/lib/python3.12/site-packages/hub_core/__init__.py": "sha256:098578bae76f7d97354b8f3dfe8ddc63b15806a46d1691d70ed12d9839d69aec",
+ "app/.venv/lib/python3.12/site-packages/hub_core/conformance/__init__.py": "sha256:f7ce0a651913f157e586afd6e953b483add0dd5257ee9f2406c47e8d46ee9587",
+ "app/.venv/lib/python3.12/site-packages/hub_core/conformance/harness.py": "sha256:ca870fbbec7b7f5db6521d615fd76dfcbb9e97cbdfb037ffa993851a663b3c40",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/__init__.py": "sha256:ffb7a6b79ce15ddd57943bfbc40799e078c6f57c6690018237baa3f3a755ac49",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/helixforge_hub_extension/__init__.py": "sha256:32c095a16f777127ec0d2cd5e8373889866b81e9955d01e5adaf9503f6864da4",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/helixforge_hub_extension/v0_1_0/__init__.py": "sha256:e176b86b24d79c11f78ad714878bc3fca41c7821654fa766278b697360a41201",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/repository_navigation/__init__.py": "sha256:64912fe6673b72c3c0d8a2da28673d58bdf6f4185a1376ffe43d3735350adb60",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/repository_navigation/v1_0_0/__init__.py": "sha256:574cf1fee8433e0b11d9475cfbcc3837730b55f6ea1faa4920cba106d959464f",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/workload_projection/__init__.py": "sha256:c01198d23571442c1c3a596c7cd16be17dc1a3efdcfe3e8f9725b7dba16fc103",
+ "app/.venv/lib/python3.12/site-packages/hub_core/contracts/workload_projection/v1_0_0/__init__.py": "sha256:a9ecfecbf44893d32268288c872cdede0440d2a1373d37a47f69ba8c6431de0d",
+ "app/.venv/lib/python3.12/site-packages/hub_core/database.py": "sha256:a7704e97509433e90655b0ea22d5fa626d75b8b6470015ef8b9e5997e7a7f5ea",
+ "app/.venv/lib/python3.12/site-packages/hub_core/events.py": "sha256:c7125a242ed638a3ede29129e4ad826cd188cf76239b59a8587a40d182bbd0e7",
+ "app/.venv/lib/python3.12/site-packages/hub_core/mcp/__init__.py": "sha256:f97b25193573e56886b66f91cb8c86ef4a39f2100d709869e279e17456959859",
+ "app/.venv/lib/python3.12/site-packages/hub_core/mcp/credentials.py": "sha256:f5433d29848bd5a399a5e31695e0d908748e0a0856d26b2d691b667c50c95c34",
+ "app/.venv/lib/python3.12/site-packages/hub_core/mcp/server.py": "sha256:fc6a6de0a29ad9b6da176302a7d24e4ba987f667b6a7fb001029f00cb0d0c582",
+ "app/.venv/lib/python3.12/site-packages/hub_core/message_identity.py": "sha256:0ab996ba50f236c1342ce72ca8ae6ff28102ef41216972a745f7260e0c93fb24",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/__init__.py": "sha256:ccf4820e270c97fd5d4ac02f0c96362a772b8f4d1bcccc3f5e0077b77f575d36",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/env.py": "sha256:282e6e1a9e4a3cfdc111887f04dc5c1645307b3943a421852abe1709ce712e57",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/roles.py": "sha256:91e85297d296b0c4b9c78b16ae67d3a4707232d8f32b2cac2b2e84f31fa80e89",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0001_core_schema.py": "sha256:c65f076a7b56fb93ee67869e04d922ea396eab72a4c2d2f0dabd706c918aa081",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0002_runtime_ports.py": "sha256:d3ce15463841b3c13eaafdd137a65d4cf2839c0b8e8fddcb2cb2f1adbceb9f00",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0003_repository_navigation.py": "sha256:fb2d2cb5ed10f1760ab789ced8d43c657c1bedc29ba666231a70a18636160902",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0004_workload_projection.py": "sha256:87ce668bfef5cf4aaf0cfd3b48d498f2370f68e06a6e66375e446af6fbbb9c96",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0005_message_identity_aliases.py": "sha256:ec05d2c3cb906b78cdec4d220a7ce09264c38904c792cdad3cd1082e7eace7c0",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/0006_outcome_outbox.py": "sha256:05effdf6f258836d1cd223f9d9fc3447d264484021d3a51cfa42517453d613e1",
+ "app/.venv/lib/python3.12/site-packages/hub_core/migrations/versions/__init__.py": "sha256:569f6d7e5cc1c17b7b1eee3eec3b82774b745d01ee1dfdf851a5c6f5c337f0bb",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/__init__.py": "sha256:c0b6de4986614a163dbb2467d15882eadbd66800478a50de4d6404198d1f773a",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/agent_message.py": "sha256:23d16eeaffe780f326b08e6c441100e6ae7b4075bbb6421734c06156a87c4375",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/base.py": "sha256:85bb40bde6f4cf8e6c5700a3a016a711fab4adff77c5eb78f89827fe28b9537b",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/capability_catalog.py": "sha256:eebbb31f264821a61662d3c9e8c7eba74f46d7f68b946e35f8127343bf2ff9c4",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/capability_request.py": "sha256:70a867c82aaa061a4e1c671b023d213032464e776226280f99e2ca192e2a6eba",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/domain.py": "sha256:ddf45c37e3c37014ddd9148be01cf104658e1601abba2a8ba96f7680cdb581b3",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/managed_repo.py": "sha256:835c5d44581daf7d7dd95325e3f7fdf0add4d529b83779ed83b5e812dcb3aa61",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/message_identity_alias.py": "sha256:d07d59cd9c9351669e8319727d08bc0e9bb7e0ee36bf8a34c0c258f0b4ea61e4",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/progress_event.py": "sha256:ee750127db2f49c5b9b94773a408f7f965d834b02e6d962220f4bc762309161f",
+ "app/.venv/lib/python3.12/site-packages/hub_core/models/tpsc.py": "sha256:cb28cbe3512486adb73c0bce633b3c542a41a64e7ec673e86a5a25ab20d94afe",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/__init__.py": "sha256:800d1c45f597fcb4970ca7a83acd2b36016006efbdf638e14a3482a954d9046d",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/capabilities.py": "sha256:18a403ad6ac961bc490543b698e079336f60ef9902a6cd71ef9899076660d5c0",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/domains.py": "sha256:d4c4061d2ab0d2d6390a10f4f815deb0b93b3979345aee965b666110ba32e6e0",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/messages.py": "sha256:903bd4f8c8ca599b606cf2229315de21097360816fcda907159dac4f48cee512",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/policy.py": "sha256:358271263295fdebb5bcb95c3a27bd3ea456964a77bc01c3f784fb7a8754aacf",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/progress.py": "sha256:680cf4dea00a1734d7d70ef7880acc8fd39f045521baa47f415c17934779d18a",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/repos.py": "sha256:1bca6c9b2b5bbe172f5b935b763ac1e399b1d403cdc2c463000ae79f843257a2",
+ "app/.venv/lib/python3.12/site-packages/hub_core/routers/tpsc.py": "sha256:706815b01be1c0320bf73cf9cff3e9415c1f27b8449ee9e6fea67b12d3042fd1",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/__init__.py": "sha256:c522e626373407bf9bcd727dac7bb812cbdc8c3ef5bb6509a74949e5b49ab4c2",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/app.py": "sha256:87b077498bbe321c73139e51f7ca689fdc7963a98c4d72a5a3bbcc564f47ee76",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/cli.py": "sha256:98ec00f2a12911245827f515877472ae1c37cea65a42454494450ffbe7380e57",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/compat.py": "sha256:9f1c4575bb406ae1174da1a005ec16f44ace63768617e11164b44b4051162d0d",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/compat_catalogs.py": "sha256:250f61ea39cced32c8cd932c4e770ae8be54ff1cf1e257ee2c82324bde0d7a26",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/config.py": "sha256:009b2fc5d1f8fc390d478b01444f610ada91e5fee30534936382be2529a843fc",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/inbox_projection.py": "sha256:84499238654310b86f7a97f2817163ff4c4da0dfce562c677294fa3dfef7f398",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/migration.py": "sha256:d12731fa581c48bf58deec19405ece977c89d08993b2a2ad047cb1fd17318ea8",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/models.py": "sha256:367ca4aa29f59f8e3f59eab22f12782146cf623fa7ac7c22346c794b85a88185",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/ports.py": "sha256:ab70a401deae73389761a0e88dc8ee843df00a671fae6919b1f7eed344cfb542",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/postgres_store.py": "sha256:9286e7845009b66f00069d67177733dabe372d79986c33ca9b43e00dfa5a2341",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/repo_manager_client.py": "sha256:8fa305277c5f3ddfdbe1daf4023ce6c3cde03b5c18c1292dd6aac3fe995aabe1",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/repository_navigation.py": "sha256:f3503596781f3e7f7d53dd28dd3aaf563905f1398575b40c8080e2b1b9978b1e",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/repository_navigation_routes.py": "sha256:eab78ab418a7b542dcdcab5da023bacc65fb50520853f8d179accb2105e373cf",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/store.py": "sha256:2fb78f14bab8df08807322d6f6416745856d69e6001381246abbac879891858d",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/tables.py": "sha256:a5ff72a9c54eedcc8bb81330e62ee72578a383a1d03a1acb598c72c96cdfbe9e",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/validation.py": "sha256:2add804d95d5d0469d6d8d605fbd1013885da972489a017260c6fddef7b80c79",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/workload_projection.py": "sha256:8500260404b0c566a62e51fef63ee320d4e3dafe5e2467f44db884296b9e3bf4",
+ "app/.venv/lib/python3.12/site-packages/hub_core/runtime/workload_projection_routes.py": "sha256:5c71189b98867f2f1f7612f47ee69c0ec3ddf836b9b1c7e134b98a2be45791de",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/__init__.py": "sha256:dfa2ec2d125f03a532637d2fbef3058ebe1f85c515ff42a930198e164b7812f7",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/agent_message.py": "sha256:d6a7f40dc9a3c61be761f4d2e2c08f726fb0a423ef73e273a7cd97c69ae6c6a3",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/capability.py": "sha256:949c55cb1f2694de4617ad53dc9cb7bc0e72addd7b9d0f104b93d783e4edc647",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/doi.py": "sha256:73d675aecabd5a62ae13184494a4b9db122ad24b3132e1d0e908eef802382f36",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/domain.py": "sha256:872a704fea959d2e4d0ff771770a97160457282218f4748ba4f830c489543aac",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/managed_repo.py": "sha256:a7ab3b1ab399da81324693dcd5d3feeccb211642b10f11cd3f03c7fe6ba751d4",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/policy.py": "sha256:bdaef1640783ac325a050cadf214e731d7c8e5e3a35dbb8f7562d5abbd209a4c",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/progress_event.py": "sha256:9ac7b7fd4881d424bfe59c5001554fd7302243e8489d5e777584d11ead1db328",
+ "app/.venv/lib/python3.12/site-packages/hub_core/schemas/tpsc.py": "sha256:5640d96a7fa862a468eeb0d6da002697b1f58e4cd675a4603b91e448d4db851e",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/__init__.py": "sha256:2754e25ec0f5725c33cb0b4dff1b7d10b364ebd828e609bb5b44eee3309d2d60",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/account_reader.py": "sha256:27019616b84cf030969a8e3c0850b5ae52f336008fd9daa4c26360566e74a39b",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/audit.py": "sha256:94cb85d71f065443c6423871b59d41e929af0441c95928273caf3e38dad8125c",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/boundary.py": "sha256:a1629ac717672276f61015c83f7af6e375bbd8c1234c712dd3d225da307ae0d6",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/browser.py": "sha256:fd474ee4b9611fe8dd2c60111402c2fce67d680a7da3519b5d4a495a6b559b7a",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/config.py": "sha256:7d604f6aeebde393a371ec3e0ab174934e10808c29fb370acba031486e9c02a3",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/context.py": "sha256:1181bbf12ba86bfd5046dab2f3a22a64ee3686be6dd52ab3e843303349b13a52",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/facts.py": "sha256:3de4db38ffd3f86f4918d76f811289cd501be3c28dcba16f5088aa2a2b7529ce",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/identity.py": "sha256:d7a0bab9d59a5f342fdedc9723d818a9d2537a59c6a9415455786af748f912e9",
+ "app/.venv/lib/python3.12/site-packages/hub_core/security/policy.py": "sha256:a2370e9ea5b04b1d5a429644d7f81edb1cbe8e15214c23b16cd6f949613eebd6",
+ "app/.venv/lib/python3.12/site-packages/hub_core/utils/__init__.py": "sha256:ce4ddb30ccc4fd2c4ee59e8a3c088a12165171af02aea957f17f72cd868e1899",
+ "app/.venv/lib/python3.12/site-packages/hub_core/utils/pagination.py": "sha256:366250b85052c7aabc2a2262521d34233124ba3a41026042792f8eda706ecb4f",
+ "app/.venv/lib/python3.12/site-packages/hub_core/utils/paths.py": "sha256:988a889be74f0626ca3c27b1d6677bd4a556e35de389c060d07ab64fa1c22fc8",
+ "app/.venv/lib/python3.12/site-packages/hub_core/utils/routing.py": "sha256:c94b44c5f1ab723e123503e20e0309f28abeeabea6d51beda79247d99852ad1e",
+ "app/.venv/lib/python3.12/site-packages/hub_core/utils/slugs.py": "sha256:eda7a7d18ff21a4d949223b75a2b615a949e615f461152a9cb9a2cf74b491e2d",
+ "app/api/routers/workstreams.py": "sha256:9c9bf4376dd323b52109912bb3f615949e89ac02c8b88b4720f569f8565ad1d6",
+ "app/api/services/forge_projection.py": "sha256:b980e62cbd85c3e7df42b01abf14354e04133374db94af6531dcd91629c3487d",
+ "app/api/services/historical_projection.py": "sha256:e24e57c58f4aaa4ec9d9bf088009ac69006e53f75b9477ee53dc26c4e850b975",
+ "app/migrations/versions/c4d5e6f7a8b9_inbox_feed_revision.py": "sha256:48f1730037be99b5623e878d7f20a06bec243ce27317fde81c82e16006d7b3ce",
+ "app/scripts/consistency_check.py": "sha256:eaee916f46a11f49d2621fc9386717d73b7fd517acbe4b695e7b551d67bfa8f7"
+ },
+ "layers": [
+ {
+ "digest": "sha256:304074f06047f3dee0c586de265e0e27b577930bc80dc7f97f3829b188bc4fb0",
+ "compressed_bytes": 30855435,
+ "archive_entries": 3271,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:75b6a36c64a1cf3520fc4b4aae4739d5caa8a6a05c27835a920a61784c9c32f8",
+ "compressed_bytes": 1294052,
+ "archive_entries": 553,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:6912e23eb44595b44dc6d9882b34f305ad6f6e9667cec70b13e5dbc4f8cd97f4",
+ "compressed_bytes": 12121637,
+ "archive_entries": 1828,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:bbc5a12237474ec0b23b5051260d56d6436a24d70d9cad961adebe7a037ed813",
+ "compressed_bytes": 249,
+ "archive_entries": 8,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:e16e3995c15e64ae03e700d74eab2389f1151e6bd5d40f55faa047e9124aeb20",
+ "compressed_bytes": 93,
+ "archive_entries": 1,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:14568be832d0c877348328755de4e94cf4d0397d03f2161f6995e3b3e756e802",
+ "compressed_bytes": 55305356,
+ "archive_entries": 2898,
+ "whiteouts": 62
+ },
+ {
+ "digest": "sha256:2a6eb309967b62bf0c483ef7ca6b595a3222af9824d7d76df93588d2db516195",
+ "compressed_bytes": 148,
+ "archive_entries": 2,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:fb5adb1b205f7c2a5a53dc53c300aa7fa968e3bf2ba85bfcf569b8f45131ae94",
+ "compressed_bytes": 940,
+ "archive_entries": 2,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:2971f0a710787419ec533843974be96de47b083059b02e4a0dfdd48a98fdb458",
+ "compressed_bytes": 35700592,
+ "archive_entries": 5471,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:ab3e8c3939ccb8f5d731704f1e752ddb7fa9d135402990246724232683b00ff0",
+ "compressed_bytes": 598,
+ "archive_entries": 3,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:d1dbbe039887ff15d19e26f1fb3c559ea123a580c77bff5e286de1d6e3540595",
+ "compressed_bytes": 102610,
+ "archive_entries": 143,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:b62bd61a70f8477e54c01355ae337a553326e09974eebd65870d66f0b2f1ae3f",
+ "compressed_bytes": 109565,
+ "archive_entries": 160,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:99e6faeb00ebe8336cefd27da2bbedb3b4edc9d3c9e36ce05097163ba105a036",
+ "compressed_bytes": 475,
+ "archive_entries": 2,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:f910ccb3f35e942317fb817caf1ea13ddd0a49263e7bfbf6b8be6d132a663b29",
+ "compressed_bytes": 243471,
+ "archive_entries": 181,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:793e1ade12c8a9541429984963ef1084a96d41a69cbd5669d655829982b3bea7",
+ "compressed_bytes": 1675,
+ "archive_entries": 7,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:7a36c195ce44bc1ce1c7964cfd1fa3f3c32daf3dc9568a4ff7b550b0d93ef0fc",
+ "compressed_bytes": 36056,
+ "archive_entries": 7,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:231fbd5b3576f8c671a67522bad6a83935990a9a7fbe120ff26b422f78bd5f60",
+ "compressed_bytes": 33942,
+ "archive_entries": 63,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:23a0876f55835a5ee79ed45c5a67ba7ac9ca1e4e3168dfa2436dd234a370434a",
+ "compressed_bytes": 10801,
+ "archive_entries": 8,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:9a13796803f4255f7c8a1df9b693923fa002647c2690a148ebf901e4de66cb4f",
+ "compressed_bytes": 2116,
+ "archive_entries": 3,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:48d53f6a4a6c1a1f0ee5b6f45e8e8b47792ff9a4416c00c9c92454634155f915",
+ "compressed_bytes": 155200,
+ "archive_entries": 67,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:d5d5b11d971cbb2269dc1684898da2c0a41bff26630affbd8123ba7c82bd41b1",
+ "compressed_bytes": 2963,
+ "archive_entries": 7,
+ "whiteouts": 0
+ },
+ {
+ "digest": "sha256:d8438040b445dc9c2ad739b44d49d347d4c276ba889d5cdd8153fdadbe14f34d",
+ "compressed_bytes": 538,
+ "archive_entries": 4,
+ "whiteouts": 0
+ }
+ ],
+ "python_package_metadata": {
+ "usr/local/lib/python3.12/site-packages/pip-25.0.1.dist-info/METADATA": {
+ "name": "pip",
+ "version": "25.0.1",
+ "metadata_sha256": "sha256:4fa7318cf30f979db7cec45c12cbbc2b4f88a15f12eefbf59a6291d0a03af926"
+ },
+ "usr/local/lib/python3.12/site-packages/uv-0.13.1.dist-info/METADATA": {
+ "name": "uv",
+ "version": "0.13.1",
+ "metadata_sha256": "sha256:3554d26fcb1db453b54bde82aa3830ddc37179f3ac4394d670361553c02d0cf2"
+ },
+ "app/.venv/lib/python3.12/site-packages/aiofile-3.12.3.dist-info/METADATA": {
+ "name": "aiofile",
+ "version": "3.12.3",
+ "metadata_sha256": "sha256:faaa3115c495ac1989aa6bda262b79a6c97e056c83e04afcfc71584a59e3ceb1"
+ },
+ "app/.venv/lib/python3.12/site-packages/alembic-1.20.0.dist-info/METADATA": {
+ "name": "alembic",
+ "version": "1.20.0",
+ "metadata_sha256": "sha256:bd32c118c43955c106bfa4255b2fb5ac9db410b0f7255a4cafb14b9e221ff41c"
+ },
+ "app/.venv/lib/python3.12/site-packages/annotated_doc-0.0.5.dist-info/METADATA": {
+ "name": "annotated-doc",
+ "version": "0.0.5",
+ "metadata_sha256": "sha256:8c24d70c5d9153d22123353a906042fd7603df2d15c2196290b0f8a962558c46"
+ },
+ "app/.venv/lib/python3.12/site-packages/annotated_types-0.8.0.dist-info/METADATA": {
+ "name": "annotated-types",
+ "version": "0.8.0",
+ "metadata_sha256": "sha256:614985b278f601b8ef863f82783b991cc3ada4b89f592eb71c7c48c5f2ef2298"
+ },
+ "app/.venv/lib/python3.12/site-packages/anyio-4.15.1.dist-info/METADATA": {
+ "name": "anyio",
+ "version": "4.15.1",
+ "metadata_sha256": "sha256:043aecb571af7189e8a6c528f5d8e32545f1b2cdfaaf761d51e6d5b67ea3ca81"
+ },
+ "app/.venv/lib/python3.12/site-packages/asyncpg-0.32.0.dist-info/METADATA": {
+ "name": "asyncpg",
+ "version": "0.32.0",
+ "metadata_sha256": "sha256:10c1e84f762a55c69630704e8a429793dcab1ce5edba7deadb35f40476d502c9"
+ },
+ "app/.venv/lib/python3.12/site-packages/attrs-26.1.0.dist-info/METADATA": {
+ "name": "attrs",
+ "version": "26.1.0",
+ "metadata_sha256": "sha256:4cd40e690f23bf37cbcad34efd6758e062df1df07c86833f7e3ce95bf1fa38cc"
+ },
+ "app/.venv/lib/python3.12/site-packages/authlib-1.9.1.dist-info/METADATA": {
+ "name": "Authlib",
+ "version": "1.9.1",
+ "metadata_sha256": "sha256:7141e57ccac936724fd004b401d0695e0ae48f32f2aee14d90259a9d4bc4fdbd"
+ },
+ "app/.venv/lib/python3.12/site-packages/beartype-0.23.1.dist-info/METADATA": {
+ "name": "beartype",
+ "version": "0.23.1",
+ "metadata_sha256": "sha256:ac75ec7a0bd6407db75c5c1da887798dfd4d5d12099e299602d03baa0ba22986"
+ },
+ "app/.venv/lib/python3.12/site-packages/cachetools-7.2.2.dist-info/METADATA": {
+ "name": "cachetools",
+ "version": "7.2.2",
+ "metadata_sha256": "sha256:76bbee4a06789a8c42253966988a07aff8f2f29cb1ae3ff9d6f16d661f01f2d4"
+ },
+ "app/.venv/lib/python3.12/site-packages/caio-0.12.9.dist-info/METADATA": {
+ "name": "caio",
+ "version": "0.12.9",
+ "metadata_sha256": "sha256:3128051d993cbf71f589f68b0082167fe1c910b17379d27653b6945ec848c3c8"
+ },
+ "app/.venv/lib/python3.12/site-packages/certifi-2026.7.22.dist-info/METADATA": {
+ "name": "certifi",
+ "version": "2026.7.22",
+ "metadata_sha256": "sha256:ef5af1638fbb23676ac3c5777dfcfc2cd9c348fe4172ed5ba3d277655b248090"
+ },
+ "app/.venv/lib/python3.12/site-packages/cffi-2.1.1.dist-info/METADATA": {
+ "name": "cffi",
+ "version": "2.1.1",
+ "metadata_sha256": "sha256:558bafb9093a8e790175d9ecff9688382981d744877dad86fd6770db0f4dc918"
+ },
+ "app/.venv/lib/python3.12/site-packages/click-8.5.0.dist-info/METADATA": {
+ "name": "click",
+ "version": "8.5.0",
+ "metadata_sha256": "sha256:e87bce0bd194de70dfb8e708e0a6b0483009f25611804eaf87bfeb63e6c20501"
+ },
+ "app/.venv/lib/python3.12/site-packages/cryptography-50.0.2.dist-info/METADATA": {
+ "name": "cryptography",
+ "version": "50.0.2",
+ "metadata_sha256": "sha256:047cfc71226409de4318b360aef15c63a2adf609731e3dcf7fe932f14ea042ee"
+ },
+ "app/.venv/lib/python3.12/site-packages/cyclopts-5.2.0.dist-info/METADATA": {
+ "name": "cyclopts",
+ "version": "5.2.0",
+ "metadata_sha256": "sha256:a1bcd1e587744ec6f5c5c64126ac03a03fc2468f7c91cd1c44572a5ac11dcb2a"
+ },
+ "app/.venv/lib/python3.12/site-packages/dnspython-2.9.0.dist-info/METADATA": {
+ "name": "dnspython",
+ "version": "2.9.0",
+ "metadata_sha256": "sha256:d25dbd09b39758cd5cebe518701012a6278731986cd384fc4a7f25e78f7de929"
+ },
+ "app/.venv/lib/python3.12/site-packages/docstring_parser-0.18.0.dist-info/METADATA": {
+ "name": "docstring_parser",
+ "version": "0.18.0",
+ "metadata_sha256": "sha256:cbc42027997fd4b194ae5fd064a4d1e57b92976941d0c26d369561a7c016a333"
+ },
+ "app/.venv/lib/python3.12/site-packages/email_validator-2.3.0.dist-info/METADATA": {
+ "name": "email-validator",
+ "version": "2.3.0",
+ "metadata_sha256": "sha256:2a97b81eefcd856bc80b0346f47fa2d800b9a438bf8f9231ae00fe931d5c9fbc"
+ },
+ "app/.venv/lib/python3.12/site-packages/exceptiongroup-1.3.1.dist-info/METADATA": {
+ "name": "exceptiongroup",
+ "version": "1.3.1",
+ "metadata_sha256": "sha256:81984a523a2f7a522ad12bea784a8bb85edec0805ebbd0fb4e350169a36dd802"
+ },
+ "app/.venv/lib/python3.12/site-packages/fastapi-0.143.3.dist-info/METADATA": {
+ "name": "fastapi",
+ "version": "0.143.3",
+ "metadata_sha256": "sha256:364f68b16f489abe36d621da2fa727f774bec90916cd3325b78e422af3b59a5f"
+ },
+ "app/.venv/lib/python3.12/site-packages/fastmcp-4.1.0.dist-info/METADATA": {
+ "name": "fastmcp",
+ "version": "4.1.0",
+ "metadata_sha256": "sha256:cfdcfd24ad904514087ec1735baa54df174f72f570833a16b6074887cfc51fa6"
+ },
+ "app/.venv/lib/python3.12/site-packages/fastmcp_slim-4.1.0.dist-info/METADATA": {
+ "name": "fastmcp-slim",
+ "version": "4.1.0",
+ "metadata_sha256": "sha256:e8eca5fbae348ee6dcda0a0f1bf0dffa493e8d4d8763647ee48f4f871e35c998"
+ },
+ "app/.venv/lib/python3.12/site-packages/greenlet-3.5.6.dist-info/METADATA": {
+ "name": "greenlet",
+ "version": "3.5.6",
+ "metadata_sha256": "sha256:b9b4cbbc1f3165e50fbc4696c433fe82b8a151fde65eede28fb6ec5efb3e1fab"
+ },
+ "app/.venv/lib/python3.12/site-packages/griffelib-2.3.2.dist-info/METADATA": {
+ "name": "griffelib",
+ "version": "2.3.2",
+ "metadata_sha256": "sha256:fd24a68ed08309718d5759b8de3b90392b5804240d94834e6455be110ead34b3"
+ },
+ "app/.venv/lib/python3.12/site-packages/h11-0.16.0.dist-info/METADATA": {
+ "name": "h11",
+ "version": "0.16.0",
+ "metadata_sha256": "sha256:28f326098ac09fcba79b8f180f96087c841fe2456215cb7b872a9c7d5cd19d64"
+ },
+ "app/.venv/lib/python3.12/site-packages/httpcore-1.0.9.dist-info/METADATA": {
+ "name": "httpcore",
+ "version": "1.0.9",
+ "metadata_sha256": "sha256:fe2d4fda6199128978779e0cf27f3c045c531863fcdd987eacc74f3a18d41c21"
+ },
+ "app/.venv/lib/python3.12/site-packages/httpcore2-2.13.1.dist-info/METADATA": {
+ "name": "httpcore2",
+ "version": "2.13.1",
+ "metadata_sha256": "sha256:37a0ea0674e98b7c3a1f99c17d9bb5457de27a0f93c8b6560c9d17d525139abb"
+ },
+ "app/.venv/lib/python3.12/site-packages/httptools-0.9.0.dist-info/METADATA": {
+ "name": "httptools",
+ "version": "0.9.0",
+ "metadata_sha256": "sha256:8a743396f45465204e2b44b43cb57ec4a343c423f52604bbaf1777fd438698c9"
+ },
+ "app/.venv/lib/python3.12/site-packages/httpx-0.28.1.dist-info/METADATA": {
+ "name": "httpx",
+ "version": "0.28.1",
+ "metadata_sha256": "sha256:febb9b0f8f3e80d57c8199c304f35c4336e8581d1d18d7983c92766b82793b25"
+ },
+ "app/.venv/lib/python3.12/site-packages/httpx2-2.13.1.dist-info/METADATA": {
+ "name": "httpx2",
+ "version": "2.13.1",
+ "metadata_sha256": "sha256:b75b03a003b5593e000907cf7239fcc3f688b8f17db4ecff883d11221ea988fc"
+ },
+ "app/.venv/lib/python3.12/site-packages/idna-3.20.dist-info/METADATA": {
+ "name": "idna",
+ "version": "3.20",
+ "metadata_sha256": "sha256:dbd8c14c1e4ca1e0c9824a6dbc7cbbf78884f38eb52d91148cd3025f671e4b85"
+ },
+ "app/.venv/lib/python3.12/site-packages/jaraco.classes-3.4.0.dist-info/METADATA": {
+ "name": "jaraco.classes",
+ "version": "3.4.0",
+ "metadata_sha256": "sha256:2e6b102232edd45ae1bb8a6b4091fd40cf3201a6bb6fd4bc97c5e8cd765a44b8"
+ },
+ "app/.venv/lib/python3.12/site-packages/jaraco_context-6.1.2.dist-info/METADATA": {
+ "name": "jaraco.context",
+ "version": "6.1.2",
+ "metadata_sha256": "sha256:f1b65b60aea229af54e09b17ae0036cf048fe4a6161fa2e7966a8ea5c2321491"
+ },
+ "app/.venv/lib/python3.12/site-packages/jaraco_functools-4.6.0.dist-info/METADATA": {
+ "name": "jaraco.functools",
+ "version": "4.6.0",
+ "metadata_sha256": "sha256:6ed3cae5b8eef084c3c7b2385a0a8ac4c8155ab7c729212bea099bee69d5bd6c"
+ },
+ "app/.venv/lib/python3.12/site-packages/jeepney-0.9.0.dist-info/METADATA": {
+ "name": "jeepney",
+ "version": "0.9.0",
+ "metadata_sha256": "sha256:b8e6c353e9aaed0ed0144029556417fda23b64f344e718331907054ba05b8c62"
+ },
+ "app/.venv/lib/python3.12/site-packages/joserfc-1.7.5.dist-info/METADATA": {
+ "name": "joserfc",
+ "version": "1.7.5",
+ "metadata_sha256": "sha256:17df62bbdc7212bbcda3144e72720a62fc34bbf76c573062939120738545250f"
+ },
+ "app/.venv/lib/python3.12/site-packages/jsonref-1.1.0.dist-info/METADATA": {
+ "name": "jsonref",
+ "version": "1.1.0",
+ "metadata_sha256": "sha256:f98db3890394fba23912faab86ad8f70ed564fc8c885298b636b270f28453e43"
+ },
+ "app/.venv/lib/python3.12/site-packages/jsonschema-4.26.0.dist-info/METADATA": {
+ "name": "jsonschema",
+ "version": "4.26.0",
+ "metadata_sha256": "sha256:05b189866ca98a96da3934bf6b83687ced3ff85325273b77e16b705f6d8b26d2"
+ },
+ "app/.venv/lib/python3.12/site-packages/jsonschema_path-0.5.0.dist-info/METADATA": {
+ "name": "jsonschema-path",
+ "version": "0.5.0",
+ "metadata_sha256": "sha256:cd7e9a4426436980f099d826dbce8f61afa6603b496385fba9a9eb0ad59cdd48"
+ },
+ "app/.venv/lib/python3.12/site-packages/jsonschema_specifications-2025.9.1.dist-info/METADATA": {
+ "name": "jsonschema-specifications",
+ "version": "2025.9.1",
+ "metadata_sha256": "sha256:35abd41757f32b4ea24756920ded47b70173d77cbc052a5a6d3ab583b2e8d89d"
+ },
+ "app/.venv/lib/python3.12/site-packages/keyring-25.7.0.dist-info/METADATA": {
+ "name": "keyring",
+ "version": "25.7.0",
+ "metadata_sha256": "sha256:bd54de98fede6dc3e1f3cd89b4e37c95d8842a523bdbb9e52ab40edff1837163"
+ },
+ "app/.venv/lib/python3.12/site-packages/mako-1.4.3.dist-info/METADATA": {
+ "name": "Mako",
+ "version": "1.4.3",
+ "metadata_sha256": "sha256:39ea65f21e706265428ea6f2925520f419a10f0113a59318192ed38175d8838b"
+ },
+ "app/.venv/lib/python3.12/site-packages/markdown_it_py-4.2.0.dist-info/METADATA": {
+ "name": "markdown-it-py",
+ "version": "4.2.0",
+ "metadata_sha256": "sha256:72c36ff516755443b1ecb61e17b2567a990230b49a504b0eb0c42d5f82448a9c"
+ },
+ "app/.venv/lib/python3.12/site-packages/markupsafe-3.0.4.dist-info/METADATA": {
+ "name": "MarkupSafe",
+ "version": "3.0.4",
+ "metadata_sha256": "sha256:66767d76388ece15d49d50dafb655891ef5d868715e7128e7f7d737a70b353c4"
+ },
+ "app/.venv/lib/python3.12/site-packages/mcp-2.3.0.dist-info/METADATA": {
+ "name": "mcp",
+ "version": "2.3.0",
+ "metadata_sha256": "sha256:a94577513a4fa0cb639c279c1ad6f4d49650872d45f2f4ab9bb2d9c2a505d457"
+ },
+ "app/.venv/lib/python3.12/site-packages/mcp_types-2.3.0.dist-info/METADATA": {
+ "name": "mcp-types",
+ "version": "2.3.0",
+ "metadata_sha256": "sha256:eb973e8b7f53d52ab1df9d1f18854ffa84f074f6052e310089e2655961c48ca9"
+ },
+ "app/.venv/lib/python3.12/site-packages/mdurl-0.1.2.dist-info/METADATA": {
+ "name": "mdurl",
+ "version": "0.1.2",
+ "metadata_sha256": "sha256:b53b29d48f499367053fda3c81e7ce27d2558380ebbf83e66023b02eb7ddd25d"
+ },
+ "app/.venv/lib/python3.12/site-packages/more_itertools-11.2.1.dist-info/METADATA": {
+ "name": "more-itertools",
+ "version": "11.2.1",
+ "metadata_sha256": "sha256:8c558bfab61818450e78db4018281f8f7b22115e681cd53ae1d9d4931be54f15"
+ },
+ "app/.venv/lib/python3.12/site-packages/nats_py-2.16.0.dist-info/METADATA": {
+ "name": "nats-py",
+ "version": "2.16.0",
+ "metadata_sha256": "sha256:58ddf3dd698f9026d397e5bb56a0baac723ba221d3dddae0831cfacc75c34255"
+ },
+ "app/.venv/lib/python3.12/site-packages/openapi_pydantic-0.6.0.dist-info/METADATA": {
+ "name": "openapi-pydantic",
+ "version": "0.6.0",
+ "metadata_sha256": "sha256:e46a181161c62fa24d7d3d0ff2eaabea3a7b580ad7aa9f70d1f08bd129d46f49"
+ },
+ "app/.venv/lib/python3.12/site-packages/opentelemetry_api-1.45.1.dist-info/METADATA": {
+ "name": "opentelemetry-api",
+ "version": "1.45.1",
+ "metadata_sha256": "sha256:5bc71e90cb421be92a43bf5ad5b0f25dccfa2a24ddceaa2877779879962a4c9e"
+ },
+ "app/.venv/lib/python3.12/site-packages/packaging-26.3.dist-info/METADATA": {
+ "name": "packaging",
+ "version": "26.3",
+ "metadata_sha256": "sha256:70fdb89fc4d4a9a043bf7372b8972bcc883fddff34ab55e9cf80d73875384763"
+ },
+ "app/.venv/lib/python3.12/site-packages/pathable-0.6.0.dist-info/METADATA": {
+ "name": "pathable",
+ "version": "0.6.0",
+ "metadata_sha256": "sha256:c418727931012c2306cb8fa2726d044616c3178c1fc078ce182385dcf2b59922"
+ },
+ "app/.venv/lib/python3.12/site-packages/platformdirs-4.13.3.dist-info/METADATA": {
+ "name": "platformdirs",
+ "version": "4.13.3",
+ "metadata_sha256": "sha256:3814840758f662bb98053d08f99085fb77721812258573732b092fd7f2120c30"
+ },
+ "app/.venv/lib/python3.12/site-packages/psycopg2_binary-2.9.13.dist-info/METADATA": {
+ "name": "psycopg2-binary",
+ "version": "2.9.13",
+ "metadata_sha256": "sha256:a5267b4520191137e34589704162fce02cfd40efcfeb899c45187c7f6bfea7e5"
+ },
+ "app/.venv/lib/python3.12/site-packages/py_key_value_aio-0.4.6.dist-info/METADATA": {
+ "name": "py-key-value-aio",
+ "version": "0.4.6",
+ "metadata_sha256": "sha256:0c1af357b841bc3862e39543faa863346c487be9f2e2ffefba009e957a256bee"
+ },
+ "app/.venv/lib/python3.12/site-packages/pycparser-3.11.dist-info/METADATA": {
+ "name": "pycparser",
+ "version": "3.11",
+ "metadata_sha256": "sha256:e3a74c846648bb55f4f907284b745e2003b87b96d9c9d17a8a50cf37804541c9"
+ },
+ "app/.venv/lib/python3.12/site-packages/pydantic-2.14.1.dist-info/METADATA": {
+ "name": "pydantic",
+ "version": "2.14.1",
+ "metadata_sha256": "sha256:4d4c8e8d5e6a27d5208f0d7949f25628e6747133034969d4d1399b1ad5394036"
+ },
+ "app/.venv/lib/python3.12/site-packages/pydantic_core-2.50.1.dist-info/METADATA": {
+ "name": "pydantic_core",
+ "version": "2.50.1",
+ "metadata_sha256": "sha256:ae6817764b603fa2300cd31b9566ecc33476df46163c4150fffc353121a166f2"
+ },
+ "app/.venv/lib/python3.12/site-packages/pydantic_settings-2.16.0.dist-info/METADATA": {
+ "name": "pydantic-settings",
+ "version": "2.16.0",
+ "metadata_sha256": "sha256:470656112388bca95fd7d27b8954fda1f11dd6579563cfb9c0ea0445d1746b77"
+ },
+ "app/.venv/lib/python3.12/site-packages/pygments-2.21.0.dist-info/METADATA": {
+ "name": "Pygments",
+ "version": "2.21.0",
+ "metadata_sha256": "sha256:1dde075570136774c706bf0009183a793fe0ee262e4a5590cc6eff8453eedd43"
+ },
+ "app/.venv/lib/python3.12/site-packages/pyjwt-2.15.1.dist-info/METADATA": {
+ "name": "PyJWT",
+ "version": "2.15.1",
+ "metadata_sha256": "sha256:1ea0c9f139b8c928276b3628af165955f6f764491448a6ad8f103cdcbb382da3"
+ },
+ "app/.venv/lib/python3.12/site-packages/pyperclip-1.11.0.dist-info/METADATA": {
+ "name": "pyperclip",
+ "version": "1.11.0",
+ "metadata_sha256": "sha256:2493839e0751761f27d39aa6d5c5dfd1df839c45da8f328aba4ae4102868b825"
+ },
+ "app/.venv/lib/python3.12/site-packages/python_dotenv-1.2.4.dist-info/METADATA": {
+ "name": "python-dotenv",
+ "version": "1.2.4",
+ "metadata_sha256": "sha256:87cfee6042412d48df1100ea7822684988c3f2b79de7a250f4717f4df3026d6f"
+ },
+ "app/.venv/lib/python3.12/site-packages/python_multipart-0.0.32.dist-info/METADATA": {
+ "name": "python-multipart",
+ "version": "0.0.32",
+ "metadata_sha256": "sha256:e9d399d7dea5eddf0e4d24b5c24687a82e7e50bf2c2b30f8f8a06a64d887dd86"
+ },
+ "app/.venv/lib/python3.12/site-packages/pyyaml-6.0.3.dist-info/METADATA": {
+ "name": "PyYAML",
+ "version": "6.0.3",
+ "metadata_sha256": "sha256:03c3b415ed38d09faedc49360e930769b40c58f68585e6de00e2e4916a858d34"
+ },
+ "app/.venv/lib/python3.12/site-packages/referencing-0.37.0.dist-info/METADATA": {
+ "name": "referencing",
+ "version": "0.37.0",
+ "metadata_sha256": "sha256:c93b00e6ab1524fd60848376604c189a114374776475c25a31d57be47b29de6a"
+ },
+ "app/.venv/lib/python3.12/site-packages/rich-15.0.0.dist-info/METADATA": {
+ "name": "rich",
+ "version": "15.0.0",
+ "metadata_sha256": "sha256:fad34fc603fac4481bf81f8dd629492fc302c0ec31909c86644751512afb2e3b"
+ },
+ "app/.venv/lib/python3.12/site-packages/rich_rst-2.2.0.dist-info/METADATA": {
+ "name": "rich-rst",
+ "version": "2.2.0",
+ "metadata_sha256": "sha256:2cd1f39149e69bd94c4f1c30cb26c763e45689e6d6527b4c23afa1112c48f165"
+ },
+ "app/.venv/lib/python3.12/site-packages/rpds_py-2026.9.1.dist-info/METADATA": {
+ "name": "rpds-py",
+ "version": "2026.9.1",
+ "metadata_sha256": "sha256:5d72dca2e387c8d2b4180b68ee87c15b64751e059100a6db58891ec9995445c3"
+ },
+ "app/.venv/lib/python3.12/site-packages/secretstorage-3.5.0.dist-info/METADATA": {
+ "name": "SecretStorage",
+ "version": "3.5.0",
+ "metadata_sha256": "sha256:b98e3dc42ff7e9b1bacce60dac2662cd9965fad6e4469fc9483c9eb99998c367"
+ },
+ "app/.venv/lib/python3.12/site-packages/sqlalchemy-2.1.5.dist-info/METADATA": {
+ "name": "SQLAlchemy",
+ "version": "2.1.5",
+ "metadata_sha256": "sha256:7f568ea71aecfb29792ccee2c8b1345825e385d54b7e7bba755e445e53b5696f"
+ },
+ "app/.venv/lib/python3.12/site-packages/sse_starlette-3.5.0.dist-info/METADATA": {
+ "name": "sse-starlette",
+ "version": "3.5.0",
+ "metadata_sha256": "sha256:6dc401682bdae1a3e1f31b1c320b7f926c354643993840393875ebfc171c9014"
+ },
+ "app/.venv/lib/python3.12/site-packages/starlette-1.8.0.dist-info/METADATA": {
+ "name": "starlette",
+ "version": "1.8.0",
+ "metadata_sha256": "sha256:551e8d74d290002521c655b816b27654e49922b88cb474c8d710a893099036f8"
+ },
+ "app/.venv/lib/python3.12/site-packages/truststore-0.10.5.dist-info/METADATA": {
+ "name": "truststore",
+ "version": "0.10.5",
+ "metadata_sha256": "sha256:1fd496f290853214e630d96f7163d2dd916f84ebe634b58aa827d6853e8f8610"
+ },
+ "app/.venv/lib/python3.12/site-packages/typing_extensions-4.16.0.dist-info/METADATA": {
+ "name": "typing_extensions",
+ "version": "4.16.0",
+ "metadata_sha256": "sha256:b05084ca1d50879865178d9fff9fabeab61bdfb1f361bfbde95421ffc8f9be46"
+ },
+ "app/.venv/lib/python3.12/site-packages/typing_inspection-0.4.4.dist-info/METADATA": {
+ "name": "typing-inspection",
+ "version": "0.4.4",
+ "metadata_sha256": "sha256:bad3d9e6342ef6750cf5128ec942327b8bebdce0b6e4d6f67cb15b27ae5118da"
+ },
+ "app/.venv/lib/python3.12/site-packages/uncalled_for-0.4.1.dist-info/METADATA": {
+ "name": "uncalled-for",
+ "version": "0.4.1",
+ "metadata_sha256": "sha256:45a21666ddfa8ca8d4e2423c05edaeaa7827686a3ae4fed82b24427854745c2f"
+ },
+ "app/.venv/lib/python3.12/site-packages/uvicorn-0.54.0.dist-info/METADATA": {
+ "name": "uvicorn",
+ "version": "0.54.0",
+ "metadata_sha256": "sha256:9399e5ddda3d26e842d6fb5420c38b0857aac568d53badbdc224b3a21339efa8"
+ },
+ "app/.venv/lib/python3.12/site-packages/uvloop-0.23.0.dist-info/METADATA": {
+ "name": "uvloop",
+ "version": "0.23.0",
+ "metadata_sha256": "sha256:fe11a50198ffd4d9f1cb20922dc638ad459d5e63e6c615b0c1c69448d3362977"
+ },
+ "app/.venv/lib/python3.12/site-packages/watchfiles-1.3.0.dist-info/METADATA": {
+ "name": "watchfiles",
+ "version": "1.3.0",
+ "metadata_sha256": "sha256:1fba87d08d66f10f7515e4817abd4ac7f27abf3300cb33b7ad3f79762987a502"
+ },
+ "app/.venv/lib/python3.12/site-packages/websockets-17.2.dist-info/METADATA": {
+ "name": "websockets",
+ "version": "17.2",
+ "metadata_sha256": "sha256:0d5f1279d095e7ba84ef21614af0822dc500ccb2e51b7c3a19eda7159b2dade4"
+ },
+ "app/.venv/lib/python3.12/site-packages/hub_core-0.2.0.dist-info/METADATA": {
+ "name": "hub-core",
+ "version": "0.2.0",
+ "metadata_sha256": "sha256:84a59c6ea36d6d8a0b91b76e52f351a5ce6f2fd9ace8a4c887b51bab05973953"
+ }
+ },
+ "package_metadata_equal_to_deployed_image": true,
+ "hub_core_source_hashes_equal_to_deployed_image": true,
+ "image_executed": false,
+ "image_installed_or_daemon_pulled": false,
+ "anonymous_protocol_only": true,
+ "token_retained": false,
+ "limitations": [
+ "Selected code hashes and package inventory do not prove full application acceptance or hermetic provenance.",
+ "Floating dependency origins remain unknown; observed byte changes require explicit review.",
+ "New API behavior is not deployed or natively accepted."
+ ]
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/checkpoint-progress-receipt.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/checkpoint-progress-receipt.json
new file mode 100644
index 00000000..e23b7413
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/checkpoint-progress-receipt.json
@@ -0,0 +1,14 @@
+{
+ "workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
+ "id": "da342cba-86c9-4aa6-9e15-c34bc7370be8",
+ "topic_id": null,
+ "task_id": null,
+ "decision_id": "df703266-6719-4c04-ae1b-ba9afd3bca1c",
+ "event_type": "note",
+ "summary": "Founder-approved frozen-binding API guard revision2 SHA5d0ddebc deployedexactc103cbf/4cc4 atHelm77. API/MCPReady,schemac4unchanged, eightactualnative intercepted/rollbackcasespasswithzero commits, all18CURRENT historicalfullrows preserved acrossrollout/probes/normalCluster20ordinarybindingpipelineandfinalallpublications. Threefrozenarchives andunrelatedstaged/dirtyworkpreserved. ExactownedJob99ebab/podobservedSucceededthenabsent; exactownedchartstagingremoved,norecovery. Originalthreeparentmismatch retainedasfounderapprovedhistoricalexception withoutguessedrestore/backfilledsuccess. Verifiedreceivingreturn09958bd6. SourceStateHub200cbd0a finishesSTATE-WP0095/all10done,declaredimagepinsconsistent; RepoManager3bcb1e1c finishesRMGR-WP0019/all6done. All32coordinator+affectedownertaskbindings/UUIDs verifiedsource/remote/primary. Custodiancheckpointceaeef1e committedthroughnormalhooksandnormalfix-consistency/rmgrsync appliedprimary expected=derived; rootconsistency0fail/0automationerror/7warnings. Existingbinding-guard drivercasesatisfied; TLS/storage/nativeconsumerwaitsremainlive. All16native targetgatesstayopen. ActiveoriginalgoalcontinueswithGPT6.1Solmediumaudit,storageassignment/nativeproposal,andidentityTLS/selectedconsumerpreparation; no newnativeidentity/storageauthorizationinferred.",
+ "detail": null,
+ "author": "codex",
+ "session_id": null,
+ "created_at": "2026-10-17T02:06:17.176566Z",
+ "workstream_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52"
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-live-and-current-rows.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-live-and-current-rows.json
new file mode 100644
index 00000000..13e1e871
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-live-and-current-rows.json
@@ -0,0 +1,152 @@
+{
+ "credential_values_exposed": false,
+ "historical_module_sha256": "sha256:e24e57c58f4aaa4ec9d9bf088009ac69006e53f75b9477ee53dc26c4e850b975",
+ "raw_rows_retained": false,
+ "schema": "state95.full-db-row-hash-snapshot.v1",
+ "status": "verified-current-rows-after-all-publication",
+ "tables": {
+ "tasks": {
+ "all_rows_sha256": "sha256:7f42dea1cabd2f5802aab5e2b1535a530192a1f35721ca1ca2cddc2fb5319cc9",
+ "columns": [
+ "assignee",
+ "blocked_on",
+ "blocking_reason",
+ "created_at",
+ "decision_id",
+ "description",
+ "due_date",
+ "flavor",
+ "id",
+ "intervention_note",
+ "needs_human",
+ "parent_task_id",
+ "priority",
+ "record_id",
+ "status",
+ "title",
+ "updated_at",
+ "wait_until",
+ "workplan_id"
+ ],
+ "row_count": 15,
+ "rows": [
+ {
+ "sha256": "sha256:89b3be69fbaf5ad60f5aefa6f2b630e1d6124e0fca7b01a5e9ce1ff41a451106",
+ "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
+ },
+ {
+ "sha256": "sha256:3aa20e9695e110468d13baa3c813ac5b474896d89eeb6edc56e9ea00d9199fd7",
+ "uuid": "08233868-d522-4117-bc4e-6c0f52545665"
+ },
+ {
+ "sha256": "sha256:72fb54d9a271067a62f33377d56e0bb9e6874a3f9c475b9ae049609b93d1f643",
+ "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01"
+ },
+ {
+ "sha256": "sha256:15198017e5c7d31b4e8ce1d5c0ef0de99ac1c50d3bffd0ea0ee807bef70ce5d1",
+ "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
+ },
+ {
+ "sha256": "sha256:29a6f1e50db6a7b4ba5b98a2f7d8d109b4df1720fce58777c21f7538084f8cda",
+ "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40"
+ },
+ {
+ "sha256": "sha256:2e00141f0d7382dd34859b686a500dc740a543efe81e23f5805754cbc6761f2e",
+ "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b"
+ },
+ {
+ "sha256": "sha256:7b3ecacb7be3c1a24a100d7b732b7f60e1a99329a6847612264cd01d13c7ecf0",
+ "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f"
+ },
+ {
+ "sha256": "sha256:71107455d1d453d69d65466ca66da1fd4ee4aa95ecdf7920b9acdce767f0c55c",
+ "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
+ },
+ {
+ "sha256": "sha256:f8e44ea79125870ab3ade44afab8752c126c424e2b751f48b9fe555e00b5b708",
+ "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
+ },
+ {
+ "sha256": "sha256:435e39ffee3a59eae3b890739a6ed8e06801fb23dead15073653d8650f9d5ff5",
+ "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880"
+ },
+ {
+ "sha256": "sha256:2fcda8440a166fc628f8fadf91a4a23ba7728ccac2e629248c6eccd00d5d4345",
+ "uuid": "ab166073-30a7-4702-a037-4091e8706e20"
+ },
+ {
+ "sha256": "sha256:b2e11b8fdc580e6cb7ec20c7e90018ba9fb468893b6eda5b9519dc68737a0ca0",
+ "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
+ },
+ {
+ "sha256": "sha256:788862d9cc5015b927d27303585431a0db4af6077178ab3bfdde37f900cee821",
+ "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
+ },
+ {
+ "sha256": "sha256:f7bc1f3acf187301430df0623417fb90b6af1869c0383a0bff9ec25f79ffe4af",
+ "uuid": "e8510646-46ed-4697-a345-f3d3009eea78"
+ },
+ {
+ "sha256": "sha256:f2760deded2f7136a60fba0156c4956ba373fbcf5eda986f5c380d7ee9c925ff",
+ "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07"
+ }
+ ]
+ },
+ "workplans": {
+ "all_rows_sha256": "sha256:1cd4e695c8f8da8bdb2335a8770a219f704f3989f14d28ebf0b1494f2458dc4a",
+ "columns": [
+ "backing_archived",
+ "backing_filename",
+ "backing_relative_path",
+ "backing_synced_at",
+ "concurrency_mode",
+ "created_at",
+ "derived_from_commit",
+ "description",
+ "due_date",
+ "execution_group",
+ "execution_state",
+ "flavor",
+ "flavor_promoted_from",
+ "flavor_promotion_reason",
+ "id",
+ "launch_mode",
+ "owner",
+ "planning_order",
+ "planning_priority",
+ "projection_retired_at",
+ "projection_retired_reason",
+ "queue_rank",
+ "repo_goal_id",
+ "repo_id",
+ "scheduled_for",
+ "slug",
+ "status",
+ "title",
+ "topic_id",
+ "updated_at"
+ ],
+ "row_count": 3,
+ "rows": [
+ {
+ "sha256": "sha256:bfd3b3abe48d989b1c253d8cca57c463c40e63413885f467b3f3e2187d33cb49",
+ "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
+ },
+ {
+ "sha256": "sha256:c3f19622843b62cea0fd987ed550fe8d050214a952f77f9250e1696506130303",
+ "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
+ },
+ {
+ "sha256": "sha256:a406d311c2c0421eb7646edf4d215dbd96f570d07b2ba0bc6a747d7c573f0b9a",
+ "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
+ }
+ ]
+ }
+ },
+ "helm_revision": 77,
+ "api_mcp_ready": true,
+ "exact_image": "forgejo.coulomb.social/coulomb/state-hub:main-c103cbf@sha256:4cc4ce17bbcdb1b66132d07c5b7b741f46df83124ef1c8403ffabdafba6993d1",
+ "checked_at": "2026-10-17T02:05:51.453898+00:00",
+ "original_failed_comparison_retained": true,
+ "original_preimages_restored": false
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-verification.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-verification.json
new file mode 100644
index 00000000..e5e34cf6
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/close-verification.json
@@ -0,0 +1,12 @@
+{
+ "status": "verified",
+ "commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "remote_exact": true,
+ "primary_projection_expected_equals_derived": true,
+ "task_bindings": 32,
+ "state95_and_registrar19_finished": true,
+ "approved_revision": 2,
+ "runtime_return_and_proposal_bound_receipts_unchanged": true,
+ "unrelated_index_and_worktree_preserved": true,
+ "native_target_gates_closed": 0
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.json
new file mode 100644
index 00000000..b6c090ca
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.json
@@ -0,0 +1,131 @@
+{
+ "repo_slug": "the-custodian",
+ "repo_path": "/home/worsch/the-custodian",
+ "source_revision": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "source_revision_reason": null,
+ "issues": [
+ {
+ "severity": "WARN",
+ "check_id": "C-35",
+ "message": "Repo Manager warning: workplans/: repository uses more than one workplan prefix: ['CUST-WP', 'THE-WP']",
+ "file_path": "workplans/",
+ "db_id": "",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-35",
+ "message": "Repo Manager contradictory: workplans/ADHOC-2026-08-25-retired.md: workplan id 'CUST-WP-ADHOC-2026-08-25-RETIRED' is not accepted by the canon kind registry",
+ "file_path": "workplans/ADHOC-2026-08-25-retired.md",
+ "db_id": "",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-05",
+ "message": "Title drift in 'cust-wp-0010': file='Domain and Repository Goals' db='Workplan Lifecycle Documentation' (file wins)",
+ "file_path": "workplans/CUST-WP-0010b-domain-and-repo-goals.md",
+ "db_id": "def8455a-9de4-4891-a15a-5b09ca98ab1e",
+ "file_value": "Domain and Repository Goals",
+ "db_value": "Workplan Lifecycle Documentation",
+ "fixable": true
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-20",
+ "message": "Workplan dependency target 'STATE-WP-0092' is not linked to State Hub",
+ "file_path": "workplans/CUST-WP-0072-fleet-flavor-and-depends-on-backfill.md",
+ "db_id": "",
+ "file_value": "STATE-WP-0092",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-40",
+ "message": "blocker satisfied, task still wait: 'CUST-WP-0073-T05' in workplan 'CUST-WP-0073' \u2014 decision 1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa is resolved",
+ "file_path": "workplans/CUST-WP-0073-agent-credential-separation.md#CUST-WP-0073-T05",
+ "db_id": "",
+ "file_value": "1591daa1-6e85-4dfd-aadd-5a9fe1ef47fa",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-39",
+ "message": "Human wait task 'CUST-WP-0079-T06' in workplan 'CUST-WP-0079' has needs_human: true without decision_id \u2014 unrouted human gate",
+ "file_path": "workplans/CUST-WP-0079-deterministic-security-flow.md#CUST-WP-0079-T06",
+ "db_id": "",
+ "file_value": "needs_human: true",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "INFO",
+ "check_id": "C-08",
+ "message": "Closed DB workstream 'cust-wp-0010b' (id=416ff571\u2026, status=finished) has no backing workplan file",
+ "file_path": "",
+ "db_id": "416ff571-18cf-5cb2-9c8b-836f68a301b3",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "INFO",
+ "check_id": "C-08",
+ "message": "Retired DB workstream 'interactive-the-custodian@retired-20260828' (id=370c2481\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
+ "file_path": "",
+ "db_id": "370c2481-6806-41eb-a917-f8874f03184f",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "INFO",
+ "check_id": "C-08",
+ "message": "Retired DB workstream 'the-custodian-adhoc-2026-07-02@retired-20260827' (id=5c53733b\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
+ "file_path": "",
+ "db_id": "5c53733b-c077-4a93-be66-9c3af8f9b6fd",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "INFO",
+ "check_id": "C-08",
+ "message": "Retired DB workstream 'adhoc-2026-05-02@retired-20260827' (id=3f54ce9c\u2026, status=finished) has no backing workplan file \u2014 projection tombstone, not live work",
+ "file_path": "",
+ "db_id": "3f54ce9c-1f95-42de-894f-0f81a52ba2e8",
+ "file_value": "",
+ "db_value": "",
+ "fixable": false
+ },
+ {
+ "severity": "WARN",
+ "check_id": "C-26",
+ "message": "Workplan id 'THE-WP-0001' uses prefix 'THE-WP' but repo canonical prefix is 'CUST-WP' \u2014 new plans must conform",
+ "file_path": "workplans/THE-WP-0001-federation-interface.md",
+ "db_id": "",
+ "file_value": "THE-WP-0001",
+ "db_value": "CUST-WP",
+ "fixable": false
+ }
+ ],
+ "fixes_applied": [
+ "bindings: synced 89/89",
+ "C-17 cleared: pushed 1 backlogged commit(s)",
+ "C-05 fixed: workstream def8455a\u2026 title \u2192 'Domain and Repository Goals'",
+ "push: pushed"
+ ],
+ "summary": {
+ "fail": 0,
+ "automation_error": 0,
+ "warn": 7,
+ "info": 4
+ },
+ "result": "warn"
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.stderr.txt b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.stderr.txt
new file mode 100644
index 00000000..047afc64
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-consistency.stderr.txt
@@ -0,0 +1 @@
+WARN: working-memory commit failed: Generated-only commits rejected: staged
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-projection.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-projection.json
new file mode 100644
index 00000000..414df3bc
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-projection.json
@@ -0,0 +1,292 @@
+{
+ "ok": true,
+ "status": "applied",
+ "repo_slug": "the-custodian",
+ "commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "requests": 2,
+ "elapsed_ms": 9730.7,
+ "git": {
+ "upstream": "origin/main",
+ "behind": 0,
+ "ahead": 0
+ },
+ "identifiers": {
+ "schema": "repo-manager.identifier-assignment.v1",
+ "ok": true,
+ "executed": true,
+ "repo": "the-custodian",
+ "namespace": "helixforge",
+ "assignments": [],
+ "historical_invalid_identifiers": [
+ {
+ "kind": "workplan",
+ "id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
+ "source": "workplans/ADHOC-2026-08-25-retired.md"
+ }
+ ],
+ "historical_identity_collisions": [],
+ "files_changed": []
+ },
+ "receipt": {
+ "schema": "state-hub.repository-projection-reconcile.v1",
+ "instance_role": "primary",
+ "instance_label": "railiance01",
+ "expected_commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "derived_commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "outcome": {
+ "schema": "state-hub.projection-reset.v1",
+ "repo_slug": "the-custodian",
+ "commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "status": "applied",
+ "counts": {
+ "created": 0,
+ "updated": 88,
+ "retired": 0,
+ "refused": 0,
+ "released": 0,
+ "created_tasks": 0,
+ "updated_tasks": 0,
+ "cancelled_tasks": 0,
+ "created_edges": 0,
+ "removed_edges": 0,
+ "unresolved_edges": 0,
+ "external_edges": 13
+ },
+ "created": [],
+ "updated": [
+ "CUST-WP-0000",
+ "CUST-WP-0000b",
+ "CUST-WP-0000c",
+ "CUST-WP-0001",
+ "CUST-WP-0002",
+ "CUST-WP-0004",
+ "CUST-WP-0005",
+ "CUST-WP-0006",
+ "CUST-WP-0007",
+ "CUST-WP-0008",
+ "CUST-WP-0009",
+ "CUST-WP-0010",
+ "CUST-WP-0010b",
+ "CUST-WP-0013",
+ "CUST-WP-0014",
+ "CUST-WP-0015",
+ "CUST-WP-0016",
+ "CUST-WP-0017",
+ "CUST-WP-0017-LEGACY",
+ "CUST-WP-0018",
+ "CUST-WP-0019",
+ "CUST-WP-0020",
+ "CUST-WP-0021",
+ "CUST-WP-0022",
+ "CUST-WP-0023",
+ "CUST-WP-0024",
+ "CUST-WP-0025",
+ "CUST-WP-0026",
+ "CUST-WP-0027",
+ "CUST-WP-0028",
+ "CUST-WP-0029",
+ "CUST-WP-0030",
+ "CUST-WP-0031",
+ "CUST-WP-0032",
+ "CUST-WP-0033",
+ "CUST-WP-0034",
+ "CUST-WP-0035",
+ "CUST-WP-0036",
+ "CUST-WP-0037",
+ "CUST-WP-0039",
+ "CUST-WP-0040",
+ "CUST-WP-0041",
+ "CUST-WP-0043",
+ "CUST-WP-0044",
+ "CUST-WP-0045",
+ "CUST-WP-0046",
+ "CUST-WP-0047",
+ "CUST-WP-0048",
+ "CUST-WP-0049",
+ "CUST-WP-0050",
+ "CUST-WP-0051",
+ "CUST-WP-0052",
+ "CUST-WP-0053",
+ "CUST-WP-0053-LEGACY",
+ "CUST-WP-0054",
+ "CUST-WP-0055",
+ "CUST-WP-0056",
+ "CUST-WP-0057",
+ "CUST-WP-0058",
+ "CUST-WP-0059",
+ "CUST-WP-0060",
+ "CUST-WP-0061",
+ "CUST-WP-0062",
+ "CUST-WP-0063",
+ "CUST-WP-0064",
+ "CUST-WP-0065",
+ "CUST-WP-0066",
+ "CUST-WP-0067",
+ "CUST-WP-0068",
+ "CUST-WP-0069",
+ "CUST-WP-0070",
+ "CUST-WP-0071",
+ "CUST-WP-0072",
+ "CUST-WP-0073",
+ "CUST-WP-0074",
+ "CUST-WP-0075",
+ "CUST-WP-0076",
+ "CUST-WP-0077",
+ "CUST-WP-0078",
+ "CUST-WP-0079",
+ "CUST-WP-0080",
+ "CUST-WP-0081",
+ "CUST-WP-ADHOC-2026-03-29",
+ "CUST-WP-ADHOC-2026-05-02",
+ "CUST-WP-ADHOC-2026-07-02",
+ "CUST-WP-ADHOC-2026-08-25",
+ "CUST-WP-ADHOC-2026-09-06",
+ "THE-WP-0001"
+ ],
+ "retired": [],
+ "released": [],
+ "created_tasks": [],
+ "updated_tasks": [],
+ "cancelled_tasks": [],
+ "created_edges": [],
+ "removed_edges": [],
+ "unresolved_edges": [],
+ "external_edges": [
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T05",
+ "target": "CUST-WP-0071-T04",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "20ddd4d7-d85d-5b95-bb0a-7b0ca8af76ca",
+ "to_workplan_id": null,
+ "to_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc"
+ },
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T03",
+ "target": "CUST-WP-0071-T01",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
+ "to_workplan_id": null,
+ "to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
+ },
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T03",
+ "target": "CUST-WP-0071-T02",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495",
+ "to_workplan_id": null,
+ "to_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68"
+ },
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T02",
+ "target": "CUST-WP-0071-T01",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
+ "to_workplan_id": null,
+ "to_task_id": "4ae8eb8d-f8c5-5256-ae50-c323cfd73d7a"
+ },
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T02",
+ "target": "RAPPS-WP-0014-T03",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "82192370-2fd7-5363-88d2-3c67889d3d68",
+ "to_workplan_id": null,
+ "to_task_id": "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1"
+ },
+ {
+ "workplan": "CUST-WP-0071",
+ "from": "CUST-WP-0071-T04",
+ "target": "CUST-WP-0071-T03",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "2249bddb-7524-5add-bd5c-c4163a6ca0f3",
+ "from_task_id": "944f98e2-7e42-5894-925b-752423f3a1cc",
+ "to_workplan_id": null,
+ "to_task_id": "6dc67558-eb1e-5bb6-a667-986f884dd495"
+ },
+ {
+ "workplan": "CUST-WP-0073",
+ "from": "CUST-WP-0073-T02",
+ "target": "GLAS-WP-0012",
+ "relationship_type": "blocks",
+ "from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
+ "from_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35",
+ "to_workplan_id": "170bf1ae-337f-5553-8d1e-03b07100e08f",
+ "to_task_id": null
+ },
+ {
+ "workplan": "CUST-WP-0073",
+ "from": "CUST-WP-0073-T04",
+ "target": "CUST-WP-0073-T02",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "a98a9f34-83b4-5c8c-8107-e05f7806d50d",
+ "from_task_id": "90725511-4e31-549f-b567-47feff1a9ca4",
+ "to_workplan_id": null,
+ "to_task_id": "4b88b0b7-dc7e-5612-aa5d-1a08f0530c35"
+ },
+ {
+ "workplan": "CUST-WP-0079",
+ "from": "CUST-WP-0079-T03",
+ "target": "ACTIVITY-WP-0047",
+ "relationship_type": "blocks",
+ "from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
+ "from_task_id": "1be0569c-221d-5925-a0ff-fd2af88e52b1",
+ "to_workplan_id": "ee97869b-dca2-54c9-aa18-0b479c244fb3",
+ "to_task_id": null
+ },
+ {
+ "workplan": "CUST-WP-0079",
+ "from": "CUST-WP-0079-T04",
+ "target": "PNEX-WP-0006",
+ "relationship_type": "blocks",
+ "from_workplan_id": "ab288f92-d364-5c24-9362-aee451f266c3",
+ "from_task_id": "5dd52156-09a8-5781-b288-99ae822f59a6",
+ "to_workplan_id": "774c5037-a3d0-5e65-893c-d18970acd66a",
+ "to_task_id": null
+ },
+ {
+ "workplan": "CUST-WP-0080",
+ "from": "CUST-WP-0080-T04",
+ "target": "CUST-WP-0080-T03",
+ "relationship_type": "starts_after",
+ "from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
+ "from_task_id": "82378b0f-7004-5c2f-9683-b356a1227576",
+ "to_workplan_id": null,
+ "to_task_id": "87637402-02f1-57c1-8fbc-4141598af8e3"
+ },
+ {
+ "workplan": "CUST-WP-0080",
+ "from": "CUST-WP-0080-T05",
+ "target": "RPF-WP-0056",
+ "relationship_type": "blocks",
+ "from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
+ "from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
+ "to_workplan_id": "31933d08-c877-5fda-aff9-6220c3705c92",
+ "to_task_id": null
+ },
+ {
+ "workplan": "CUST-WP-0080",
+ "from": "CUST-WP-0080-T05",
+ "target": "RAPP-PLAYWRIGHT-WP-0004",
+ "relationship_type": "blocks",
+ "from_workplan_id": "dfadb19b-445e-59c6-bcd6-48e87b912c52",
+ "from_task_id": "ed8b909a-23bf-5e5f-b600-e477fbae136b",
+ "to_workplan_id": "f9280c40-201e-59b5-a6f0-1aace8e0e2e2",
+ "to_task_id": null
+ }
+ ],
+ "refused": [],
+ "notes": [
+ "Preserved bound historical row and task UUIDs: CUST-WP-ADHOC-2026-08-25-RETIRED"
+ ]
+ }
+ }
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-projection.stderr.txt b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/guard-custodian-projection.stderr.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publication-commit.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publication-commit.json
new file mode 100644
index 00000000..a437c9ca
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publication-commit.json
@@ -0,0 +1,43 @@
+{
+ "status": "committed",
+ "commit": "ceaeef1ee79eeba37e7cacf73ba8aed3357a5e7c",
+ "exit_code": 0,
+ "paths": [
+ ".kaizen/agents/custodian-codex/supervision.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/approval-decision.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/check_live.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/coordination-receipt.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/execute.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/execution.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/fresh-headroom.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/fresh-server-dryrun.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/frozen-archives.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-after.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-before.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-post-pipeline.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/helm-return.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/job-observation.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/native-binding-probes.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/owner-bindings.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/owner-heads.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publish_checkpoint.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/run_maintenance.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/runtime-after.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/runtime-before.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/staging-cleanup.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/sync_custodian.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-consistency.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-projection.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-loaded-client.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-receiving-return.json",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verify_close.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verify_owner_bindings.py",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/watch_rollout.py",
+ "docs/netkingdom-target-implementation.md",
+ "workplans/CUST-WP-0080-netkingdom-security-blueprint-and-gap-closure.md",
+ "docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publication-inputs.json"
+ ],
+ "stdout": "[main ceaeef1] Record approved API guard delivery and completed owner returns\n 33 files changed, 2800 insertions(+), 9 deletions(-)\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/approval-decision.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/check_live.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/coordination-receipt.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/execute.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/execution.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/fresh-headroom.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/fresh-server-dryrun.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/frozen-archives.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-after.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-before.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/full-rows-post-pipeline.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/helm-return.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/job-observation.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/native-binding-probes.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/owner-bindings.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/owner-heads.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publication-inputs.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/publish_checkpoint.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/run_maintenance.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/runtime-after.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/runtime-before.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/staging-cleanup.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/sync_custodian.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-consistency.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-projection.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-loaded-client.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-receiving-return.json\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verify_close.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verify_owner_bindings.py\n create mode 100644 docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/watch_rollout.py\n",
+ "stderr": "{\"blocked_blob_ids\": [], \"scanned_blobs\": 32, \"scanner_sha256\": \"88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509\", \"scanner_version\": \"8.30.1\", \"scope\": \"changed-staged-blobs\", \"status\": \"clean\"}\n",
+ "unrelated_work_preserved": true
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/staging.json b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/staging.json
new file mode 100644
index 00000000..8e4bb08c
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/staging.json
@@ -0,0 +1,9 @@
+{
+ "status": "verified",
+ "directory": "/tmp/statehub-binding-guard-b5c0663d830f",
+ "uid": 1000,
+ "directory_mode": "700",
+ "file_mode": "600",
+ "archive_sha256": "b5c0663d830fc8d3b65080f237a0be8d01f8b2f7fec9dc33aeba91b1b6b281d4",
+ "remote_archive": "/tmp/statehub-binding-guard-b5c0663d830f/state-hub-0.1.0.tgz"
+}
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-consistency.stderr.txt b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-consistency.stderr.txt
new file mode 100644
index 00000000..3345563f
--- /dev/null
+++ b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-consistency.stderr.txt
@@ -0,0 +1 @@
+WARN: git commit failed for writeback: Generated-only commits rejected: staged
diff --git a/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-projection.stderr.txt b/docs/changes/netkingdom-target-2026-10-17/guard-successor-execution-r2/verified-client-cluster-projection.stderr.txt
new file mode 100644
index 00000000..e69de29b