Files
faas-cli/commands/auth.go
Alex Ellis (OpenFaaS Ltd) ad610f1071 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>
2021-12-03 11:30:13 +00:00

508 lines
13 KiB
Go

// 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"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/openfaas/faas-cli/config"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var (
scope string
authURL string
tokenURL string
clientID string
audience string
listenPort int
launchBrowser bool
eula bool
grant string
clientSecret string
redirectHost string
)
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")
authCmd.Flags().StringVar(&clientSecret, "client-secret", "", "OAuth2 client_secret, for use with client_credentials grant")
faasCmd.AddCommand(authCmd)
}
var authCmd = &cobra.Command{
Use: `auth --auth-url AUTH_URL | --client-id CLIENT_ID --scope SCOPE
[--audience AUDIENCE]
[--launch-browser LAUNCH_BROWSER]
[--client-secret]
[--grant GRANT]`,
Short: "Obtain a token for your OpenFaaS gateway",
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,
}
func preRunAuth(cmd *cobra.Command, args []string) error {
return checkValues(authURL,
clientID,
eula,
)
}
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")
}
u, uErr := url.Parse(authURL)
if uErr != nil {
return fmt.Errorf("--auth-url is an invalid URL: %s", uErr.Error())
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("--auth-url is an invalid URL: %s", u.String())
}
if len(clientID) == 0 {
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
}
func runAuth(cmd *cobra.Command, args []string) error {
if grant == "implicit" {
return authImplicit("token")
} else if grant == "implicit-id" {
return authImplicit("id_token")
} 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
}
func authImplicit(grant string) error {
context, cancel := context.WithCancel(context.TODO())
defer cancel()
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(makeCallbackHandler(cancel)),
}
go func() {
fmt.Printf("Starting local token server on port %d\n", listenPort)
if err := server.ListenAndServe(); err != nil {
panic(err)
}
select {
case <-context.Done():
break
}
}()
defer server.Shutdown(context)
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("response_type", grant)
q.Add("scope", scope)
q.Add("&response_mode", "fragment")
q.Add("audience", audience)
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
fmt.Printf("Launching browser: %s\n", browserBase)
if launchBrowser {
err := launchURL(browserBase.String())
if err != nil {
return errors.Wrap(err, "unable to launch browser")
}
}
<-context.Done()
return nil
}
func makeRedirectURI(host string, port int) (*url.URL, error) {
val := fmt.Sprintf("%s/oauth/callback", fmt.Sprintf("%s:%d", host, port))
res, err := url.Parse(val)
if err != nil {
return res, err
}
if st := res.String(); !(strings.HasPrefix(st, "http://") || strings.HasPrefix(st, "https://")) {
return res, fmt.Errorf("a scheme is required for the URL, i.e. http://")
}
return res, err
}
func authClientCredentials() error {
body := ClientCredentialsReq{
ClientID: clientID,
ClientSecret: clientSecret,
Audience: audience,
GrantType: grant,
}
bodyBytes, marshalErr := json.Marshal(body)
if marshalErr != nil {
return errors.Wrapf(marshalErr, "unable to unmarshal %s", string(bodyBytes))
}
buf := bytes.NewBuffer(bodyBytes)
req, _ := http.NewRequest(http.MethodPost, authURL, buf)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("cannot POST to %s", authURL))
}
if res.Body != nil {
defer res.Body.Close()
tokenData, _ := ioutil.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return fmt.Errorf("cannot authenticate, code: %d.\nResponse: %s", res.StatusCode, string(tokenData))
}
token := AuthToken{}
tokenErr := json.Unmarshal(tokenData, &token)
if tokenErr != nil {
return errors.Wrapf(tokenErr, "unable to unmarshal token: %s", string(tokenData))
}
if err := config.UpdateAuthConfig(gateway, token.AccessToken, config.Oauth2AuthType); err != nil {
return err
}
fmt.Println("credentials saved for", gateway)
printExampleTokenUsage(gateway, token.AccessToken)
}
return nil
}
// launchURL opens a URL with the default browser for Linux, MacOS or Windows.
func launchURL(serverURL string) error {
ctx := context.Background()
var command *exec.Cmd
switch runtime.GOOS {
case "linux":
command = exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(`xdg-open "%s"`, serverURL))
case "darwin":
command = exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(`open "%s"`, serverURL))
case "windows":
escaped := strings.Replace(serverURL, "&", "^&", -1)
command = exec.CommandContext(ctx, "cmd", "/c", fmt.Sprintf(`start %s`, escaped))
}
command.Stdout = os.Stdout
command.Stdin = os.Stdin
command.Stderr = os.Stderr
return command.Run()
}
func printExampleTokenUsage(gateway, token string) {
fmt.Printf(`Example usage:
# Use an explicit token
faas-cli list --gateway "%s" --token "%s"
# Use the saved token
faas-cli list --gateway "%s"
`, gateway, token, gateway)
}
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) {
if v := r.URL.Query().Get("fragment"); len(v) > 0 {
q, err := url.ParseQuery(v)
if err != nil {
panic(errors.Wrap(err, "unable to parse fragment response from browser redirect"))
}
key := "id_token"
if token := q.Get(key); len(token) > 0 {
if err := config.UpdateAuthConfig(gateway, token, config.Oauth2AuthType); err != nil {
fmt.Printf("error while saving authentication token: %s", err.Error())
}
fmt.Println("credentials saved for", gateway)
printExampleTokenUsage(gateway, token)
} else {
fmt.Printf("Unable to detect a valid %s in URL fragment. Check your credentials or contact your administrator.\n", key)
}
cancel()
return
}
if r.Body != nil {
defer r.Body.Close()
}
w.Write([]byte(buildCaptureFragment()))
}
}
func buildCaptureFragment() string {
return `
<html>
<head>
<title>OpenFaaS CLI Authorization flow</title>
<script>
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
console.log(xhttp.responseText)
}
};
// Encode the fragment data which could contain data that is query-string formatted
xhttp.open("GET", "/oauth2/callback?fragment="+encodeURIComponent(document.location.hash.slice(1)), true);
xhttp.send();
</script>
</head>
<body>
Authorization flow complete. Please close this browser window.
</body>
</html>`
}
type ClientCredentialsReq struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Audience string `json:"audience"`
GrantType string `json:"grant_type"`
}
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"`
}