taldir

Directory service to resolve wallet mailboxes by messenger addresses
Log | Files | Refs | Submodules | README | LICENSE

commit 3c06d1e8f899855627abedec82526058b226da55
parent 076986ae4f8c202ec22f3903d9bf12fbd8eda27f
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Fri, 19 Dec 2025 21:51:49 +0900

oidc: initial implementation; token validation incomplete

Diffstat:
Mgo.mod | 1+
Mgo.sum | 2++
Mpkg/taldir/command_validator.go | 16++++++----------
Mpkg/taldir/oidc_validator.go | 52+++++++++++++++++++++++++++++++++++++---------------
Mpkg/taldir/taldir.go | 102++++++++++++++++++++++++++++++++++++++++++-------------------------------------
5 files changed, 100 insertions(+), 73 deletions(-)

diff --git a/go.mod b/go.mod @@ -17,6 +17,7 @@ require ( require ( github.com/BurntSushi/toml v1.5.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.6 // indirect diff --git a/go.sum b/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/pkg/taldir/command_validator.go b/pkg/taldir/command_validator.go @@ -43,27 +43,23 @@ type CommandValidator struct { landingPageTpl *template.Template } -func (t *CommandValidator) LandingPageTpl() *template.Template { +func (t CommandValidator) LandingPageTpl() *template.Template { return t.landingPageTpl } -func (t *CommandValidator) Type() ValidatorType { +func (t CommandValidator) Type() ValidatorType { return ValidatorTypeCommand } -func (t *CommandValidator) Name() string { +func (t CommandValidator) Name() string { return t.name } -func (t *CommandValidator) IsEnabled() bool { - return t.config.Ini.Section("taldir-validator-" + t.name).Key("enabled").MustBool(false) -} - -func (t *CommandValidator) ChallengeFee() string { +func (t CommandValidator) ChallengeFee() string { return t.config.Ini.Section("taldir-validator-" + t.name).Key("challenge_fee").MustString("KUDOS:0") } -func (t *CommandValidator) IsAliasValid(alias string) (err error) { +func (t CommandValidator) IsAliasValid(alias string) (err error) { if t.validAliasRegex != "" { matched, _ := regexp.MatchString(t.validAliasRegex, alias) if !matched { @@ -73,7 +69,7 @@ func (t *CommandValidator) IsAliasValid(alias string) (err error) { return } -func (t *CommandValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) { +func (t CommandValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) { path, err := exec.LookPath(t.command) if err != nil { return "", err diff --git a/pkg/taldir/oidc_validator.go b/pkg/taldir/oidc_validator.go @@ -22,6 +22,7 @@ import ( "crypto/rand" "encoding/json" "fmt" + "github.com/golang-jwt/jwt/v5" "html/template" "net/http" "net/url" @@ -29,6 +30,15 @@ import ( "strings" ) +type AuthorizationsState struct { + + // Alias + alias string + + // Challenge + challenge string +} + type OidcValidator struct { // Name @@ -63,13 +73,16 @@ type OidcValidator struct { // State object // Maps states to challenge - authorizationsState map[string][]string + authorizationsState map[string]*AuthorizationsState } type OidcTokenResponse struct { // AccessToken AccessToken string `json:"access_token"` + // IDToken (OIDC only) + IDToken string `json:"id_token"` + // Token type TokenType string `json:"token_type"` @@ -77,27 +90,23 @@ type OidcTokenResponse struct { ExpiresIn int `json:"expires_in"` } -func (t *OidcValidator) LandingPageTpl() *template.Template { +func (t OidcValidator) LandingPageTpl() *template.Template { return t.landingPageTpl } -func (t *OidcValidator) Type() ValidatorType { +func (t OidcValidator) Type() ValidatorType { return ValidatorTypeOIDC } -func (t *OidcValidator) Name() string { +func (t OidcValidator) Name() string { return t.name } -func (t *OidcValidator) IsEnabled() bool { - return t.config.Ini.Section("taldir-validator-" + t.name).Key("enabled").MustBool(false) -} - -func (t *OidcValidator) ChallengeFee() string { +func (t OidcValidator) ChallengeFee() string { return t.config.Ini.Section("taldir-validator-" + t.name).Key("challenge_fee").MustString("KUDOS:0") } -func (t *OidcValidator) IsAliasValid(alias string) (err error) { +func (t OidcValidator) IsAliasValid(alias string) (err error) { if t.validAliasRegex != "" { matched, _ := regexp.MatchString(t.validAliasRegex, alias) if !matched { @@ -107,7 +116,7 @@ func (t *OidcValidator) IsAliasValid(alias string) (err error) { return } -func (t *OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, error) { +func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, error) { // Process authorization code state := r.URL.Query().Get("state") if state == "" { @@ -144,13 +153,26 @@ func (t *OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, er if err != nil { return "", "", fmt.Errorf("unable to parse token response") } - // TODO check against state, return hAlias and challenge - return "FIXME", "FIXME", nil + token, err := jwt.Parse(tokenResponse.IDToken, func(token *jwt.Token) (any, error) { + return []byte("my_secret_key"), nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + if err != nil { + return "", "", fmt.Errorf("unable to parse JWT token from response") + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok { + if t.authorizationsState[state].alias != claims["sub"] { + return "", "", fmt.Errorf("subject in ID token (%s) does not match state (%s)", claims["sub"], t.authorizationsState[state].alias) + } + return t.authorizationsState[state].alias, t.authorizationsState[state].challenge, nil + } else { + return "", "", fmt.Errorf("unable to parse claims from JWT token") + } } -func (t *OidcValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) { +func (t OidcValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) { state := rand.Text() - t.authorizationsState[state] = []string{alias, challenge} + t.authorizationsState[state] = &AuthorizationsState{alias, challenge} redirectURI := fmt.Sprintf("%s?response_type=code&redirect_uri=%s&client_id=%s&scope=%s&state=%s", t.authorizationEndpoint, t.redirectURI, t.clientID, t.scope, state) return redirectURI, nil } diff --git a/pkg/taldir/taldir.go b/pkg/taldir/taldir.go @@ -60,7 +60,7 @@ type Taldir struct { Router *mux.Router // The main DB handle - Db *gorm.DB + DB *gorm.DB // Our configuration from the config.json Cfg TaldirConfig @@ -198,7 +198,7 @@ type Validation struct { Duration int64 `json:"duration"` // Target URI to associate with this alias - TargetUri string `json:"target_uri"` + TargetURI string `json:"target_uri"` // The activation code sent to the client Challenge string `json:"-"` @@ -282,7 +282,7 @@ func (t *Taldir) isPMSValid(pms string) (err error) { if t.ValidPMSRegex != "" { matched, _ := regexp.MatchString(t.ValidPMSRegex, pms) if !matched { - return fmt.Errorf("Payment System Address `%s' invalid", pms) // TODO i18n + return fmt.Errorf("payment System Address `%s' invalid", pms) // TODO i18n } } return @@ -295,7 +295,7 @@ func (t *Taldir) getSingleEntry(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) var entry Entry hsAlias := saltHAlias(vars["h_alias"], t.Salt) - var err = t.Db.First(&entry, "hs_alias = ?", hsAlias).Error + var err = t.DB.First(&entry, "hs_alias = ?", hsAlias).Error if err == nil { w.Header().Set("Content-Type", "application/json") resp, _ := json.Marshal(entry) @@ -329,14 +329,14 @@ func (t *Taldir) disseminateStart(e Entry) { // Disseminate all entries func (t *Taldir) disseminateEntries() error { var entries []Entry - t.Db.Where("1 = 1").Find(&entries) + t.DB.Where("1 = 1").Find(&entries) for _, e := range entries { t.disseminateStart(e) } return nil } -// Hashes the alias with its type in a prefix-free fashion +// HashAlias hashes the alias with its type in a prefix-free fashion // SHA512(len(atype||alias)||atype||alias) func (t *Taldir) HashAlias(atype string, alias string) []byte { h := sha512.New() @@ -378,7 +378,7 @@ func (t *Taldir) validationRequest(w http.ResponseWriter, r *http.Request) { w.Write(resp) return } - err = t.Db.First(&validation, "h_alias = ?", vars["h_alias"]).Error + err = t.DB.First(&validation, "h_alias = ?", vars["h_alias"]).Error if err != nil { w.WriteHeader(http.StatusNotFound) return @@ -394,42 +394,42 @@ func (t *Taldir) validationRequest(w http.ResponseWriter, r *http.Request) { validation.LastSolutionTimeframeStart = time.Now() validation.SolutionAttemptCount = 1 } - t.Db.Save(&validation) - expectedSolution := util.GenerateSolution(validation.TargetUri, validation.Challenge) + t.DB.Save(&validation) + expectedSolution := util.GenerateSolution(validation.TargetURI, validation.Challenge) t.Logger.Logf(LogDebug, "Expected solution: `%s', given: `%s'\n", expectedSolution, confirm.Solution) if confirm.Solution != expectedSolution { w.WriteHeader(http.StatusForbidden) return } - err = t.Db.Delete(&validation).Error + err = t.DB.Delete(&validation).Error if err != nil { t.Logger.Logf(LogError, "Error deleting validation") w.WriteHeader(http.StatusInternalServerError) return } entry.HsAlias = saltHAlias(validation.HAlias, t.Salt) - entry.TargetURI = validation.TargetUri + entry.TargetURI = validation.TargetURI tmpDuration := (entry.Duration.Microseconds() + validation.Duration) * 1000 entry.Duration = time.Duration(tmpDuration) - err = t.Db.First(&entry, "hs_alias = ?", entry.HsAlias).Error + err = t.DB.First(&entry, "hs_alias = ?", entry.HsAlias).Error if err == nil { - if validation.TargetUri == "" { + if validation.TargetURI == "" { t.Logger.Logf(LogDebug, "Deleted entry for '%s´\n", entry.HsAlias) - err = t.Db.Delete(&entry).Error + err = t.DB.Delete(&entry).Error if err != nil { w.WriteHeader(http.StatusInternalServerError) return } t.disseminateStop(entry) } else { - t.Db.Save(&entry) + t.DB.Save(&entry) t.disseminateStart(entry) } } else { - if validation.TargetUri == "" { + if validation.TargetURI == "" { t.Logger.Logf(LogWarning, "Validated a deletion request but no entry found for `%s'\n", entry.HsAlias) } else { - err = t.Db.Create(&entry).Error + err = t.DB.Create(&entry).Error if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -441,7 +441,7 @@ func (t *Taldir) validationRequest(w http.ResponseWriter, r *http.Request) { func (t *Taldir) isRateLimited(hAlias string) (bool, error) { var validations []Validation - res := t.Db.Where("h_alias = ?", hAlias).Find(&validations) + res := t.DB.Where("h_alias = ?", hAlias).Find(&validations) // NOTE: Check rate limit if res.Error == nil { // Limit re-initiation attempts to ValidationInitiationMax times @@ -503,7 +503,7 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { validation.HAlias = hAlias validation.ValidatorName = validator.Name() hsAlias := saltHAlias(validation.HAlias, t.Salt) - err = t.Db.First(&entry, "hs_alias = ?", hsAlias).Error + err = t.DB.First(&entry, "hs_alias = ?", hsAlias).Error // Round to the nearest multiple of a month reqDuration := time.Duration(req.Duration * 1000) reqDuration = reqDuration.Round(monthDuration) @@ -516,7 +516,7 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { // Nothing changed. Return validity w.WriteHeader(http.StatusOK) w.Header().Set("Content-Type", "application/json") - w.Write([]byte(fmt.Sprintf("{\"valid_for\": %d}", time.Until(entryValidity).Microseconds()))) + w.Write(fmt.Appendf(make([]byte, 0), "{\"valid_for\": %d}", time.Until(entryValidity).Microseconds())) return } } @@ -534,16 +534,16 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { } jsonResp, _ := json.Marshal(rlResponse) w.Write(jsonResp) - t.Db.Delete(&validation) + t.DB.Delete(&validation) return } - err = t.Db.First(&validation, "h_alias = ? AND target_uri = ? AND duration = ?", + err = t.DB.First(&validation, "h_alias = ? AND target_uri = ? AND duration = ?", hAlias, req.TargetURI, reqDuration).Error validationExists := (nil == err) // FIXME: Always set new challenge? validation.Challenge = util.GenerateChallenge(t.ChallengeBytes) if !validationExists { - validation.TargetUri = req.TargetURI + validation.TargetURI = req.TargetURI validation.SolutionAttemptCount = 0 validation.LastSolutionTimeframeStart = time.Now() validation.Duration = reqDuration.Microseconds() @@ -578,20 +578,19 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { // FIXME: We probably need to handle the return code here (see gns registrar for how) _, _, payto, paytoErr := t.Merchant.IsOrderPaid(validation.OrderID) if paytoErr != nil { - fmt.Println(paytoErr) w.WriteHeader(http.StatusInternalServerError) - t.Logger.Logf(LogError, "%s\n", err.Error()) + t.Logger.Logf(LogError, "%s\n", paytoErr) return } if len(payto) != 0 { - t.Db.Save(&validation) + t.DB.Save(&validation) w.WriteHeader(http.StatusPaymentRequired) w.Header().Set("Taler", payto) // FIXME no idea what to do with this. return } // In this case, this order was paid } - err = t.Db.Save(&validation).Error + err = t.DB.Save(&validation).Error if err != nil { t.Logger.Logf(LogError, "%s\n", err.Error()) w.WriteHeader(500) @@ -603,7 +602,7 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { redirectionLink, err := validator.RegistrationStart(topic, link, message, req.Alias, validation.Challenge) if err != nil { t.Logger.Logf(LogError, "%s\n", err.Error()) - t.Db.Delete(&validation) + t.DB.Delete(&validation) w.WriteHeader(500) return } @@ -667,7 +666,7 @@ func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) { var png []byte var validation Validation - err := t.Db.First(&validation, "h_alias = ?", vars["h_alias"]).Error + err := t.DB.First(&validation, "h_alias = ?", vars["h_alias"]).Error w.Header().Set("Content-Type", "text/html; charset=utf-8") if err != nil { // This validation does not exist. @@ -716,17 +715,17 @@ func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) { } t.ValidationTpl.Execute(w, fullData) } else { - expectedSolution := util.GenerateSolution(validation.TargetUri, validation.Challenge) + expectedSolution := util.GenerateSolution(validation.TargetURI, validation.Challenge) confirmDeletionOrRegistration := "" - if validation.TargetUri == "" { + if validation.TargetURI == "" { confirmDeletionOrRegistration = t.I18n.GetLocale(r).GetMessage("confirmDelete", alias) } else { - confirmDeletionOrRegistration = t.I18n.GetLocale(r).GetMessage("confirmReg", alias, validation.TargetUri) + confirmDeletionOrRegistration = t.I18n.GetLocale(r).GetMessage("confirmReg", alias, validation.TargetURI) } fullData := map[string]any{ "version": t.Cfg.Version, "error": r.URL.Query().Get("error"), - "target_uri": template.URL(validation.TargetUri), + "target_uri": template.URL(validation.TargetURI), "alias": template.URL(alias), "halias": template.URL(validation.HAlias), "solution": template.URL(expectedSolution), @@ -740,8 +739,8 @@ func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) { // ClearDatabase nukes the database (for tests) func (t *Taldir) ClearDatabase() { - t.Db.Where("1 = 1").Delete(&Entry{}) - t.Db.Where("1 = 1").Delete(&Validation{}) + t.DB.Where("1 = 1").Delete(&Entry{}) + t.DB.Where("1 = 1").Delete(&Validation{}) } func (t *Taldir) termsResponse(w http.ResponseWriter, r *http.Request) { @@ -842,7 +841,7 @@ func (t *Taldir) typeLookupResultPage(w http.ResponseWriter, r *http.Request) { hAliasBin := t.HashAlias(val.Name(), r.URL.Query().Get("alias")) hAlias := util.Base32CrockfordEncode(hAliasBin[:]) hsAlias := saltHAlias(hAlias, t.Salt) - err = t.Db.First(&entry, "hs_alias = ?", hsAlias).Error + err = t.DB.First(&entry, "hs_alias = ?", hsAlias).Error if err != nil { t.Logger.Logf(LogError, "`%s` not found.\n", hAlias) } else { @@ -990,26 +989,33 @@ func (t *Taldir) Initialize(cfg TaldirConfig) { if !strings.HasPrefix(sec.Name(), "taldir-validator-") { continue } - if !sec.HasKey("enabled") { - t.Logger.Logf(LogWarning, "`enabled` key in section `[%s]` not found, disabling validator.\n", sec.Name()) + vname := strings.TrimPrefix(sec.Name(), "taldir-validator-") + if !sec.Key("enabled").MustBool(false) { + t.Logger.Logf(LogWarning, "`Validator `%s' disabled.\n", vname) continue } if !sec.HasKey("type") { t.Logger.Logf(LogWarning, "`type` key in section `[%s]` not found, disabling validator.\n", sec.Name()) continue } - vname := strings.TrimPrefix(sec.Name(), "taldir-validator-") vlandingPageTplFile := sec.Key("registration_page").MustString(t.getFileName("web/templates/landing_" + vname + ".html")) vlandingPageTpl, err := template.ParseFiles(vlandingPageTplFile, navTplFile, footerTplFile) if err != nil { t.Logger.Logf(LogWarning, "`%s` template not found, disabling validator `%s`.\n", vlandingPageTplFile, vname) continue } - v := makeCommandValidator(&cfg, vname, vlandingPageTpl) - if v.IsEnabled() { - t.Logger.Logf(LogInfo, "`%s` validator disabled.\n", vname) - t.Validators[vname] = &v + var v Validator + vtype := sec.Key("type").MustString("") + switch vtype { + case string(ValidatorTypeCommand): + v = Validator(makeCommandValidator(&cfg, vname, vlandingPageTpl)) + case string(ValidatorTypeOIDC): + v = makeOidcValidator(&cfg, vname, vlandingPageTpl) + default: + t.Logger.Logf(LogWarning, "`%s` type unknown, disabling validator `%s`.\n", vtype, vname) + continue } + t.Validators[vname] = v t.Logger.Logf(LogDebug, "`%s` validator enabled.\n", vname) } t.Logger.Logf(LogDebug, "Found %d validators.\n", len(t.Validators)) @@ -1045,16 +1051,16 @@ func (t *Taldir) Initialize(cfg TaldirConfig) { if err != nil { panic(err) } - t.Db = _db - if err := t.Db.AutoMigrate(&Entry{}); err != nil { + t.DB = _db + if err := t.DB.AutoMigrate(&Entry{}); err != nil { panic(err) } - if err := t.Db.AutoMigrate(&Validation{}); err != nil { + if err := t.DB.AutoMigrate(&Validation{}); err != nil { panic(err) } if cfg.Ini.Section("taldir").Key("purge_mappings_on_startup_dangerous").MustBool(false) { t.Logger.Logf(LogWarning, "DANGER Purging mappings!") - tx := t.Db.Where("1 = 1").Delete(&Entry{}) + tx := t.DB.Where("1 = 1").Delete(&Entry{}) t.Logger.Logf(LogDebug, "Deleted %d entries.\n", tx.RowsAffected) } // Clean up validations @@ -1066,7 +1072,7 @@ func (t *Taldir) Initialize(cfg TaldirConfig) { } go func() { for { - tx := t.Db.Where("created_at < ?", time.Now().Add(-validationExp)).Delete(&Validation{}) + tx := t.DB.Where("created_at < ?", time.Now().Add(-validationExp)).Delete(&Validation{}) t.Logger.Logf(LogInfo, "Cleaned up %d stale validations.\n", tx.RowsAffected) time.Sleep(validationExp) }