[FB-1379] Create a featurebase subcommand to obtain an auth token (#2079)

* Add CleanOAuthConfig endpoint

We will use this to get the OAuthConfig information, without the client secret, from
FeatureBase without having to have access to the config file. This will be useful
for the auth-token subcommand.

* Add string manipulation utility functions

Go doesn't have native support for these kind of things, so I added this to make it
easier to do string reversal, and replacing the first string encountered from the
end of the string to the front.

* Add auth-token subcommand

This is for work on [FB-1379](https://molecula.atlassian.net/browse/FB-1379).

We need this new auth-token subcommand to allow users to get access and refresh
tokens without having to login to featurebase via the UI. This commit adds that
functionality.

* error on oauth endpoint if auth isn't on

* https as default scheme in cmd, not internalclient
This commit is contained in:
reesporte 2022-05-26 11:35:51 -05:00 committed by GitHub
parent 2d937728fa
commit 8ba81643d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 650 additions and 0 deletions

View file

@ -122,6 +122,13 @@ func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenUR
return auth, nil
}
// CleanOAuthConfig returns a's oauthConfig without the client secret
func (a Auth) CleanOAuthConfig() oauth2.Config {
b := *a.oAuthConfig
b.ClientSecret = ""
return b
}
// SecretKey is a convenient function to get the SecretKey from an Auth struct
func (a Auth) SecretKey() []byte {
return a.secretKey

View file

@ -651,6 +651,22 @@ func (s *ServerTransportStream) SetTrailer(md metadata.MD) error {
return nil
}
func TestCleanOAuthConfig(t *testing.T) {
a := NewTestAuth(t)
res := a.CleanOAuthConfig()
assertEqual("", res.ClientSecret, t)
assertEqual(a.oAuthConfig.ClientID, res.ClientID, t)
assertEqual(a.oAuthConfig.RedirectURL, res.RedirectURL, t)
assertEqual(a.oAuthConfig.Scopes, res.Scopes, t)
assertEqual(a.oAuthConfig.Endpoint, res.Endpoint, t)
}
func assertEqual(exp, got interface{}, t *testing.T) {
if !reflect.DeepEqual(exp, got) {
t.Fatalf("expected %v, got %v", exp, got)
}
}
func TestCheckAllowedNetworks(t *testing.T) {
tests := []struct {

29
cmd/auth_token.go Normal file
View file

@ -0,0 +1,29 @@
// Copyright 2022 Molecula Corp. All rights reserved.
package cmd
import (
"context"
"io"
"github.com/molecula/featurebase/v3/ctl"
"github.com/spf13/cobra"
)
func newAuthTokenCommand(stdin io.Reader, stdout io.Writer, stderr io.Writer) *cobra.Command {
cmd := ctl.NewAuthTokenCommand(stdin, stdout, stderr)
ccmd := &cobra.Command{
Use: "auth-token",
Short: "Get an auth-token",
Long: `
Retrieves an auth-token for use in authenticating with FeatureBase from the configured identity provider.
`,
RunE: func(c *cobra.Command, args []string) error {
return cmd.Run(context.Background())
},
}
flags := ccmd.Flags()
flags.StringVar(&cmd.Host, "host", "https://localhost:10101", "The address (host:port) of FeatureBase (HTTPs).")
ctl.SetTLSConfig(flags, "", &cmd.TLS.CertificatePath, &cmd.TLS.CertificateKeyPath, &cmd.TLS.CACertPath, &cmd.TLS.SkipVerify, &cmd.TLS.EnableClientVerification)
return ccmd
}

View file

@ -57,6 +57,7 @@ at https://docs.molecula.cloud/.
rc.AddCommand(newExportCommand(stdin, stdout, stderr))
rc.AddCommand(newGenerateConfigCommand(stdin, stdout, stderr))
rc.AddCommand(newImportCommand(stdin, stdout, stderr))
rc.AddCommand(newAuthTokenCommand(stdin, stdout, stderr))
rc.AddCommand(newRBFCommand(stdin, stdout, stderr))
rc.AddCommand(newServeCmd(stdin, stdout, stderr))
rc.AddCommand(newHolderCmd(stdin, stdout, stderr))

273
ctl/auth_token.go Normal file
View file

@ -0,0 +1,273 @@
// Copyright 2022 Molecula Corp. All rights reserved.
package ctl
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
pilosa "github.com/molecula/featurebase/v3"
"github.com/molecula/featurebase/v3/server"
"golang.org/x/oauth2"
)
// AuthTokenCommand represents a command for retrieving an auth-token from a
// FeatureBase node.
type AuthTokenCommand struct { // nolint: maligned
// TLS configuration.
TLS server.TLSConfig
tlsConfig *tls.Config
// Host is the host and port of the FeatureBase node to authenticate with.
Host string `json:"host"`
// Reusable client.
client *pilosa.InternalClient
// Standard input/output.
*pilosa.CmdIO
}
// NewAuthTokenCommand returns a new instance of AuthTokenCommand.
func NewAuthTokenCommand(stdin io.Reader, stdout, stderr io.Writer) *AuthTokenCommand {
return &AuthTokenCommand{
CmdIO: pilosa.NewCmdIO(stdin, stdout, stderr),
}
}
type deviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// deviceAuthRequest makes a Device Authorization Request and parses the Device
// Authorization Response as defined in RFC 8628 sections 3.1 and 3.2.
func deviceAuthRequest(cli *http.Client, config oauth2.Config) (rsp *deviceAuthResponse, err error) {
// build the request
req, err := http.NewRequest(
http.MethodPost,
pilosa.ReplaceFirstFromBack(config.Endpoint.AuthURL, "authorize", "devicecode"),
strings.NewReader(
url.Values{
"client_id": {config.ClientID},
"scope": {strings.Join(config.Scopes, " ")},
}.Encode(),
),
)
if err != nil {
return nil, fmt.Errorf("building request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// do the request
dar := &deviceAuthResponse{}
if resp, err := cli.Do(req); err != nil {
return nil, fmt.Errorf("making request with status %d: %v", resp.StatusCode, err)
} else if !(resp.StatusCode >= 200 && resp.StatusCode < 300) {
body, _ := io.ReadAll(resp.Body) // ignore the error bc it's ok if there's no body
return nil, fmt.Errorf(
"unsuccessful with status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
} else if err := json.NewDecoder(resp.Body).Decode(dar); err != nil {
return nil, fmt.Errorf("decoding device auth response body: %w", err)
}
return dar, nil
}
// successResponse represents a successful device auth token reponse.
type successResponse struct {
Access string `json:"access_token"`
Refresh string `json:"refresh_token"`
Type string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
// errorResponse represents a device auth token error reponse.
type errorResponse struct {
Err error
Error string `json:"error"`
Description string `json:"error_description"`
URI string `json:"error_uri"`
}
// waitResponse represents a device auth token reponse that indicates the client
// should continue polling.
type waitResponse struct {
SlowDown bool
}
// parseResponse parses a device auth grant response from the /token endpoint. It
// returns a successResponse on success, an errorResponse on error, or a
// waitResponse if the authorization is still pending or we are requested to slow
// down.
func parseResponse(resp *http.Response) interface{} {
if s := resp.StatusCode; s >= 200 && s < 300 {
rsp := &successResponse{}
if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil {
return errorResponse{Err: fmt.Errorf("reading response body: %w", err)}
}
return *rsp
}
rsp := &errorResponse{}
if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil {
return errorResponse{Err: fmt.Errorf("reading response body: %w", err)}
}
switch rsp.Error {
case "slow_down":
return waitResponse{SlowDown: true}
case "authorization_pending":
return waitResponse{}
default:
rsp.Err = fmt.Errorf("error: %s, description: %s, uri: %s", rsp.Error, rsp.Description, rsp.URI)
return *rsp
}
}
// Run executes the main program execution.
func (cmd *AuthTokenCommand) Run(ctx context.Context) (err error) {
// Parse TLS configuration for node-specific clients.
tls := cmd.TLSConfiguration()
if cmd.tlsConfig, err = server.GetTLSConfig(&tls, cmd.Logger()); err != nil {
return fmt.Errorf("parsing tls config: %w", err)
}
// Create an internal client.
client, err := commandClient(cmd)
if err != nil {
return fmt.Errorf("creating client: %w", err)
}
// Get the OAuth config.
var access, refresh string
config, err := client.OAuthConfig()
if err != nil {
return fmt.Errorf("getting oauth config: %w", err)
}
cli := &http.Client{}
// Make the device authorization request.
dar, err := deviceAuthRequest(cli, config)
if err != nil {
return fmt.Errorf("making device auth request: %w", err)
}
// Prompt the user to visit verification_uri and enter code.
fmt.Printf(formatPromptBox(dar.VerificationURI, dar.UserCode))
// Request a token until success or error response, slowing down if requested.
interval := dar.Interval
var exit bool
for !exit {
time.Sleep(time.Duration(interval) * time.Second)
// Make a device access token request to the token endpoint.
req, err := http.NewRequest(
http.MethodPost,
config.Endpoint.TokenURL,
strings.NewReader(
url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"client_id": {config.ClientID},
"device_code": {dar.DeviceCode},
}.Encode(),
),
)
if err != nil {
return fmt.Errorf("building request to %s: %w", config.Endpoint.TokenURL, err)
}
resp, err := cli.Do(req)
if err != nil {
return fmt.Errorf("making request to %s: %w", config.Endpoint.TokenURL, err)
}
output := parseResponse(resp)
switch o := output.(type) {
case successResponse:
access = o.Access
refresh = o.Refresh
exit = true
case errorResponse:
return o.Err
case waitResponse:
if o.SlowDown {
interval += 5
}
}
}
// Convey the response to the user.
fmt.Printf("\nauth-token: %s\n", access)
fmt.Printf("\nrefresh-token: %s\n", refresh)
return nil
}
// TLSHost implements the CommandWithTLSSupport interface.
func (cmd *AuthTokenCommand) TLSHost() string { return cmd.Host }
// TLSConfiguration implements the CommandWithTLSSupport interface.
func (cmd *AuthTokenCommand) TLSConfiguration() server.TLSConfig { return cmd.TLS }
// formatPromptBox makes a nice little prompt box to hold the url and code.
func formatPromptBox(url, code string) string {
top := "+-----------------------------------------------"
buffer := "| "
width := len(top) + 1
if len(url) >= width {
width = len(url) + 5
}
if len(code) >= width {
width = len(code) + 5
}
urlStr := fmt.Sprintf("| %s", url)
for i := len(urlStr); i < width-1; i++ {
urlStr += " "
}
urlStr += "|"
codeStr := fmt.Sprintf("| %s", code)
for i := len(codeStr); i < width-1; i++ {
codeStr += " "
}
codeStr += "|"
for i := len(top); i < width-1; i++ {
top += "-"
buffer += " "
}
top += "+\n"
buffer += "|\n"
pls := "| Please visit:"
for i := len(pls); i < width-1; i++ {
pls += " "
}
pls += "|\n"
and := "| And enter the code:"
for i := len(and); i < width-1; i++ {
and += " "
}
and += "|\n"
return fmt.Sprintf("%s%s%s%s\n%s%s%s\n%s%s", top, buffer, pls, urlStr, buffer, and, codeStr, buffer, top)
}

View file

@ -0,0 +1,191 @@
package ctl
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"golang.org/x/oauth2"
)
func TestFormatPromptBox(t *testing.T) {
golden := `+----------------------------------------------------+
| |
| Please visit: |
| testingtestingtestingtestingtestingtestingtesting |
| |
| And enter the code: |
| blahblah |
| |
+----------------------------------------------------+
`
got := formatPromptBox("testingtestingtestingtestingtestingtestingtesting", "blahblah")
if got != golden {
t.Fatalf("expected:\n%s, got:\n%s", golden, got)
}
}
type authReqTest struct {
config oauth2.Config
expRsp *deviceAuthResponse
expErr error
}
func TestDeviceAuthRequest(t *testing.T) {
goodClientID := "ring-a-ding-dillo"
goodResponse := deviceAuthResponse{
DeviceCode: "Old knives are long enough as swords for hobbit-people.",
UserCode: "Sharp blades are good to have, if Shire-folk go walking, east, south, or far away into dark and danger.",
VerificationURI: "I am no weather-master, nor is aught that goes on two legs.",
VerificationURIComplete: "Hey! Come merry dol! derry dol! My hearties!",
ExpiresIn: -486846000,
Interval: 9,
}
srv := httptest.NewServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID := r.PostFormValue("client_id")
if clientID != goodClientID {
http.Error(
w,
"Get out, you old Wight! Vanish in the sunlight!",
http.StatusBadRequest,
)
return
}
b := bytes.Buffer{}
if err := json.NewEncoder(&b).Encode(goodResponse); err != nil {
t.Fatalf("unexpected error encoding goodResponse: %v", err)
}
w.Write(b.Bytes())
w.WriteHeader(http.StatusOK)
}),
)
cli := &http.Client{}
for name, test := range map[string]authReqTest{
"badRequest": {
config: oauth2.Config{
ClientID: "Iarwain Ben-adar",
Endpoint: oauth2.Endpoint{AuthURL: srv.URL},
},
expRsp: nil,
expErr: fmt.Errorf("unsuccessful with status 400: Get out, you old Wight! Vanish in the sunlight!"),
},
"goodRequest": {
config: oauth2.Config{
ClientID: "ring-a-ding-dillo",
Endpoint: oauth2.Endpoint{AuthURL: srv.URL},
},
expRsp: &goodResponse,
expErr: nil,
},
} {
t.Run(name, func(t *testing.T) {
got, err := deviceAuthRequest(cli, test.config)
if !errEqual(err, test.expErr) {
t.Errorf("expected '%v', got '%v'", test.expErr, err)
}
if !reflect.DeepEqual(got, test.expRsp) {
t.Errorf("expected '%v', got '%v'", test.expRsp, got)
}
})
}
}
type respTest struct {
resp *http.Response
exp interface{}
}
func TestParseResponse(t *testing.T) {
sr := successResponse{
Access: "ACCESS",
Refresh: "REFRESH",
Type: "access",
ExpiresIn: 10000,
Scope: "scopity-scope-scopity-scope-pope",
}
good := bytes.Buffer{}
if err := json.NewEncoder(&good).Encode(sr); err != nil {
t.Fatalf("unexpected error: %v", err)
}
sd := errorResponse{Error: "slow_down"}
slowDown := bytes.Buffer{}
if err := json.NewEncoder(&slowDown).Encode(sd); err != nil {
t.Fatalf("unexpected error: %v", err)
}
sd.Error = "authorization_pending"
pending := bytes.Buffer{}
if err := json.NewEncoder(&pending).Encode(sd); err != nil {
t.Fatalf("unexpected error: %v", err)
}
sd.Error = "invalid_client"
genErr := bytes.Buffer{}
if err := json.NewEncoder(&genErr).Encode(sd); err != nil {
t.Fatalf("unexpected error: %v", err)
}
for name, test := range map[string]respTest{
"success": {
resp: &http.Response{
StatusCode: 200,
Body: io.NopCloser(&good),
ContentLength: int64(good.Len()),
},
exp: sr,
},
"slowDown": {
resp: &http.Response{
StatusCode: 400,
Body: io.NopCloser(&slowDown),
ContentLength: int64(slowDown.Len()),
},
exp: waitResponse{SlowDown: true},
},
"pending": {
resp: &http.Response{
StatusCode: 400,
Body: io.NopCloser(&pending),
ContentLength: int64(pending.Len()),
},
exp: waitResponse{},
},
"generalError": {
resp: &http.Response{
StatusCode: 400,
Body: io.NopCloser(&genErr),
ContentLength: int64(pending.Len()),
},
exp: errorResponse{
Error: "invalid_client",
Description: "",
URI: "",
Err: fmt.Errorf("error: %s, description: %s, uri: %s", "invalid_client",
"", ""),
},
},
} {
t.Run(name, func(t *testing.T) {
if got := parseResponse(test.resp); !reflect.DeepEqual(got, test.exp) {
t.Errorf("expected: '%v', got '%v'", test.exp, got)
}
})
}
}
func errEqual(a, b error) bool {
if a == nil {
return b == nil
}
if b == nil {
return a == nil
}
return a.Error() == b.Error()
}

View file

@ -533,6 +533,7 @@ func newRouter(handler *Handler) http.Handler {
router.HandleFunc("/redirect", handler.handleRedirect).Methods("GET").Name("Redirect")
router.HandleFunc("/auth", handler.handleCheckAuthentication).Methods("GET").Name("CheckAuthentication")
router.HandleFunc("/userinfo", handler.handleUserInfo).Methods("GET").Name("UserInfo")
router.HandleFunc("/internal/oauth-config", handler.handleOAuthConfig).Methods("GET").Name("GetOAuthConfig")
// Endpoints to support lattice UI embedded via statik.
// The messiness here reflects the fact that assets live in a nontrivial
@ -3793,6 +3794,26 @@ func (h *Handler) handleRedirect(w http.ResponseWriter, r *http.Request) {
h.auth.Redirect(w, r)
}
// handleOAuthConfig handles requests for a cleaned version of our oAuthConfig. We
// use this endpoint /internal/oauth-config in the `featurebase auth-token`
// subcommand to create a RedirectURL on the fly.
func (h *Handler) handleOAuthConfig(w http.ResponseWriter, r *http.Request) {
if !validHeaderAcceptJSON(r.Header) {
http.Error(w, "JSON only acceptable response", http.StatusNotAcceptable)
return
}
if h.auth == nil {
http.Error(w, "auth not enabled: no OAuthConfig", http.StatusNotFound)
return
}
config := h.auth.CleanOAuthConfig()
if err := json.NewEncoder(w).Encode(config); err != nil {
h.logger.Errorf("writing oauth-config info: %s", err)
}
}
func (h *Handler) handleCheckAuthentication(w http.ResponseWriter, r *http.Request) {
if !validHeaderAcceptJSON(r.Header) {
http.Error(w, "JSON only acceptable response", http.StatusNotAcceptable)

View file

@ -827,6 +827,37 @@ func TestHandleGetDiskUsage(t *testing.T) {
}
}
func TestHandleOAuthConfig(t *testing.T) {
h := Handler{
logger: logger.NewStandardLogger(os.Stdout),
queryLogger: logger.NewStandardLogger(os.Stdout),
auth: NewTestAuth(t),
api: &API{
server: &Server{
dataDir: t.TempDir(),
},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/whatever", nil)
h.handleOAuthConfig(w, r)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected %v, got %v", http.StatusOK, resp.StatusCode)
}
defer resp.Body.Close()
var rsp oauth2.Config
if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil {
t.Fatalf("unexpected error decoding body: %v", err)
}
if exp := h.auth.CleanOAuthConfig(); !reflect.DeepEqual(exp, rsp) {
t.Fatalf("expected %v, got %v", exp, rsp)
}
}
func TestAuthzAllowedIPs(t *testing.T) {
tests := []struct {
configuredIPs []string

View file

@ -27,6 +27,7 @@ import (
"github.com/molecula/featurebase/v3/topology"
"github.com/molecula/featurebase/v3/tracing"
"github.com/pkg/errors"
"golang.org/x/oauth2"
)
// InternalClient represents a client to the Pilosa cluster.
@ -2453,3 +2454,26 @@ func (c *InternalClient) PartitionNodes(ctx context.Context, partitionID int) ([
func (c *InternalClient) SetInternalAPI(api *API) {
c.api = api
}
func (c *InternalClient) OAuthConfig() (rsp oauth2.Config, err error) {
u := uriPathToURL(c.defaultURI, "/internal/oauth-config")
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return rsp, errors.Wrap(err, "creating request")
}
req.Header.Set("User-Agent", "pilosa/"+Version)
req.Header.Set("Accept", "application/json")
resp, err := c.executeRequest(req)
if err != nil {
return rsp, fmt.Errorf("getting config: %w", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&rsp); err != nil {
return rsp, fmt.Errorf("json decode: %s", err)
}
return rsp, nil
}

23
util.go
View file

@ -124,3 +124,26 @@ func EtcdUnixSocket(tb testing.TB) string {
})
return fmt.Sprintf("unix://%s", addr)
}
// Rev reverses a string
func Rev(input string) string {
n := 0
runes := make([]rune, len(input))
for _, r := range input {
runes[n] = r
n++
}
runes = runes[0:n]
for i := 0; i < n/2; i++ {
runes[i], runes[n-1-i] = runes[n-1-i], runes[i]
}
return string(runes)
}
// ReplaceFirstFromBack replaces the first instance of toReplace from the back of
// the string s
func ReplaceFirstFromBack(s, toReplace, replacement string) string {
return Rev(strings.Replace(Rev(s), Rev(toReplace), Rev(replacement), 1))
}

View file

@ -106,3 +106,37 @@ func NewTestClusterWithReplication(tb testing.TB, nNodes, nReplicas, partitionN
c.close()
}
}
func TestReplaceFirstFromBack(t *testing.T) {
for name, test := range map[string]struct {
input string
exp string
toReplace string
replacement string
}{
"url": {
input: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize",
exp: "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/devicecode",
toReplace: "authorize",
replacement: "devicecode",
},
"unicode": {
input: "那不是兽人号角",
exp: "那是一只兽人号角",
toReplace: "不是",
replacement: "是一只",
},
"multiple": {
input: "cowscowscowscowscows",
exp: "cowscowscowscowscats",
toReplace: "cows",
replacement: "cats",
},
} {
t.Run(name, func(t *testing.T) {
if got := ReplaceFirstFromBack(test.input, test.toReplace, test.replacement); got != test.exp {
t.Fatalf("expected %v, got %v", test.exp, got)
}
})
}
}