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) <alexellis2@gmail.com>
This commit is contained in:
Alex Ellis (OpenFaaS Ltd)
2021-12-03 10:19:13 +00:00
committed by Alex Ellis
parent b562392b12
commit ad610f1071
5 changed files with 231 additions and 23 deletions

View File

@@ -27,7 +27,7 @@ RUN test -z "$(gofmt -l $(find . -type f -name '*.go' -not -path "./vendor/*"))"
# ldflags "-s -w" strips binary # ldflags "-s -w" strips binary
# ldflags -X injects commit version into 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} \ 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 go test $(go list ./... | grep -v /vendor/ | grep -v /template/|grep -v /build/|grep -v /sample/) -cover

10
LICENSE
View File

@@ -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 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -83,7 +83,7 @@ The main commands supported by the CLI are:
* `faas-cli secret` - manage secrets for your functions * `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 * `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` #### `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 ##### `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`. 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 ```sh
faas-cli auth \ faas-cli auth \
--auth-url https://tenant0.eu.auth0.com/authorize \ --auth-url https://tenant0.eu.auth0.com/authorize \
--token-url https://tenant0.eu.auth0.com/oauth/token \
--audience http://gw.example.com \ --audience http://gw.example.com \
--client-id "${OAUTH_CLIENT_ID}" --client-id "${OAUTH_CLIENT_ID}"
``` ```
@@ -402,4 +405,6 @@ See [contributing guide](https://github.com/openfaas/faas-cli/blob/master/CONTRI
### License ### 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.

View File

@@ -1,11 +1,16 @@
// Copyright (c) OpenFaaS Author(s) 2017. All rights reserved. // Copyright (c) OpenFaaS Ltd 2021. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information. //
// Licensed for use with OpenFaaS Pro only
// See EULA: https://github.com/openfaas/faas/blob/master/pro/EULA.md
package commands package commands
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
@@ -23,12 +28,15 @@ import (
) )
var ( var (
scope string scope string
authURL string authURL string
tokenURL string
clientID string clientID string
audience string audience string
listenPort int listenPort int
launchBrowser bool launchBrowser bool
eula bool
grant string grant string
clientSecret string clientSecret string
redirectHost string redirectHost string
@@ -37,11 +45,14 @@ var (
func init() { func init() {
authCmd.Flags().StringVarP(&gateway, "gateway", "g", defaultGateway, "Gateway URL starting with http(s)://") 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(&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().StringVar(&clientID, "client-id", "", "OAuth2 client_id")
authCmd.Flags().IntVar(&listenPort, "listen-port", 31111, "OAuth2 local port for receiving cookie") authCmd.Flags().IntVar(&listenPort, "listen-port", 31111, "OAuth2 local port for receiving cookie")
authCmd.Flags().StringVar(&audience, "audience", "", "OAuth2 audience") authCmd.Flags().StringVar(&audience, "audience", "", "OAuth2 audience")
authCmd.Flags().BoolVar(&launchBrowser, "launch-browser", true, "Launch browser for OAuth2 redirect") 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().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(&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") 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] [--client-secret]
[--grant GRANT]`, [--grant GRANT]`,
Short: "Obtain a token for your OpenFaaS gateway", Short: "Obtain a token for your OpenFaaS gateway",
Long: "Authenticate to an OpenFaaS gateway using OAuth2.", Long: `Authenticate to an OpenFaaS gateway using OIDC.
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`, 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, RunE: runAuth,
PreRunE: preRunAuth, PreRunE: preRunAuth,
} }
@@ -67,10 +89,11 @@ var authCmd = &cobra.Command{
func preRunAuth(cmd *cobra.Command, args []string) error { func preRunAuth(cmd *cobra.Command, args []string) error {
return checkValues(authURL, return checkValues(authURL,
clientID, clientID,
eula,
) )
} }
func checkValues(authURL, clientID string) error { func checkValues(authURL, clientID string, eula bool) error {
if len(authURL) == 0 { if len(authURL) == 0 {
return fmt.Errorf("--auth-url is required and must be a valid OIDC URL") 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") 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 return nil
} }
@@ -99,6 +126,94 @@ func runAuth(cmd *cobra.Command, args []string) error {
} else if grant == "client_credentials" { } else if grant == "client_credentials" {
return authClientCredentials() 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 return nil
} }
@@ -208,7 +323,7 @@ func authClientCredentials() error {
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
return fmt.Errorf("cannot authenticate, code: %d.\nResponse: %s", res.StatusCode, string(tokenData)) return fmt.Errorf("cannot authenticate, code: %d.\nResponse: %s", res.StatusCode, string(tokenData))
} }
token := ClientCredentialsToken{} token := AuthToken{}
tokenErr := json.Unmarshal(tokenData, &token) tokenErr := json.Unmarshal(tokenData, &token)
if tokenErr != nil { if tokenErr != nil {
return errors.Wrapf(tokenErr, "unable to unmarshal token: %s", string(tokenData)) 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) { func makeCallbackHandler(cancel context.CancelFunc) func(w http.ResponseWriter, r *http.Request) {
return 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"` GrantType string `json:"grant_type"`
} }
type ClientCredentialsToken struct { type AuthToken struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
Scope string `json:"scope"` IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"` Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
} }

View File

@@ -1,5 +1,7 @@
// Copyright (c) OpenFaaS Author(s) 2020. All rights reserved. // Copyright (c) OpenFaaS Ltd 2021. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information. //
// Licensed for use with OpenFaaS Pro only
// See EULA: https://github.com/openfaas/faas/blob/master/pro/EULA.md
package commands package commands
@@ -12,6 +14,7 @@ func Test_auth(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
authURL string authURL string
eula bool
clientID string clientID string
wantErr string wantErr string
}{ }{
@@ -20,31 +23,42 @@ func Test_auth(t *testing.T) {
authURL: "", authURL: "",
clientID: "", clientID: "",
wantErr: "--auth-url is required and must be a valid OIDC URL", wantErr: "--auth-url is required and must be a valid OIDC URL",
eula: true,
}, },
{ {
name: "Invalid auth-url", name: "Invalid auth-url",
authURL: "xyz", authURL: "xyz",
clientID: "", clientID: "",
wantErr: "--auth-url is an invalid URL: xyz", 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", name: "Valid auth-url, invalid client-id",
authURL: "http://xyz", authURL: "http://xyz",
clientID: "", clientID: "",
wantErr: "--client-id is required", wantErr: "--client-id is required",
eula: true,
}, },
{ {
name: "Valid auth-url and client-id", name: "Valid auth-url and client-id",
authURL: "http://xyz", authURL: "http://xyz",
clientID: "abc", clientID: "abc",
wantErr: "", wantErr: "",
eula: true,
}, },
} }
for _, testCase := range testCases { for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) { t.Run(testCase.name, func(t *testing.T) {
err := checkValues(testCase.authURL, testCase.clientID) err := checkValues(testCase.authURL, testCase.clientID, testCase.eula)
gotErr := "" gotErr := ""
if err != nil { if err != nil {
gotErr = err.Error() gotErr = err.Error()