From ad610f10710e890659fd6e08c08a300e555d01d6 Mon Sep 17 00:00:00 2001 From: "Alex Ellis (OpenFaaS Ltd)" Date: Fri, 3 Dec 2021 10:19:13 +0000 Subject: [PATCH] Support PKCE for auth command for OpenFaaS Pro users * Enables PKCE in the place of implicit auth flow. * Auth command requires EULA acceptance and valid OpenFaaS Pro license or trial. * Updates feature status from alpha to generally-available Signed-off-by: Alex Ellis (OpenFaaS Ltd) --- Dockerfile | 2 +- LICENSE | 10 +- README.md | 15 ++- commands/auth.go | 207 +++++++++++++++++++++++++++++++++++++++--- commands/auth_test.go | 20 +++- 5 files changed, 231 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index e245ec6b..faefc9d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ RUN test -z "$(gofmt -l $(find . -type f -name '*.go' -not -path "./vendor/*"))" # ldflags "-s -w" strips binary # ldflags -X injects commit version into binary -RUN /usr/bin/license-check -path ./ --verbose=false "Alex Ellis" "OpenFaaS Author(s)" +RUN /usr/bin/license-check -path ./ --verbose=false "Alex Ellis" "OpenFaaS Author(s)" "OpenFaaS Ltd" RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ go test $(go list ./... | grep -v /vendor/ | grep -v /template/|grep -v /build/|grep -v /sample/) -cover diff --git a/LICENSE b/LICENSE index e3664181..5651d3ab 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,14 @@ +Annotated portions of this project are licensed under the OpenFaaS Pro +commercial license, for which a license is required to use the software. + +EULA: https://github.com/openfaas/faas/blob/master/pro/EULA.md + +The remainder of the source code is licensed under the MIT license. + MIT License -Copyright (c) 2016-2017 Alex Ellis +Copyright (c) 2016-2021 OpenFaaS Ltd +Copyright (c) 2017-2021 OpenFaaS Author(s) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 0577156f..39fa3946 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The main commands supported by the CLI are: * `faas-cli secret` - manage secrets for your functions -* `faas-cli auth` - (alpha) initiates an OAuth2 authorization flow to obtain a cookie +* `faas-cli auth` - initiates an OAuth2 authorization flow to obtain a token * `faas-cli registry-login` - generate registry auth file in correct format by providing username and password for docker/ecr/self hosted registry @@ -116,13 +116,15 @@ You can chose between using a [programming language template](https://github.com #### `faas-cli auth` -The `auth` command is currently available for alpha testing. Use the `auth` command to obtain a JWT to use as a Bearer token. +The `auth` command is only licensed for OpenFaaS Pro customers. -Two flow-types are supported in the CLI. +Use the `auth` command to obtain a JWT to use as a Bearer token. ##### `code` grant - default -Use this flow to obtain a token. +Use this flow to obtain a token for interactive use from your workstation. + +The code grant flow uses the PKCE extension. At this time the `token` cannot be saved or retained in your OpenFaaS config file. You can pass the token using a CLI flag of `--token=$TOKEN`. @@ -131,6 +133,7 @@ Example: ```sh faas-cli auth \ --auth-url https://tenant0.eu.auth0.com/authorize \ + --token-url https://tenant0.eu.auth0.com/oauth/token \ --audience http://gw.example.com \ --client-id "${OAUTH_CLIENT_ID}" ``` @@ -402,4 +405,6 @@ See [contributing guide](https://github.com/openfaas/faas-cli/blob/master/CONTRI ### License -This project is part of OpenFaaS and is licensed under the MIT License. +Portions of this project are licensed under the OpenFaaS Pro EULA. + +The remaining source unless annotated is licensed under the MIT License. diff --git a/commands/auth.go b/commands/auth.go index cda3e2fb..3acb11fa 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -1,11 +1,16 @@ -// Copyright (c) OpenFaaS Author(s) 2017. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// Copyright (c) OpenFaaS Ltd 2021. All rights reserved. +// +// Licensed for use with OpenFaaS Pro only +// See EULA: https://github.com/openfaas/faas/blob/master/pro/EULA.md package commands import ( "bytes" "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io/ioutil" @@ -23,12 +28,15 @@ import ( ) var ( - scope string - authURL string + scope string + authURL string + tokenURL string + clientID string audience string listenPort int launchBrowser bool + eula bool grant string clientSecret string redirectHost string @@ -37,11 +45,14 @@ var ( func init() { authCmd.Flags().StringVarP(&gateway, "gateway", "g", defaultGateway, "Gateway URL starting with http(s)://") authCmd.Flags().StringVar(&authURL, "auth-url", "", "OAuth2 Authorize URL i.e. http://idp/oauth/authorize") + authCmd.Flags().StringVar(&tokenURL, "token-url", "", "OAuth2 Token URL i.e. http://idp/oauth/token") + authCmd.Flags().StringVar(&clientID, "client-id", "", "OAuth2 client_id") authCmd.Flags().IntVar(&listenPort, "listen-port", 31111, "OAuth2 local port for receiving cookie") authCmd.Flags().StringVar(&audience, "audience", "", "OAuth2 audience") authCmd.Flags().BoolVar(&launchBrowser, "launch-browser", true, "Launch browser for OAuth2 redirect") authCmd.Flags().StringVar(&redirectHost, "redirect-host", "http://127.0.0.1", "Host for OAuth2 redirection in the implicit flow including URL scheme") + authCmd.Flags().BoolVar(&eula, "eula", false, "Agree to the EULA, for use with OpenFaaS Pro only") authCmd.Flags().StringVar(&scope, "scope", "openid profile", "scope for OAuth2 flow - i.e. \"openid profile\"") authCmd.Flags().StringVar(&grant, "grant", "implicit", "grant for OAuth2 flow - either implicit, implicit-id or client_credentials") @@ -57,9 +68,20 @@ var authCmd = &cobra.Command{ [--client-secret] [--grant GRANT]`, Short: "Obtain a token for your OpenFaaS gateway", - Long: "Authenticate to an OpenFaaS gateway using OAuth2.", - Example: ` faas-cli auth --client-id my-id --auth-url https://tenant.auth0.com/authorize --scope "oidc profile" --audience my-id - faas-cli auth --grant=client_credentials --client-id=id --client-secret=secret --auth-url=https://tenant.auth0.com/token`, + Long: `Authenticate to an OpenFaaS gateway using OIDC. + +Only licensed for use by OpenFaaS Pro customers.`, + Example: ` faas-cli auth \ + --grant code \ + --client-id my-id \ + --auth-url https://tenant.auth0.com/authorize \ + --token-url https://tenant.auth0.com/oauth/token \ + --scope "oidc profile email" + + faas-cli auth --grant=client_credentials \ + --client-id=id \ + --client-secret=secret \ + --auth-url=https://tenant.auth0.com/oauth/token`, RunE: runAuth, PreRunE: preRunAuth, } @@ -67,10 +89,11 @@ var authCmd = &cobra.Command{ func preRunAuth(cmd *cobra.Command, args []string) error { return checkValues(authURL, clientID, + eula, ) } -func checkValues(authURL, clientID string) error { +func checkValues(authURL, clientID string, eula bool) error { if len(authURL) == 0 { return fmt.Errorf("--auth-url is required and must be a valid OIDC URL") @@ -88,6 +111,10 @@ func checkValues(authURL, clientID string) error { return fmt.Errorf("--client-id is required") } + if !eula { + return fmt.Errorf("the auth command is only licensed for OpenFaaS Pro customers, see: https://github.com/openfaas/faas/blob/master/pro/EULA.md") + } + return nil } @@ -99,6 +126,94 @@ func runAuth(cmd *cobra.Command, args []string) error { } else if grant == "client_credentials" { return authClientCredentials() } + if grant == "code" { + if len(tokenURL) == 0 { + return fmt.Errorf("--token-url is required for PKCE") + } + return authPkce("id_token") + } + + return nil +} + +func authPkce(grant string) error { + + context, cancel := context.WithCancel(context.TODO()) + defer cancel() + + verifier := make([]byte, 32) + + _, err := rand.Read(verifier) + if err != nil { + return err + } + + verifierEncoded := base64.RawURLEncoding.EncodeToString(verifier[:]) + + challenge := sha256.Sum256([]byte(verifierEncoded)) + challengeEncoded := base64.RawURLEncoding.EncodeToString(challenge[:]) + + q := url.Values{} + q.Add("client_id", clientID) + + q.Add("state", fmt.Sprintf("%d", time.Now().UnixNano())) + q.Add("nonce", fmt.Sprintf("%d", time.Now().UnixNano())) + q.Add("scope", scope) + q.Add("response_type", "code") + q.Add("audience", audience) + q.Add("code_challenge", challengeEncoded) + q.Add("code_challenge_method", "S256") + + uri, err := makeRedirectURI(redirectHost, listenPort) + if err != nil { + return err + } + + q.Add("redirect_uri", uri.String()) + + authURLVal, _ := url.Parse(authURL) + authURLVal.RawQuery = q.Encode() + + browserBase := authURLVal + + errCh := make(chan error, 1) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", listenPort), + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + MaxHeaderBytes: 1 << 20, // Max header of 1MB + Handler: http.HandlerFunc(makeCodeCallbackHandler(cancel, errCh, verifierEncoded, clientID, uri.String())), + } + + go func() { + fmt.Printf("Starting local token server on port %d\n", listenPort) + if err := server.ListenAndServe(); err != nil { + if err != http.ErrServerClosed { + panic(err) + } + } + }() + + defer server.Shutdown(context) + + fmt.Printf("Launching browser: %s\n", browserBase) + if launchBrowser { + err := launchURL(browserBase.String()) + if err != nil { + return errors.Wrap(err, "unable to launch browser") + } + } + + select { + case <-context.Done(): + server.Shutdown(context) + case serverErr := <-errCh: + if serverErr != nil { + return serverErr + } + } + return nil } @@ -208,7 +323,7 @@ func authClientCredentials() error { if res.StatusCode != http.StatusOK { return fmt.Errorf("cannot authenticate, code: %d.\nResponse: %s", res.StatusCode, string(tokenData)) } - token := ClientCredentialsToken{} + token := AuthToken{} tokenErr := json.Unmarshal(tokenData, &token) if tokenErr != nil { return errors.Wrapf(tokenErr, "unable to unmarshal token: %s", string(tokenData)) @@ -254,6 +369,70 @@ func printExampleTokenUsage(gateway, token string) { } +func makeCodeCallbackHandler(cancel context.CancelFunc, errCh chan error, verifierEncoded, clientID, redirectURI string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + fmt.Println(r.URL) + + if r.URL.Path == "/oauth/callback" { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + v := url.Values{} + v.Add("code", code) + v.Add("state", state) + v.Add("code_verifier", verifierEncoded) + v.Add("grant_type", "authorization_code") + v.Add("client_id", clientID) + v.Add("redirect_uri", redirectURI) + + u, err := url.Parse(tokenURL) + if err != nil { + errCh <- err + return + } + + buf := bytes.NewBufferString(v.Encode()) + req, err := http.NewRequest(http.MethodPost, u.String(), buf) + if err != nil { + errCh <- err + return + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + res, err := http.DefaultClient.Do(req) + if err != nil { + errCh <- err + return + } + + tokenData, _ := ioutil.ReadAll(res.Body) + + if res.StatusCode != http.StatusOK { + errCh <- fmt.Errorf("cannot authenticate, code: %d.\nResponse: %s", res.StatusCode, string(tokenData)) + return + } + + token := AuthToken{} + tokenErr := json.Unmarshal(tokenData, &token) + if tokenErr != nil { + errCh <- errors.Wrapf(tokenErr, "unable to unmarshal token: %s", string(tokenData)) + return + } + + if err := config.UpdateAuthConfig(gateway, token.IDToken, config.Oauth2AuthType); err != nil { + errCh <- err + return + } + fmt.Println("credentials saved for", gateway) + printExampleTokenUsage(gateway, token.IDToken) + + errCh <- nil + } + + } +} + func makeCallbackHandler(cancel context.CancelFunc) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { @@ -318,9 +497,11 @@ type ClientCredentialsReq struct { GrantType string `json:"grant_type"` } -type ClientCredentialsToken struct { +type AuthToken struct { AccessToken string `json:"access_token"` - Scope string `json:"scope"` - ExpiresIn int `json:"expires_in"` - TokenType string `json:"token_type"` + IDToken string `json:"id_token"` + + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` } diff --git a/commands/auth_test.go b/commands/auth_test.go index 1267cd2b..f99194f7 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -1,5 +1,7 @@ -// Copyright (c) OpenFaaS Author(s) 2020. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// Copyright (c) OpenFaaS Ltd 2021. All rights reserved. +// +// Licensed for use with OpenFaaS Pro only +// See EULA: https://github.com/openfaas/faas/blob/master/pro/EULA.md package commands @@ -12,6 +14,7 @@ func Test_auth(t *testing.T) { testCases := []struct { name string authURL string + eula bool clientID string wantErr string }{ @@ -20,31 +23,42 @@ func Test_auth(t *testing.T) { authURL: "", clientID: "", wantErr: "--auth-url is required and must be a valid OIDC URL", + eula: true, }, { name: "Invalid auth-url", authURL: "xyz", clientID: "", wantErr: "--auth-url is an invalid URL: xyz", + eula: true, + }, + { + name: "Invalid eula acceptance", + authURL: "http://xyz", + clientID: "id", + wantErr: "the auth command is only licensed for OpenFaaS Pro customers, see: https://github.com/openfaas/faas/blob/master/pro/EULA.md", + eula: false, }, { name: "Valid auth-url, invalid client-id", authURL: "http://xyz", clientID: "", wantErr: "--client-id is required", + eula: true, }, { name: "Valid auth-url and client-id", authURL: "http://xyz", clientID: "abc", wantErr: "", + eula: true, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - err := checkValues(testCase.authURL, testCase.clientID) + err := checkValues(testCase.authURL, testCase.clientID, testCase.eula) gotErr := "" if err != nil { gotErr = err.Error()