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 -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

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
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

View File

@@ -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.

View File

@@ -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"
@@ -25,10 +30,13 @@ import (
var (
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,8 +497,10 @@ type ClientCredentialsReq struct {
GrantType string `json:"grant_type"`
}
type ClientCredentialsToken struct {
type AuthToken struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
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.
// 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()