taldir

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

commit 3f833d11deaf1abda39fe04aa77ff8b7b067d64f
parent ae5cd44be94fc29d817800a0b240d97826c6c4e3
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Tue, 23 Dec 2025 12:11:36 +0900

First successful OIDC validation flow tested

Diffstat:
Mcmd/taldir-server/main_test.go | 32++++++++++++++++----------------
Mgo.mod | 2+-
Mgo.sum | 4++--
Mlocales/de-DE/taldir.yml | 1+
Mlocales/en-US/taldir.yml | 1+
Mpkg/taldir/oidc_validator.go | 61+++++++++++++++++++++++++++++++++++++++++++------------------
Mpkg/taldir/taldir.go | 16+++++++++-------
Mtaldir.conf.example | 13+++++++++++++
Aweb/templates/landing_oidctest.html | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 146 insertions(+), 44 deletions(-)

diff --git a/cmd/taldir-server/main_test.go b/cmd/taldir-server/main_test.go @@ -147,15 +147,15 @@ func TestMain(m *testing.M) { } func getHAlias(alias string) string { - ha := t.HashAlias("test", alias) + ha := taldir.HashAlias("test", alias) return util.Base32CrockfordEncode(ha) } func TestNoEntry(s *testing.T) { t.ClearDatabase() - h_alias := getHAlias("jdoe@example.com") - req, _ := http.NewRequest("GET", "/"+h_alias, nil) + hAlias := getHAlias("jdoe@example.com") + req, _ := http.NewRequest("GET", "/"+hAlias, nil) response := executeRequest(req) if http.StatusNotFound != response.Code { @@ -186,11 +186,11 @@ func TestRegisterRequest(s *testing.T) { if err != nil { s.Errorf("Error reading validation code file contents!\n") } - h_alias := getHAlias("abc@test") + hAlias := getHAlias("abc@test") trimCode := strings.Trim(string(code), " \r\n") solution := util.GenerateSolution("myinbox@xyz", trimCode) solutionJSON := "{\"solution\": \"" + solution + "\"}" - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusNoContent != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusNoContent, response.Code) @@ -220,9 +220,9 @@ func TestRegisterQRPageRequest(s *testing.T) { if err != nil { s.Errorf("Error reading validation code file contents!\n") } - h_alias := getHAlias("abc@test") + hAlias := getHAlias("abc@test") trimCode := strings.Trim(string(code), " \r\n") - req, _ = http.NewRequest("GET", "/register/"+h_alias+"/"+trimCode+"?alias="+"abc@test", nil) + req, _ = http.NewRequest("GET", "/register/"+hAlias+"/"+trimCode+"?alias="+"abc@test", nil) response = executeRequest(req) if http.StatusOK != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusOK, response.Code) @@ -246,11 +246,11 @@ func TestReRegisterRequest(s *testing.T) { if err != nil { s.Errorf("Error reading validation code file contents!\n") } - h_alias := getHAlias("abc@test") + hAlias := getHAlias("abc@test") trimCode := strings.Trim(string(code), " \r\n") solution := util.GenerateSolution("myinbox@xyz", trimCode) solutionJSON := "{\"solution\": \"" + solution + "\"}" - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusNoContent != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusNoContent, response.Code) @@ -303,25 +303,25 @@ func TestSolutionRequestTooMany(s *testing.T) { if http.StatusAccepted != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusAccepted, response.Code) } - h_alias := getHAlias("abc@test") + hAlias := getHAlias("abc@test") solution := util.GenerateSolution("myinbox@xyz", "wrongSolution") solutionJSON := "{\"solution\": \"" + solution + "\"}" - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusForbidden != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusForbidden, response.Code) } - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusForbidden != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusForbidden, response.Code) } - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusForbidden != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusForbidden, response.Code) } - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusTooManyRequests != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusTooManyRequests, response.Code) @@ -346,11 +346,11 @@ func TestRegisterRequestWrongTargetUri(s *testing.T) { if err != nil { s.Errorf("Error reading validation code file contents!\n") } - h_alias := getHAlias("abc@test") + hAlias := getHAlias("abc@test") trimCode := strings.Trim(string(code), " \r\n") solution := util.GenerateSolution("myinox@xyz", trimCode) solutionJSON := "{\"solution\": \"" + solution + "\"}" - req, _ = http.NewRequest("POST", "/"+h_alias, bytes.NewBuffer([]byte(solutionJSON))) + req, _ = http.NewRequest("POST", "/"+hAlias, bytes.NewBuffer([]byte(solutionJSON))) response = executeRequest(req) if http.StatusForbidden != response.Code { s.Errorf("Expected response code %d. Got %d\n", http.StatusForbidden, response.Code) diff --git a/go.mod b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/gertd/go-pluralize v0.2.1 + github.com/go-jose/go-jose/v4 v4.1.3 github.com/gorilla/mux v1.8.1 github.com/kataras/i18n v0.0.8 github.com/schanzen/taler-go v1.1.1 @@ -17,7 +18,6 @@ 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,8 +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/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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/locales/de-DE/taldir.yml b/locales/de-DE/taldir.yml @@ -1,5 +1,6 @@ phone: "Telefonnummer" email: "E-Mail" +oidctest: "OIDC Test" error: "Es ist ein Fehler aufgetreten!" title: "Alias Registration und Suche" selectAliasToLookup: "Bitte wähle einen Alias-Typ den Du suchen möchtest:" diff --git a/locales/en-US/taldir.yml b/locales/en-US/taldir.yml @@ -1,5 +1,6 @@ phone: "Phone" email: "E-mail" +oidctest: "OIDC Test" error: "An error occurred!" title: "Alias Registration and Lookup" selectAliasToLookup: "Select a type of alias that you want to look up:" diff --git a/pkg/taldir/oidc_validator.go b/pkg/taldir/oidc_validator.go @@ -22,12 +22,14 @@ import ( "crypto/rand" "encoding/json" "fmt" - "github.com/golang-jwt/jwt/v5" "html/template" "net/http" "net/url" "regexp" "strings" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" ) type AuthorizationsState struct { @@ -62,6 +64,9 @@ type OidcValidator struct { // Token endpoint tokenEndpoint string + // JWKS endpoint + jwksEndpoint string + // OIDC authorization endpoint authorizationEndpoint string @@ -117,6 +122,7 @@ func (t OidcValidator) IsAliasValid(alias string) (err error) { } func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, error) { + var jwkSet jose.JSONWebKeySet // Process authorization code state := r.URL.Query().Get("state") if state == "" { @@ -125,6 +131,22 @@ func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, err if t.authorizationsState[state] == nil { return "", "", fmt.Errorf("state invalid") } + req, err := http.NewRequest("GET", t.jwksEndpoint, nil) + if err != nil { + return "", "", fmt.Errorf("failed to create JWKS request") + } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to execute JWKS request") + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("unexpected response code %d", resp.StatusCode) + } + err = json.NewDecoder(resp.Body).Decode(&jwkSet) + if err != nil { + return "", "", fmt.Errorf("unable to parse JWKS response") + } // TODO process authorization code code := r.URL.Query().Get("code") data := url.Values{} @@ -133,14 +155,14 @@ func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, err data.Set("redirect_uri", t.redirectURI) data.Set("code", code) - req, err := http.NewRequest("POST", t.tokenEndpoint, strings.NewReader(data.Encode())) + req, err = http.NewRequest("POST", t.tokenEndpoint, strings.NewReader(data.Encode())) if err != nil { return "", "", fmt.Errorf("failed to create token request") } req.SetBasicAuth(t.clientID, t.clientSecret) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - client := &http.Client{} - resp, err := client.Do(req) + client = &http.Client{} + resp, err = client.Do(req) if err != nil { return "", "", fmt.Errorf("failed to execute token request") } @@ -148,26 +170,27 @@ func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, err return "", "", fmt.Errorf("unexpected response code %d", resp.StatusCode) } // TODO unmarshal JSON, retrieve/check against state, return hAlias and challenge + var tokenResponse OidcTokenResponse - err = json.NewDecoder(r.Body).Decode(&tokenResponse) + err = json.NewDecoder(resp.Body).Decode(&tokenResponse) if err != nil { - return "", "", fmt.Errorf("unable to parse token response") + return "", "", fmt.Errorf("unable to parse token response: %v", err) } - token, err := jwt.Parse(tokenResponse.IDToken, func(token *jwt.Token) (any, error) { - return []byte("my_secret_key"), nil - }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + token, err := jwt.ParseSigned(tokenResponse.IDToken, []jose.SignatureAlgorithm{jose.RS256}) if err != nil { - return "", "", fmt.Errorf("unable to parse JWT token from response") + return "", "", fmt.Errorf("unable to parse token: %v", err) } - 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") + // Verify and extract claims using the JWKS + var claims jwt.Claims + err = token.Claims(jwkSet, &claims) + if err != nil { + return "", "", fmt.Errorf("unable to validate token: %v", err) + } + if claims.Subject != t.authorizationsState[state].alias { + return "", "", fmt.Errorf("subject in ID token (%s) does not match state (%s)", claims.Subject, t.authorizationsState[state].alias) } + return claims.Subject, t.authorizationsState[state].challenge, nil } func (t OidcValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) { @@ -181,7 +204,7 @@ func makeOidcValidator(cfg *TaldirConfig, name string, landingPageTpl *template. mainSec := cfg.Ini.Section("taldir") baseURL := mainSec.Key("base_url").MustString("") // FIXME escape URI? - redirectURI := fmt.Sprintf("%s/%s", baseURL, name) + redirectURI := fmt.Sprintf("%s/oidc_validator/%s", baseURL, name) sec := cfg.Ini.Section("taldir-validator-" + name) return OidcValidator{ name: name, @@ -191,8 +214,10 @@ func makeOidcValidator(cfg *TaldirConfig, name string, landingPageTpl *template. clientSecret: sec.Key("client_secret").MustString(""), scope: sec.Key("scope").MustString("openid"), tokenEndpoint: sec.Key("token_endpoint").MustString(""), + jwksEndpoint: sec.Key("jwks_endpoint").MustString(""), authorizationEndpoint: sec.Key("authorization_endpoint").MustString(""), validAliasRegex: sec.Key("valid_alias_regex").MustString(""), redirectURI: redirectURI, + authorizationsState: make(map[string]*AuthorizationsState, 0), } } diff --git a/pkg/taldir/taldir.go b/pkg/taldir/taldir.go @@ -338,7 +338,7 @@ func (t *Taldir) disseminateEntries() error { // 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 { +func HashAlias(atype string, alias string) []byte { h := sha512.New() b := make([]byte, 4) binary.BigEndian.PutUint32(b, uint32(len(atype)+len(alias))) @@ -498,7 +498,7 @@ func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request) { // Setup validation object. Retrieve object from DB if it already // exists. - hAliasBin := t.HashAlias(validator.Name(), req.Alias) + hAliasBin := HashAlias(validator.Name(), req.Alias) hAlias := util.Base32CrockfordEncode(hAliasBin) validation.HAlias = hAlias validation.ValidatorName = validator.Name() @@ -625,14 +625,16 @@ func (t *Taldir) oidcValidatorResponse(w http.ResponseWriter, r *http.Request) { if name != vars["validator"] { continue } - oidcValidator := validator.(*OidcValidator) - hAlias, challenge, err := oidcValidator.ProcessOidcCallback(r) + oidcValidator := validator.(OidcValidator) + alias, challenge, err := oidcValidator.ProcessOidcCallback(r) if err != nil { t.Logger.Logf(LogError, "%s\n", err.Error()) w.WriteHeader(http.StatusInternalServerError) return } - http.Redirect(w, r, fmt.Sprintf("/register/%s/%s", hAlias, challenge), http.StatusSeeOther) + ha := HashAlias(validator.Name(), alias) + hAlias := util.Base32CrockfordEncode(ha) + http.Redirect(w, r, fmt.Sprintf("/register/%s/%s?alias=%s", hAlias, challenge, alias), http.StatusSeeOther) return } w.WriteHeader(http.StatusNotFound) @@ -687,7 +689,7 @@ func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) { } // FIXME requires a prefix-free encoding - hAliasBin := t.HashAlias(validation.ValidatorName, alias) + hAliasBin := HashAlias(validation.ValidatorName, alias) expectedHAlias := util.Base32CrockfordEncode(hAliasBin) if expectedHAlias != validation.HAlias { @@ -838,7 +840,7 @@ func (t *Taldir) typeLookupResultPage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/landing/"+val.Name()+"?error=%s", emsg), http.StatusSeeOther) return } else { - hAliasBin := t.HashAlias(val.Name(), r.URL.Query().Get("alias")) + hAliasBin := 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 diff --git a/taldir.conf.example b/taldir.conf.example @@ -32,3 +32,16 @@ valid_alias_regex='^\S+@\S+\.\S+$' type = command enabled = true command = test + +[taldir-validator-oidctest] +type = oidc +enabled = true +jwks_endpoint=http://127.0.0.1:9400/jwks +authorization_endpoint=http://127.0.0.1:9400/oauth2/authorize +token_endpoint=http://127.0.0.1:9400/oauth2/token +client_id=test +client_secret=testsecret +scope=openid email +valid_alias_regex='^\S+@\S+\.\S+$' + + diff --git a/web/templates/landing_oidctest.html b/web/templates/landing_oidctest.html @@ -0,0 +1,60 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <!-- Required meta tags --> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <link href="/css/bootstrap.min.css" rel="stylesheet"> + <link href="/css/style.css" rel="stylesheet"> + <link href="/fontawesome/css/fontawesome.css" rel="stylesheet" /> + <link href="/fontawesome/css/solid.css" rel="stylesheet" /> + <title>{{ call .tr "title" }}</title> + </head> + <body> + {{ template "nav.html" . }} + <div class="container pt-5"> + {{if .error}} + <div class="container pt-5"> + <div id="ebanner" class="alert alert-danger" role="alert"> + <h4 class="alert-heading">{{ call .tr "error" }}</h4> + <hr> + <p class="mb-0">{{.error}}</p> + </div> + </div> + {{end}} + <div class="row"> + <div class="col-2"> + <ul class="timeline"> + <li class="inprogress">{{ call .tr "lookup" }}</li> + <li>{{ call .tr "registerOrModify" }}</li> + <li>{{ call .tr "confirm" }}</li> + </ul> + </div> + <div class="col-8"> + <div class="card"> + <div class="card-body"> + <h4 class="card-title">{{ call .tr "lookupEmail" }}</h4> + <hr> + <p class="card-text">{{ call .tr "lookupEmailDescription" }}</p> + </div> + <form method="get" action="/lookup/oidctest"> + <div class="row"> + <div class="col-lg-6 offset-lg-3 text-center"> + <div class="input-group mb-3"> + <div class="form-floating"> + <input type="email" name="alias" id="floatingInput" class="form-control" placeholder="jdoe@example.com" aria-label="Default" aria-describedby="inputGroup-sizing-default" data-sb-validations="required,email"> + <label for="floatingInput">{{ call .tr "email" }}</label> + </div> + <div class="invalid-feedback text-white" data-sb-feedback="emailAddressBelow:required">Email Address is required.</div> + <div class="invalid-feedback text-white" data-sb-feedback="emailAddressBelow:email">Email Address is not valid.</div> + <input class="input-group-text btn btn-outline-primary" type="submit" value="{{ call .tr "lookup" }}"> + </div> + </div> + </div> + </form> + </div> + </div> + </div> + {{ template "footer.html" . }} + </body> +</html>