taldir

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

oidc_validator.go (6565B)


      1 // This file is part of tdir, the Taler Directory implementation.
      2 // Copyright (C) 2025 Martin Schanzenbach
      3 //
      4 // Taldir is free software: you can redistribute it and/or modify it
      5 // under the terms of the GNU Affero General Public License as published
      6 // by the Free Software Foundation, either version 3 of the License,
      7 // or (at your option) any later version.
      8 //
      9 // Taldir is distributed in the hope that it will be useful, but
     10 // WITHOUT ANY WARRANTY; without even the implied warranty of
     11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     12 // Affero General Public License for more details.
     13 //
     14 // You should have received a copy of the GNU Affero General Public License
     15 // along with this program.  If not, see <http://www.gnu.org/licenses/>.
     16 //
     17 // SPDX-License-Identifier: AGPL3.0-or-later
     18 
     19 package taldir
     20 
     21 import (
     22 	"crypto/rand"
     23 	"encoding/json"
     24 	"fmt"
     25 	"html/template"
     26 	"net/http"
     27 	"net/url"
     28 	"regexp"
     29 	"strings"
     30 )
     31 
     32 type AuthorizationsState struct {
     33 
     34 	// Alias
     35 	alias string
     36 
     37 	// Challenge
     38 	challenge string
     39 }
     40 
     41 type RelevantUserClaims struct {
     42 	// Subject
     43 	Sub string
     44 }
     45 
     46 type OidcValidator struct {
     47 
     48 	// Name
     49 	name string
     50 
     51 	// Config
     52 	config *TaldirConfig
     53 
     54 	// Client ID
     55 	clientID string
     56 
     57 	// Client secret
     58 	clientSecret string
     59 
     60 	// Scope(s)
     61 	scope string
     62 
     63 	// Claim that is the alias
     64 	aliasClaimName string
     65 
     66 	// Redirect URI
     67 	redirectURI string
     68 
     69 	// Userinfo endpoint
     70 	userinfoEndpoint string
     71 
     72 	// Token endpoint
     73 	tokenEndpoint string
     74 
     75 	// OIDC authorization endpoint
     76 	authorizationEndpoint string
     77 
     78 	// registration/lookup page
     79 	landingPageTpl *template.Template
     80 
     81 	// Validator alias regex
     82 	validAliasRegex string
     83 
     84 	// State object
     85 	// Maps states to challenge
     86 	authorizationsState map[string]*AuthorizationsState
     87 }
     88 
     89 type OAuthTokenResponse struct {
     90 	// AccessToken
     91 	AccessToken string `json:"access_token"`
     92 
     93 	// Token type
     94 	TokenType string `json:"token_type"`
     95 
     96 	// Expiration
     97 	ExpiresIn int `json:"expires_in"`
     98 }
     99 
    100 func (t OidcValidator) LandingPageTpl() *template.Template {
    101 	return t.landingPageTpl
    102 }
    103 
    104 func (t OidcValidator) Type() ValidatorType {
    105 	return ValidatorTypeOIDC
    106 }
    107 
    108 func (t OidcValidator) Name() string {
    109 	return t.name
    110 }
    111 
    112 func (t OidcValidator) ChallengeFee() string {
    113 	return t.config.Ini.GetString("directory-validator-"+t.name, "challenge_fee", "KUDOS:0")
    114 }
    115 
    116 func (t OidcValidator) IsAliasValid(alias string) (err error) {
    117 	if t.validAliasRegex != "" {
    118 		matched, _ := regexp.MatchString(t.validAliasRegex, alias)
    119 		if !matched {
    120 			return fmt.Errorf("alias `%s' invalid", alias) // TODO i18n
    121 		}
    122 	}
    123 	return
    124 }
    125 
    126 func (t OidcValidator) ValidateAliasSubject(tokenString string, expectedAlias string) error {
    127 	var relevantClaims map[string]any
    128 	req, err := http.NewRequest("GET", t.userinfoEndpoint, nil)
    129 	if err != nil {
    130 		return fmt.Errorf("failed to create userinfo request")
    131 	}
    132 	req.Header.Set("Authorization", "Bearer "+tokenString)
    133 	client := &http.Client{}
    134 	resp, err := client.Do(req)
    135 	if err != nil {
    136 		return fmt.Errorf("failed to execute userinfo request")
    137 	}
    138 	if resp.StatusCode != http.StatusOK {
    139 		return fmt.Errorf("unexpected response code %d", resp.StatusCode)
    140 	}
    141 	err = json.NewDecoder(resp.Body).Decode(&relevantClaims)
    142 	if err != nil {
    143 		return fmt.Errorf("unable to parse userinfo response")
    144 	}
    145 	aliasClaim := relevantClaims[t.aliasClaimName]
    146 	if aliasClaim != expectedAlias {
    147 		return fmt.Errorf("subject in ID token (%s) does not match state (%s)", aliasClaim, expectedAlias)
    148 	}
    149 	return nil
    150 }
    151 
    152 func (t OidcValidator) ProcessOidcCallback(r *http.Request) (string, string, error) {
    153 	// Process authorization code
    154 	stateParam := r.URL.Query().Get("state")
    155 	if stateParam == "" {
    156 		return "", "", fmt.Errorf("no state query parameter provided")
    157 	}
    158 	state, ok := t.authorizationsState[stateParam]
    159 	if !ok {
    160 		return "", "", fmt.Errorf("state invalid")
    161 	}
    162 	alias := state.alias
    163 	challenge := state.challenge
    164 	delete(t.authorizationsState, stateParam)
    165 	code := r.URL.Query().Get("code")
    166 	data := url.Values{}
    167 	data.Set("client_id", t.clientID)
    168 	data.Set("grant_type", "authorization_code")
    169 	data.Set("redirect_uri", t.redirectURI)
    170 	data.Set("code", code)
    171 
    172 	req, err := http.NewRequest("POST", t.tokenEndpoint, strings.NewReader(data.Encode()))
    173 	if err != nil {
    174 		return "", "", fmt.Errorf("failed to create token request: %v", err)
    175 	}
    176 	req.SetBasicAuth(t.clientID, t.clientSecret)
    177 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    178 	client := &http.Client{}
    179 	resp, err := client.Do(req)
    180 	if err != nil {
    181 		return "", "", fmt.Errorf("failed to execute token request: %v", err)
    182 	}
    183 	if resp.StatusCode != http.StatusOK {
    184 		return "", "", fmt.Errorf("unexpected response code %d", resp.StatusCode)
    185 	}
    186 	var tokenResponse OAuthTokenResponse
    187 	err = json.NewDecoder(resp.Body).Decode(&tokenResponse)
    188 	if err != nil {
    189 		return "", "", fmt.Errorf("unable to parse token response: %v", err)
    190 	}
    191 	err = t.ValidateAliasSubject(tokenResponse.AccessToken, alias)
    192 	if err != nil {
    193 		return "", "", fmt.Errorf("unable to validate token: %v", err)
    194 	}
    195 	return alias, challenge, nil
    196 }
    197 
    198 func (t OidcValidator) RegistrationStart(topic string, link string, message string, alias string, challenge string) (string, error) {
    199 	state := rand.Text()
    200 	t.authorizationsState[state] = &AuthorizationsState{alias, challenge}
    201 	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)
    202 	return redirectURI, nil
    203 }
    204 
    205 func makeOidcValidator(cfg *TaldirConfig, name string, landingPageTpl *template.Template) OidcValidator {
    206 	baseURL := cfg.Ini.GetString("directory", "base_url", "")
    207 	// FIXME escape URI?
    208 	redirectURI := fmt.Sprintf("%s/oidc_validator/%s", baseURL, name)
    209 	sec := "directory-validator-" + name
    210 	return OidcValidator{
    211 		name:                  name,
    212 		config:                cfg,
    213 		landingPageTpl:        landingPageTpl,
    214 		clientID:              cfg.Ini.GetString(sec, "client_id", ""),
    215 		clientSecret:          cfg.Ini.GetString(sec, "client_secret", ""),
    216 		scope:                 cfg.Ini.GetString(sec, "scope", "profile"),
    217 		tokenEndpoint:         cfg.Ini.GetString(sec, "token_endpoint", ""),
    218 		userinfoEndpoint:      cfg.Ini.GetString(sec, "userinfo_endpoint", ""),
    219 		authorizationEndpoint: cfg.Ini.GetString(sec, "authorization_endpoint", ""),
    220 		validAliasRegex:       cfg.Ini.GetString(sec, "valid_alias_regex", ""),
    221 		aliasClaimName:        cfg.Ini.GetString(sec, "alias_claim", "sub"),
    222 		redirectURI:           redirectURI,
    223 		authorizationsState:   make(map[string]*AuthorizationsState, 0),
    224 	}
    225 }