Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow authenticated users without Minder projects to accept credentials #3909

Merged
merged 5 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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