github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/auth/authorize/authenticator/device_authenticator.go (about) 1 /* 2 Copyright (C) 2022-2023 ApeCloud Co., Ltd 3 4 This file is part of KubeBlocks project 5 6 This program is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Affero General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This program is distributed in the hope that it will be useful 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Affero General Public License for more details. 15 16 You should have received a copy of the GNU Affero General Public License 17 along with this program. If not, see <http://www.gnu.org/licenses/>. 18 */ 19 20 package authenticator 21 22 import ( 23 "context" 24 "encoding/json" 25 "fmt" 26 "net/http" 27 "net/url" 28 "strings" 29 "time" 30 31 "github.com/benbjohnson/clock" 32 "github.com/hashicorp/go-cleanhttp" 33 "github.com/pkg/errors" 34 ) 35 36 // DeviceAuthenticator performs the authentication flow for logging in. 37 type DeviceAuthenticator struct { 38 client *http.Client 39 AuthURL *url.URL 40 AuthAudience string 41 Clock clock.Clock 42 ClientID string 43 } 44 45 type DeviceVerification struct { 46 // DeviceCode is the unique code for the device. When the user goes to the VerificationURL in their browser-based device, this code will be bound to their session. 47 DeviceCode string 48 // UserCode contains the code that should be input at the VerificationURL to authorize the device. 49 UserCode string 50 // VerificationURL contains the URL the user should visit to authorize the device. 51 VerificationURL string 52 // VerificationCompleteURL contains the complete URL the user should visit to authorize the device. This allows your app to embed the user_code in the URL, if you so choose. 53 VerificationCompleteURL string 54 // CheckInterval indicates the interval (in seconds) at which the app should poll the token URL to request a token. 55 Interval time.Duration 56 // ExpiresAt indicates the lifetime (in seconds) of the device_code and user_code. 57 ExpiresAt time.Time 58 } 59 60 type DeviceCodeResponse struct { 61 DeviceCode string `json:"device_code"` 62 UserCode string `json:"user_code"` 63 VerificationURI string `json:"verification_uri"` 64 VerificationCompleteURI string `json:"verification_uri_complete"` 65 ExpiresIn int `json:"expires_in"` 66 PollingInterval int `json:"interval"` 67 } 68 69 type ErrorResponse struct { 70 ErrorCode string `json:"error"` 71 Description string `json:"error_description"` 72 } 73 74 func (e ErrorResponse) Error() string { 75 return e.Description 76 } 77 78 func newDeviceAuthenticator(client *http.Client, clientID string, authURL string) (*DeviceAuthenticator, error) { 79 if client == nil { 80 client = cleanhttp.DefaultClient() 81 } 82 83 baseURL, err := url.Parse(authURL) 84 if err != nil { 85 return nil, err 86 } 87 88 authenticator := &DeviceAuthenticator{ 89 client: client, 90 AuthURL: baseURL, 91 AuthAudience: authURL + "/api/v2/", 92 Clock: clock.New(), 93 ClientID: clientID, 94 } 95 return authenticator, nil 96 } 97 98 // GetAuthorization performs the device verification API calls. 99 func (d *DeviceAuthenticator) GetAuthorization(ctx context.Context, openURLFunc func(URL string), states ...string) (interface{}, error) { 100 req, err := d.newRequest(ctx, "oauth/device/code", url.Values{ 101 "client_id": []string{d.ClientID}, 102 "audience": []string{d.AuthAudience}, 103 "scope": []string{strings.Join([]string{ 104 "read_databases", "write_databases", "read_user", "read_organization", "offline_access", "openid", "profile", "email", 105 }, " ")}, 106 }) 107 if err != nil { 108 return nil, err 109 } 110 res, err := d.client.Do(req) 111 if err != nil { 112 return nil, err 113 } 114 defer res.Body.Close() 115 116 if _, err = checkErrorResponse(res); err != nil { 117 return nil, err 118 } 119 120 deviceCodeRes := &DeviceCodeResponse{} 121 err = json.NewDecoder(res.Body).Decode(deviceCodeRes) 122 if err != nil { 123 return nil, errors.Wrap(err, "error decoding device code response") 124 } 125 126 interval := time.Duration(deviceCodeRes.PollingInterval) * time.Second 127 if interval == 0 { 128 interval = time.Duration(5) * time.Second 129 } 130 131 expiresAt := d.Clock.Now().Add(time.Duration(deviceCodeRes.ExpiresIn) * time.Second) 132 133 openURLFunc(deviceCodeRes.VerificationURI) 134 135 return &DeviceVerification{ 136 DeviceCode: deviceCodeRes.DeviceCode, 137 UserCode: deviceCodeRes.UserCode, 138 VerificationCompleteURL: deviceCodeRes.VerificationCompleteURI, 139 VerificationURL: deviceCodeRes.VerificationURI, 140 ExpiresAt: expiresAt, 141 Interval: interval, 142 }, nil 143 } 144 145 func (d *DeviceAuthenticator) GetToken(ctx context.Context, authorization interface{}) (*TokenResponse, error) { 146 v, ok := authorization.(*DeviceVerification) 147 if !ok { 148 return nil, errors.New("invalid authorization") 149 } 150 151 for { 152 // This loop begins right after we open the user's browser to send an 153 // authentication code. We don't request a token immediately because the 154 // has to complete that authentication flow before we can provide a 155 // token anyway. 156 select { 157 case <-ctx.Done(): 158 return nil, ctx.Err() 159 case <-time.After(v.Interval): 160 } 161 tokenResponse, err := d.requestToken(ctx, v.DeviceCode, d.ClientID) 162 if err != nil { 163 // Fatal error. 164 return nil, err 165 } 166 167 if tokenResponse != nil { 168 return tokenResponse, nil 169 } 170 171 if tokenResponse == nil && d.Clock.Now().After(v.ExpiresAt) { 172 return nil, errors.New("authentication timed out") 173 } 174 } 175 } 176 177 func (d *DeviceAuthenticator) requestToken(ctx context.Context, deviceCode string, clientID string) (*TokenResponse, error) { 178 req, err := d.newRequest(ctx, "oauth/token", url.Values{ 179 "grant_type": []string{"urn:ietf:params:oauth:grant-type:device_code"}, 180 "device_code": []string{deviceCode}, 181 "client_id": []string{clientID}, 182 }) 183 if err != nil { 184 return nil, errors.Wrap(err, "error creating request") 185 } 186 187 res, err := d.client.Do(req) 188 if err != nil { 189 return nil, errors.Wrap(err, "error performing http request") 190 } 191 defer res.Body.Close() 192 193 isRetryable, err := checkErrorResponse(res) 194 if err != nil { 195 return nil, err 196 } 197 198 if isRetryable { 199 return nil, nil 200 } 201 202 tokenRes := &TokenResponse{} 203 204 err = json.NewDecoder(res.Body).Decode(tokenRes) 205 if err != nil { 206 return nil, errors.Wrap(err, "error decoding token response") 207 } 208 return tokenRes, nil 209 } 210 211 func (d *DeviceAuthenticator) GetUserInfo(ctx context.Context, token string) (*UserInfoResponse, error) { 212 req, err := d.newRequest(ctx, "userinfo", url.Values{ 213 "access_token": []string{token}, 214 }) 215 if err != nil { 216 return nil, errors.Wrap(err, "error creating request") 217 } 218 219 res, err := d.client.Do(req) 220 if err != nil { 221 return nil, errors.Wrap(err, "error performing http request") 222 } 223 defer res.Body.Close() 224 225 userInfo := &UserInfoResponse{} 226 err = json.NewDecoder(res.Body).Decode(userInfo) 227 if err != nil { 228 return nil, errors.Wrap(err, "error decoding userinfo") 229 } 230 231 return userInfo, err 232 } 233 234 // RefreshToken The device authenticator needs the clientSecret when refreshing the token, 235 // and the kbcli client does not hold it, so this method does not need to be implemented. 236 func (d *DeviceAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { 237 return nil, nil 238 } 239 240 func (d *DeviceAuthenticator) Logout(ctx context.Context, token string, openURLFunc func(URL string)) error { 241 req, err := d.newRequest(ctx, "oidc/logout", url.Values{ 242 "id_token_hint": []string{token}, 243 "client_id": []string{d.ClientID}, 244 }) 245 if err != nil { 246 return errors.Wrap(err, "error creating request") 247 } 248 249 res, err := d.client.Do(req) 250 fmt.Println(res.StatusCode) 251 if err != nil { 252 return errors.Wrap(err, "error performing http request") 253 } 254 defer res.Body.Close() 255 if _, err = checkErrorResponse(res); err != nil { 256 return err 257 } 258 259 logoutURL := fmt.Sprintf(d.AuthURL.Path + "/oidc/logout?federated") 260 openURLFunc(logoutURL) 261 262 return nil 263 } 264 265 func (d *DeviceAuthenticator) newRequest(ctx context.Context, path string, payload url.Values) (*http.Request, error) { 266 u, err := d.AuthURL.Parse(path) 267 if err != nil { 268 return nil, err 269 } 270 271 req, err := http.NewRequestWithContext( 272 ctx, 273 http.MethodPost, 274 u.String(), 275 strings.NewReader(payload.Encode()), 276 ) 277 if err != nil { 278 return nil, err 279 } 280 281 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 282 req.Header.Set("Accept", "application/json") 283 return req, nil 284 } 285 286 // CheckErrorResponse returns whether the error is retryable or not and the error itself. 287 func checkErrorResponse(res *http.Response) (bool, error) { 288 if res.StatusCode < 400 { 289 return false, nil 290 } 291 292 // Client or server error. 293 errorRes := &ErrorResponse{} 294 err := json.NewDecoder(res.Body).Decode(errorRes) 295 if err != nil { 296 return false, errors.Wrap(err, "error decoding response") 297 } 298 299 // Authentication is not yet complete or requests need to be slowed down. 300 if errorRes.ErrorCode == "authorization_pending" || errorRes.ErrorCode == "slow_down" { 301 return true, nil 302 } 303 304 return false, errorRes 305 }