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:
committed by
Alex Ellis
parent
b562392b12
commit
ad610f1071
@@ -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
10
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
|
||||
|
||||
15
README.md
15
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.
|
||||
|
||||
197
commands/auth.go
197
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"
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user