summaryrefslogtreecommitdiffstats
path: root/client/go/auth
diff options
context:
space:
mode:
authorLeandro Alves <leandroalves@yahooinc.com>2021-11-06 00:39:41 +0100
committerLeandro Alves <leandroalves@yahooinc.com>2021-11-06 23:27:18 +0100
commitc8ed549ba9fcad20cfcdc4862740df8df402c62b (patch)
tree39f23d70d1b6d0e9c9f98793938b528d9f86dc9c /client/go/auth
parent213f516d7b3b613cc11b0de0ee74b69939cf7136 (diff)
first draft for the device flow support
Diffstat (limited to 'client/go/auth')
-rw-r--r--client/go/auth/auth.go175
-rw-r--r--client/go/auth/secrets.go24
-rw-r--r--client/go/auth/token.go68
3 files changed, 267 insertions, 0 deletions
diff --git a/client/go/auth/auth.go b/client/go/auth/auth.go
new file mode 100644
index 00000000000..397e410924d
--- /dev/null
+++ b/client/go/auth/auth.go
@@ -0,0 +1,175 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package auth
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+const (
+ audiencePath = "/api/v2/"
+ waitThresholdInSeconds = 3
+ // SecretsNamespace namespace used to set/get values from the keychain
+ SecretsNamespace = "vespa-cli"
+)
+
+var requiredScopes = []string{"openid", "offline_access"}
+
+type Authenticator struct {
+ Audience string
+ ClientID string
+ DeviceCodeEndpoint string
+ OauthTokenEndpoint string
+}
+
+// SecretStore provides access to stored sensitive data.
+type SecretStore interface {
+ // Get gets the secret
+ Get(namespace, key string) (string, error)
+ // Delete removes the secret
+ Delete(namespace, key string) error
+}
+
+type Result struct {
+ Tenant string
+ Domain string
+ RefreshToken string
+ AccessToken string
+ ExpiresIn int64
+}
+
+type State struct {
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ VerificationURI string `json:"verification_uri_complete"`
+ ExpiresIn int `json:"expires_in"`
+ Interval int `json:"interval"`
+}
+
+// RequiredScopes returns the scopes used for login.
+func RequiredScopes() []string { return requiredScopes }
+
+func (s *State) IntervalDuration() time.Duration {
+ return time.Duration(s.Interval+waitThresholdInSeconds) * time.Second
+}
+
+// Start kicks-off the device authentication flow
+// by requesting a device code from Auth0,
+// The returned state contains the URI for the next step of the flow.
+func (a *Authenticator) Start(ctx context.Context) (State, error) {
+ s, err := a.getDeviceCode(ctx)
+ if err != nil {
+ return State{}, fmt.Errorf("cannot get device code: %w", err)
+ }
+ return s, nil
+}
+
+// Wait waits until the user is logged in on the browser.
+func (a *Authenticator) Wait(ctx context.Context, state State) (Result, error) {
+ t := time.NewTicker(state.IntervalDuration())
+ for {
+ select {
+ case <-ctx.Done():
+ return Result{}, ctx.Err()
+ case <-t.C:
+ data := url.Values{
+ "client_id": {a.ClientID},
+ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
+ "device_code": {state.DeviceCode},
+ }
+ r, err := http.PostForm(a.OauthTokenEndpoint, data)
+ if err != nil {
+ return Result{}, fmt.Errorf("cannot get device code: %w", err)
+ }
+ defer r.Body.Close()
+
+ var res struct {
+ AccessToken string `json:"access_token"`
+ IDToken string `json:"id_token"`
+ RefreshToken string `json:"refresh_token"`
+ Scope string `json:"scope"`
+ ExpiresIn int64 `json:"expires_in"`
+ TokenType string `json:"token_type"`
+ Error *string `json:"error,omitempty"`
+ ErrorDescription string `json:"error_description,omitempty"`
+ }
+
+ err = json.NewDecoder(r.Body).Decode(&res)
+ if err != nil {
+ return Result{}, fmt.Errorf("cannot decode response: %w", err)
+ }
+
+ if res.Error != nil {
+ if *res.Error == "authorization_pending" {
+ continue
+ }
+ return Result{}, errors.New(res.ErrorDescription)
+ }
+
+ ten, domain, err := parseTenant(res.AccessToken)
+ if err != nil {
+ return Result{}, fmt.Errorf("cannot parse tenant from the given access token: %w", err)
+ }
+
+ return Result{
+ RefreshToken: res.RefreshToken,
+ AccessToken: res.AccessToken,
+ ExpiresIn: res.ExpiresIn,
+ Tenant: ten,
+ Domain: domain,
+ }, nil
+ }
+ }
+}
+
+func (a *Authenticator) getDeviceCode(ctx context.Context) (State, error) {
+ data := url.Values{
+ "client_id": {a.ClientID},
+ "scope": {strings.Join(requiredScopes, " ")},
+ "audience": {a.Audience},
+ }
+ r, err := http.PostForm(a.DeviceCodeEndpoint, data)
+ if err != nil {
+ return State{}, fmt.Errorf("cannot get device code: %w", err)
+ }
+ defer r.Body.Close()
+ var res State
+ err = json.NewDecoder(r.Body).Decode(&res)
+ if err != nil {
+ return State{}, fmt.Errorf("cannot decode response: %w", err)
+ }
+ return res, nil
+}
+
+func parseTenant(accessToken string) (tenant, domain string, err error) {
+ parts := strings.Split(accessToken, ".")
+ v, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return "", "", err
+ }
+ var payload struct {
+ AUDs []string `json:"aud"`
+ }
+ if err := json.Unmarshal(v, &payload); err != nil {
+ return "", "", err
+ }
+ for _, aud := range payload.AUDs {
+ u, err := url.Parse(aud)
+ if err != nil {
+ return "", "", err
+ }
+ if u.Path == audiencePath {
+ parts := strings.Split(u.Host, ".")
+ return parts[0], u.Host, nil
+ }
+ }
+ return "", "", fmt.Errorf("audience not found for %s", audiencePath)
+}
diff --git a/client/go/auth/secrets.go b/client/go/auth/secrets.go
new file mode 100644
index 00000000000..e38d8c56595
--- /dev/null
+++ b/client/go/auth/secrets.go
@@ -0,0 +1,24 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package auth
+
+import (
+ "github.com/zalando/go-keyring"
+)
+
+type Keyring struct{}
+
+// Set sets the given key/value pair with the given namespace.
+func (k *Keyring) Set(namespace, key, value string) error {
+ return keyring.Set(namespace, key, value)
+}
+
+// Get gets a value for the given namespace and key.
+func (k *Keyring) Get(namespace, key string) (string, error) {
+ return keyring.Get(namespace, key)
+}
+
+// Delete deletes a value for the given namespace and key.
+func (k *Keyring) Delete(namespace, key string) error {
+ return keyring.Delete(namespace, key)
+}
diff --git a/client/go/auth/token.go b/client/go/auth/token.go
new file mode 100644
index 00000000000..e9b90b8994e
--- /dev/null
+++ b/client/go/auth/token.go
@@ -0,0 +1,68 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+)
+
+type TokenResponse struct {
+ AccessToken string `json:"access_token"`
+ IDToken string `json:"id_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type TokenRetriever struct {
+ Authenticator *Authenticator
+ Secrets SecretStore
+ Client *http.Client
+}
+
+// Delete deletes the given tenant from the secrets' storage.
+func (t *TokenRetriever) Delete(tenant string) error {
+ return t.Secrets.Delete(SecretsNamespace, tenant)
+}
+
+// Refresh gets a new access token from the provided refresh token,
+// The request is used the default client_id and endpoint for device authentication.
+func (t *TokenRetriever) Refresh(ctx context.Context, tenant string) (TokenResponse, error) {
+ // get stored refresh token:
+ refreshToken, err := t.Secrets.Get(SecretsNamespace, tenant)
+ if err != nil {
+ return TokenResponse{}, fmt.Errorf("cannot get the stored refresh token: %w", err)
+ }
+ if refreshToken == "" {
+ return TokenResponse{}, errors.New("cannot use the stored refresh token: the token is empty")
+ }
+ // get access token:
+ r, err := t.Client.PostForm(t.Authenticator.OauthTokenEndpoint, url.Values{
+ "grant_type": {"refresh_token"},
+ "client_id": {t.Authenticator.ClientID},
+ "refresh_token": {refreshToken},
+ })
+ if err != nil {
+ return TokenResponse{}, fmt.Errorf("cannot get a new access token from the refresh token: %w", err)
+ }
+
+ defer r.Body.Close()
+ if r.StatusCode != http.StatusOK {
+ b, _ := ioutil.ReadAll(r.Body)
+ bodyStr := string(b)
+ return TokenResponse{}, fmt.Errorf("cannot get a new access token from the refresh token: %s", bodyStr)
+ }
+
+ var res TokenResponse
+ err = json.NewDecoder(r.Body).Decode(&res)
+ if err != nil {
+ return TokenResponse{}, fmt.Errorf("cannot decode response: %w", err)
+ }
+
+ return res, nil
+}