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