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 }