Skip to content

Commit

Permalink
Make use of dex refresh tokens and store them into local config. (#456)
Browse files Browse the repository at this point in the history
* Make use of dex refresh tokens and store them into local config
* API client will automatically redeem OIDC refresh token if auth token expired.
* Stop the practice of reissuing/resigning non-expiring dex claims in API server.
  • Loading branch information
jessesuen committed Jul 25, 2018
1 parent 3ad036a commit 0e78172
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 109 deletions.
38 changes: 12 additions & 26 deletions cmd/argocd/commands/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman

// Perform the login
var tokenString string
var refreshToken string
if !sso {
tokenString = passwordLogin(acdClient, username, password)
} else {
Expand All @@ -85,15 +86,7 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman
if !ssoConfigured(acdSet) {
log.Fatalf("ArgoCD instance is not configured with SSO")
}
tokenString = oauth2Login(server, clientOpts.PlainText)
// The token which we just received from the OAuth2 flow, was from dex. ArgoCD
// currently does not back dex with any kind of persistent storage (it is run
// in-memory). As a result, this token cannot be used in any permanent capacity.
// Restarts of dex will result in a different signing key, and sessions becoming
// invalid. Instead we turn-around and ask ArgoCD to re-sign the token (who *does*
// have persistence of signing keys), and is what we store in the config. Should we
// ever decide to have a database layer for dex, the next line can be removed.
tokenString = tokenLogin(acdClient, tokenString)
tokenString, refreshToken = oauth2Login(server, clientOpts.PlainText)
}

parser := &jwt.Parser{
Expand All @@ -116,8 +109,9 @@ func NewLoginCommand(globalClientOpts *argocdclient.ClientOptions) *cobra.Comman
Insecure: globalClientOpts.Insecure,
})
localCfg.UpsertUser(localconfig.User{
Name: ctxName,
AuthToken: tokenString,
Name: ctxName,
AuthToken: tokenString,
RefreshToken: refreshToken,
})
if ctxName == "" {
ctxName = server
Expand Down Expand Up @@ -163,8 +157,9 @@ func getFreePort() (int, error) {
return ln.Addr().(*net.TCPAddr).Port, ln.Close()
}

// oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and returns the JWT token
func oauth2Login(host string, plaintext bool) string {
// oauth2Login opens a browser, runs a temporary HTTP server to delegate OAuth2 login flow and
// returns the JWT token and a refresh token (if supported)
func oauth2Login(host string, plaintext bool) (string, string) {
ctx := context.Background()
port, err := getFreePort()
errors.CheckError(err)
Expand All @@ -183,6 +178,7 @@ func oauth2Login(host string, plaintext bool) string {
}
srv := &http.Server{Addr: ":" + strconv.Itoa(port)}
var tokenString string
var refreshToken string
loginCompleted := make(chan struct{})

callbackHandler := func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -215,8 +211,9 @@ func oauth2Login(host string, plaintext bool) string {
log.Fatal(errMsg)
return
}

refreshToken, _ = tok.Extra("refresh_token").(string)
log.Debugf("Token: %s", tokenString)
log.Debugf("Refresh Token: %s", tokenString)
successPage := `
<div style="height:100px; width:100%!; display:flex; flex-direction: column; justify-content: center; align-items:center; background-color:#2ecc71; color:white; font-size:22"><div>Authentication successful!</div></div>
<p style="margin-top:20px; font-size:18; text-align:center">Authentication was successful, you can now return to CLI. This page will close automatically</p>
Expand Down Expand Up @@ -248,7 +245,7 @@ func oauth2Login(host string, plaintext bool) string {
}()
<-loginCompleted
_ = srv.Shutdown(ctx)
return tokenString
return tokenString, refreshToken
}

func passwordLogin(acdClient argocdclient.Client, username, password string) string {
Expand All @@ -263,14 +260,3 @@ func passwordLogin(acdClient argocdclient.Client, username, password string) str
errors.CheckError(err)
return createdSession.Token
}

func tokenLogin(acdClient argocdclient.Client, token string) string {
sessConn, sessionIf := acdClient.NewSessionClientOrDie()
defer util.Close(sessConn)
sessionRequest := session.SessionCreateRequest{
Token: token,
}
createdSession, err := sessionIf.Create(context.Background(), &sessionRequest)
errors.CheckError(err)
return createdSession.Token
}
147 changes: 127 additions & 20 deletions pkg/apiclient/apiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@ import (
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"strings"
"time"

oidc "github.com/coreos/go-oidc"
jwt "github.com/dgrijalva/jwt-go"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"

"github.com/argoproj/argo-cd/common"
"github.com/argoproj/argo-cd/server/account"
"github.com/argoproj/argo-cd/server/application"
"github.com/argoproj/argo-cd/server/cluster"
Expand All @@ -21,9 +32,6 @@ import (
"github.com/argoproj/argo-cd/server/version"
grpc_util "github.com/argoproj/argo-cd/util/grpc"
"github.com/argoproj/argo-cd/util/localconfig"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)

const (
Expand Down Expand Up @@ -68,11 +76,12 @@ type ClientOptions struct {
}

type client struct {
ServerAddr string
PlainText bool
Insecure bool
CertPEMData []byte
AuthToken string
ServerAddr string
PlainText bool
Insecure bool
CertPEMData []byte
AuthToken string
RefreshToken string
}

// NewClient creates a new API client from a set of config options.
Expand All @@ -82,6 +91,7 @@ func NewClient(opts *ClientOptions) (Client, error) {
if err != nil {
return nil, err
}
var ctxName string
if localCfg != nil {
configCtx, err := localCfg.ResolveContext(opts.Context)
if err != nil {
Expand All @@ -98,6 +108,8 @@ func NewClient(opts *ClientOptions) (Client, error) {
c.PlainText = configCtx.Server.PlainText
c.Insecure = configCtx.Server.Insecure
c.AuthToken = configCtx.User.AuthToken
c.RefreshToken = configCtx.User.RefreshToken
ctxName = configCtx.Name
}
}
// Override server address if specified in env or CLI flag
Expand Down Expand Up @@ -137,9 +149,97 @@ func NewClient(opts *ClientOptions) (Client, error) {
if opts.Insecure {
c.Insecure = true
}
if localCfg != nil {
err = c.refreshAuthToken(localCfg, ctxName, opts.ConfigPath)
if err != nil {
return nil, err
}
}
return &c, nil
}

// refreshAuthToken refreshes a JWT auth token if it is invalid (e.g. expired)
func (c *client) refreshAuthToken(localCfg *localconfig.LocalConfig, ctxName, configPath string) error {
configCtx, err := localCfg.ResolveContext(ctxName)
if err != nil {
return err
}
if c.RefreshToken == "" {
// If we have no refresh token, there's no point in doing anything
return nil
}
parser := &jwt.Parser{
SkipClaimsValidation: true,
}
var claims jwt.StandardClaims
_, _, err = parser.ParseUnverified(configCtx.User.AuthToken, &claims)
if err != nil {
return err
}
if claims.Valid() == nil {
// token is still valid
return nil
}

log.Debug("Auth token no longer valid. Refreshing")
tlsConfig, err := c.tlsConfig()
if err != nil {
return err
}
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment,
Dial: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).Dial,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
}
ctx := oidc.ClientContext(context.Background(), httpClient)
var scheme string
if c.PlainText {
scheme = "http"
} else {
scheme = "https"
}
conf := &oauth2.Config{
ClientID: common.ArgoCDCLIClientAppID,
Scopes: []string{"openid", "profile", "email", "groups", "offline_access"},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s://%s%s/auth", scheme, c.ServerAddr, common.DexAPIEndpoint),
TokenURL: fmt.Sprintf("%s://%s%s/token", scheme, c.ServerAddr, common.DexAPIEndpoint),
},
RedirectURL: fmt.Sprintf("%s://%s/auth/callback", scheme, c.ServerAddr),
}
t := &oauth2.Token{
RefreshToken: c.RefreshToken,
}
token, err := conf.TokenSource(ctx, t).Token()
if err != nil {
return err
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return errors.New("no id_token in token response")
}
refreshToken, _ := token.Extra("refresh_token").(string)
c.AuthToken = rawIDToken
c.RefreshToken = refreshToken
localCfg.UpsertUser(localconfig.User{
Name: ctxName,
AuthToken: c.AuthToken,
RefreshToken: c.RefreshToken,
})
err = localconfig.WriteLocalConfig(*localCfg, configPath)
if err != nil {
return err
}
return nil
}

// NewClientOrDie creates a new API client from a set of config options, or fails fatally if the new client creation fails.
func NewClientOrDie(opts *ClientOptions) Client {
client, err := NewClient(opts)
Expand All @@ -162,32 +262,39 @@ func (c jwtCredentials) RequireTransportSecurity() bool {
func (c jwtCredentials) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
return map[string]string{
MetaDataTokenKey: c.Token,
"tokens": c.Token, // legacy key. delete eventually
}, nil
}

func (c *client) NewConn() (*grpc.ClientConn, error) {
var creds credentials.TransportCredentials
if !c.PlainText {
var tlsConfig tls.Config
if len(c.CertPEMData) > 0 {
cp := x509.NewCertPool()
if !cp.AppendCertsFromPEM(c.CertPEMData) {
return nil, fmt.Errorf("credentials: failed to append certificates")
}
tlsConfig.RootCAs = cp
}
if c.Insecure {
tlsConfig.InsecureSkipVerify = true
tlsConfig, err := c.tlsConfig()
if err != nil {
return nil, err
}
creds = credentials.NewTLS(&tlsConfig)
creds = credentials.NewTLS(tlsConfig)
}
endpointCredentials := jwtCredentials{
Token: c.AuthToken,
}
return grpc_util.BlockingDial(context.Background(), "tcp", c.ServerAddr, creds, grpc.WithPerRPCCredentials(endpointCredentials))
}

func (c *client) tlsConfig() (*tls.Config, error) {
var tlsConfig tls.Config
if len(c.CertPEMData) > 0 {
cp := x509.NewCertPool()
if !cp.AppendCertsFromPEM(c.CertPEMData) {
return nil, fmt.Errorf("credentials: failed to append certificates")
}
tlsConfig.RootCAs = cp
}
if c.Insecure {
tlsConfig.InsecureSkipVerify = true
}
return &tlsConfig, nil
}

func (c *client) ClientOptions() ClientOptions {
return ClientOptions{
ServerAddr: c.ServerAddr,
Expand Down
6 changes: 0 additions & 6 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import (
"github.com/argoproj/argo-cd"
"github.com/argoproj/argo-cd/common"
"github.com/argoproj/argo-cd/errors"

"github.com/argoproj/argo-cd/pkg/apiclient"
appclientset "github.com/argoproj/argo-cd/pkg/client/clientset/versioned"
"github.com/argoproj/argo-cd/reposerver"
Expand Down Expand Up @@ -503,11 +502,6 @@ func getToken(md metadata.MD) string {
if ok && len(tokens) > 0 {
return tokens[0]
}
// check the legacy key (v0.3.2 and below). 'tokens' was renamed to 'token'
tokens, ok = md["tokens"]
if ok && len(tokens) > 0 {
return tokens[0]
}
// check the HTTP cookie
for _, cookieToken := range md["grpcgateway-cookie"] {
header := http.Header{}
Expand Down
47 changes: 14 additions & 33 deletions server/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ package session

import (
"context"
"fmt"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/argoproj/argo-cd/util/jwt"
sessionmgr "github.com/argoproj/argo-cd/util/session"
)

Expand All @@ -23,40 +21,23 @@ func NewServer(mgr *sessionmgr.SessionManager) *Server {
}
}

// Create generates a non-expiring JWT token signed by ArgoCD. This endpoint is used in two circumstances:
// 1. Web/CLI logins for local users (i.e. admin), for when SSO is not configured. In this case,
// username/password.
// 2. CLI login which completed an OAuth2 login flow but wish to store a permanent token in their config
// Create generates a JWT token signed by ArgoCD intended for web/CLI logins of the admin user
// using username/password
func (s *Server) Create(ctx context.Context, q *SessionCreateRequest) (*SessionResponse, error) {
var tokenString string
var err error
if q.Password != "" {
// first case
err = s.mgr.VerifyUsernamePassword(q.Username, q.Password)
if err != nil {
return nil, err
}
tokenString, err = s.mgr.Create(q.Username, 0)
if err != nil {
return nil, err
}
} else if q.Token != "" {
// second case
claimsIf, err := s.mgr.VerifyToken(q.Token)
if err != nil {
return nil, err
}
claims, err := jwt.MapClaims(claimsIf)
if err != nil {
return nil, err
}
tokenString, err = s.mgr.ReissueClaims(claims, 0)
if err != nil {
return nil, fmt.Errorf("Failed to resign claims: %v", err)
}
} else {
if q.Token != "" {
return nil, status.Errorf(codes.Unauthenticated, "token-based session creation no longer supported. please upgrade argocd cli to v0.7+")
}
if q.Username == "" || q.Password == "" {
return nil, status.Errorf(codes.Unauthenticated, "no credentials supplied")
}
err := s.mgr.VerifyUsernamePassword(q.Username, q.Password)
if err != nil {
return nil, err
}
tokenString, err := s.mgr.Create(q.Username, 0)
if err != nil {
return nil, err
}
return &SessionResponse{Token: tokenString}, nil
}

Expand Down
Loading

0 comments on commit 0e78172

Please sign in to comment.