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:
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)
}