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