Skip to content

Commit

Permalink
Allow authenticated users without Minder projects to accept credentia…
Browse files Browse the repository at this point in the history
…ls (#3909)

* Allow authenticated users without Minder projects to accept credentials

* Refactor ensureCredentials to enable a common PreRunE for credentials

* Also claim previously enrolled App installs when accepting a first invite and creating a Minder account
  • Loading branch information
evankanderson authored Jul 18, 2024
1 parent 078e38d commit e017d7d
Show file tree
Hide file tree
Showing 13 changed files with 415 additions and 275 deletions.
35 changes: 2 additions & 33 deletions cmd/cli/app/auth/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,16 @@ package auth

import (
"context"
_ "embed"
"errors"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/stacklok/minder/internal/config"
clientconfig "github.com/stacklok/minder/internal/config/client"
"github.com/stacklok/minder/internal/util"
"github.com/stacklok/minder/internal/util/cli"
minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

//go:embed html/login_success.html
var loginSuccessHtml []byte

//go:embed html/access_denied.html
var accessDeniedHtml []byte

//go:embed html/generic_failure.html
var genericAuthFailure []byte

// loginCmd represents the login command
var loginCmd = &cobra.Command{
Use: "login",
Expand All @@ -56,32 +44,13 @@ func LoginCommand(cmd *cobra.Command, _ []string) error {
if err != nil {
return cli.MessageAndError("Unable to read config", err)
}

skipBrowser := viper.GetBool("login.skip-browser")

// No longer print usage on returned error, since we've parsed our inputs
// See https://github.com/spf13/cobra/issues/340#issuecomment-374617413
cmd.SilenceUsage = true

// wait for the token to be received
var loginErr loginError
token, err := Login(ctx, cmd, clientConfig, nil, skipBrowser)
if errors.As(err, &loginErr) && loginErr.isAccessDenied() {
cmd.Println("Access denied. Please run the command again and accept the terms and conditions.")
return nil
}
if err != nil {
return err
}

// save credentials
filePath, err := util.SaveCredentials(util.OpenIdCredentials{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
AccessTokenExpiresAt: token.Expiry,
})
filePath, err := cli.LoginAndSaveCreds(ctx, cmd, clientConfig)
if err != nil {
cmd.PrintErrf("couldn't save credentials: %s\n", err)
return cli.MessageAndError("Error ensuring credentials", err)
}

conn, err := cli.GrpcForCommand(viper.GetViper())
Expand Down
2 changes: 1 addition & 1 deletion cmd/cli/app/auth/auth_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TokenCommand(cmd *cobra.Command, _ []string) error {
cmd.Printf("Error getting token: %v\n", err)
if errors.Is(err, os.ErrNotExist) || errors.Is(err, util.ErrGettingRefreshToken) {
// wait for the token to be received
token, err := Login(ctx, cmd, clientConfig, []string{}, skipBrowser)
token, err := cli.Login(ctx, cmd, clientConfig, []string{}, skipBrowser)
if err != nil {
return err
}
Expand Down
192 changes: 0 additions & 192 deletions cmd/cli/app/auth/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,17 @@ package auth

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"

"github.com/gorilla/securecookie"
"github.com/pkg/browser"
"github.com/spf13/cobra"
"github.com/zitadel/oidc/v3/pkg/client/rp"
httphelper "github.com/zitadel/oidc/v3/pkg/http"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

"github.com/stacklok/minder/cmd/cli/app"
clientconfig "github.com/stacklok/minder/internal/config/client"
mcrypto "github.com/stacklok/minder/internal/crypto"
"github.com/stacklok/minder/internal/util"
"github.com/stacklok/minder/internal/util/cli"
"github.com/stacklok/minder/internal/util/cli/table"
"github.com/stacklok/minder/internal/util/cli/table/layouts"
"github.com/stacklok/minder/internal/util/rand"
minderv1 "github.com/stacklok/minder/pkg/api/protobuf/go/minder/v1"
)

Expand Down Expand Up @@ -116,181 +102,3 @@ func getProjectTableRows(projects []*minderv1.ProjectRole) [][]string {
}
return rows
}

type loginError struct {
ErrorType string
Description string
}

func (e loginError) Error() string {
return fmt.Sprintf("Error: %s\nDescription: %s\n", e.ErrorType, e.Description)
}

func (e loginError) isAccessDenied() bool {
return e.ErrorType == "access_denied"
}

func writeError(w http.ResponseWriter, loginerr loginError) (string, error) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Type", "text/html; charset=utf-8")

htmlPage := genericAuthFailure
msg := "Access Denied."

if loginerr.isAccessDenied() {
htmlPage = accessDeniedHtml
msg = "Access Denied. Please accept the terms and conditions"
}

_, err := w.Write(htmlPage)
if err != nil {
return msg, err
}
return "", nil
}

// Login is a helper function to handle the login process
// and return the access token
func Login(
ctx context.Context,
cmd *cobra.Command,
cfg *clientconfig.Config,
extraScopes []string,
skipBroswer bool,
) (*oidc.Tokens[*oidc.IDTokenClaims], error) {
issuerUrlStr := cfg.Identity.CLI.IssuerUrl
clientID := cfg.Identity.CLI.ClientId

parsedURL, err := url.Parse(issuerUrlStr)
if err != nil {
return nil, cli.MessageAndError("Error parsing issuer URL", err)
}

issuerUrl := parsedURL.JoinPath("realms/stacklok")
scopes := []string{"openid", "minder-audience"}

if len(extraScopes) > 0 {
scopes = append(scopes, extraScopes...)
}

callbackPath := "/auth/callback"

errChan := make(chan loginError)

errorHandler := func(w http.ResponseWriter, _ *http.Request, errorType string, errorDesc string, _ string) {
loginerr := loginError{
ErrorType: errorType,
Description: errorDesc,
}

msg, writeErr := writeError(w, loginerr)
if writeErr != nil {
// if we cannot display the access denied page, just print an error message
cmd.Println(msg)
}
errChan <- loginerr
}

// create encrypted cookie handler to mitigate CSRF attacks
hashKey := securecookie.GenerateRandomKey(32)
encryptKey := securecookie.GenerateRandomKey(32)
cookieHandler := httphelper.NewCookieHandler(hashKey, encryptKey, httphelper.WithUnsecure(),
httphelper.WithSameSite(http.SameSiteLaxMode))
options := []rp.Option{
rp.WithCookieHandler(cookieHandler),
rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
rp.WithPKCE(cookieHandler),
rp.WithErrorHandler(errorHandler),
}

// Get random port
port, err := rand.GetRandomPort()
if err != nil {
return nil, cli.MessageAndError("Error getting random port", err)
}

parsedURL, err = url.Parse(fmt.Sprintf("http://localhost:%v", port))
if err != nil {
return nil, cli.MessageAndError("Error parsing callback URL", err)
}
redirectURI := parsedURL.JoinPath(callbackPath)

provider, err := rp.NewRelyingPartyOIDC(ctx, issuerUrl.String(), clientID, "", redirectURI.String(), scopes, options...)
if err != nil {
return nil, cli.MessageAndError("Error creating relying party", err)
}

stateFn := func() string {
state, err := mcrypto.GenerateNonce()
if err != nil {
cmd.PrintErrln("error generating state for login")
os.Exit(1)
}
return state
}

tokenChan := make(chan *oidc.Tokens[*oidc.IDTokenClaims])

callback := func(w http.ResponseWriter, _ *http.Request,
tokens *oidc.Tokens[*oidc.IDTokenClaims], _ string, _ rp.RelyingParty) {

tokenChan <- tokens
// send a success message to the browser
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err := w.Write(loginSuccessHtml)
if err != nil {
// if we cannot display the success page, just print a success message
cmd.Println("Authentication Successful")
}
}

http.Handle("/login", rp.AuthURLHandler(stateFn, provider))
http.Handle(callbackPath, rp.CodeExchangeHandler(callback, provider))

server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
ReadHeaderTimeout: time.Second * 10,
}
// Start the server in a goroutine
go func() {
err := server.ListenAndServe()
// ignore error if it's just a graceful shutdown
if err != nil && !errors.Is(err, http.ErrServerClosed) {
cmd.Printf("Error starting server: %v\n", err)
}
}()

defer server.Shutdown(ctx)

// get the OAuth authorization URL
loginUrl := fmt.Sprintf("http://localhost:%v/login", port)

if !skipBroswer {
// Redirect user to provider to log in
cmd.Printf("Your browser will now be opened to: %s\n", loginUrl)

// open user's browser to login page
if err := browser.OpenURL(loginUrl); err != nil {
cmd.Printf("You may login by pasting this URL into your browser: %s\n", loginUrl)
}
} else {
cmd.Printf("Skipping browser login. You may login by pasting this URL into your browser: %s\n", loginUrl)
}

cmd.Println("Please follow the instructions on the page to log in.")

cmd.Println("Waiting for token...")

// wait for the token to be received
var token *oidc.Tokens[*oidc.IDTokenClaims]
var loginErr error

select {
case token = <-tokenChan:
break
case loginErr = <-errChan:
break
}

return token, loginErr
}
11 changes: 6 additions & 5 deletions cmd/cli/app/auth/invite/invite_accept.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ import (

// inviteAcceptCmd represents the accept command
var inviteAcceptCmd = &cobra.Command{
Use: "accept",
Short: "Accept a pending invitation",
Long: `Accept a pending invitation for the current minder user`,
RunE: cli.GRPCClientWrapRunE(inviteAcceptCommand),
Args: cobra.ExactArgs(1),
Use: "accept",
Short: "Accept a pending invitation",
Long: `Accept a pending invitation for the current minder user`,
PreRunE: cli.EnsureCredentials,
RunE: cli.GRPCClientWrapRunE(inviteAcceptCommand),
Args: cobra.ExactArgs(1),
}

// inviteAcceptCommand is the "invite accept" subcommand
Expand Down
11 changes: 6 additions & 5 deletions cmd/cli/app/auth/invite/invite_decline.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ import (

// inviteDeclineCmd represents the decline command
var inviteDeclineCmd = &cobra.Command{
Use: "decline",
Short: "Declines a pending invitation",
Long: `Declines a pending invitation for the current minder user`,
RunE: cli.GRPCClientWrapRunE(inviteDeclineCommand),
Args: cobra.ExactArgs(1),
Use: "decline",
Short: "Declines a pending invitation",
Long: `Declines a pending invitation for the current minder user`,
PreRunE: cli.EnsureCredentials,
RunE: cli.GRPCClientWrapRunE(inviteDeclineCommand),
Args: cobra.ExactArgs(1),
}

// inviteDeclineCommand is the "invite decline" subcommand
Expand Down
3 changes: 1 addition & 2 deletions cmd/cli/app/auth/offline_token/offline_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (
"github.com/spf13/viper"
"google.golang.org/grpc"

"github.com/stacklok/minder/cmd/cli/app/auth"
"github.com/stacklok/minder/internal/config"
clientconfig "github.com/stacklok/minder/internal/config/client"
"github.com/stacklok/minder/internal/util/cli"
Expand Down Expand Up @@ -60,7 +59,7 @@ func offlineGetCommand(ctx context.Context, cmd *cobra.Command, _ []string, _ *g
cmd.SilenceUsage = true

// wait for the token to be received
token, err := auth.Login(ctx, cmd, clientConfig, []string{"offline_access"}, skipBrowser)
token, err := cli.Login(ctx, cmd, clientConfig, []string{"offline_access"}, skipBrowser)
if err != nil {
return err
}
Expand Down
Loading

0 comments on commit e017d7d

Please sign in to comment.