diff --git a/docs/stackit_auth_login.md b/docs/stackit_auth_login.md index e839b499..5175c94b 100644 --- a/docs/stackit_auth_login.md +++ b/docs/stackit_auth_login.md @@ -5,6 +5,7 @@ Logs in to the STACKIT CLI ### Synopsis Logs in to the STACKIT CLI using a user account. +The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account. ``` stackit auth login [flags] diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 90bc05b0..e19f962f 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -15,22 +15,16 @@ func NewCmd(p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Logs in to the STACKIT CLI", - Long: "Logs in to the STACKIT CLI using a user account.", - Args: args.NoArgs, + Long: fmt.Sprintf("%s\n%s", + "Logs in to the STACKIT CLI using a user account.", + "The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account."), + Args: args.NoArgs, Example: examples.Build( examples.NewExample( `Login to the STACKIT CLI. This command will open a browser window where you can login to your STACKIT account`, "$ stackit auth login"), ), RunE: func(cmd *cobra.Command, args []string) error { - p.Warn(fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n", - "Starting on July 9 2024, the new STACKIT Identity Provider (IDP) will be available.", - "On this date, we will release a new version of the STACKIT CLI that will use the new IDP for user authentication.", - "This also means that the user authentication on STACKIT CLI versions released before July 9 2024 is no longer guaranteed to work for all services.", - "Please make sure to update your STACKIT CLI to the latest version after July 9 2024 to ensure that you can continue to use all STACKIT services.", - "You can find more information regarding the new IDP at https://docs.stackit.cloud/stackit/en/release-notes-23101442.html#ReleaseNotes-2024-06-21-identity-provider", - )) - err := auth.AuthorizeUser(p, false) if err != nil { return fmt.Errorf("authorization failed: %w", err) diff --git a/internal/pkg/auth/user_login.go b/internal/pkg/auth/user_login.go index 4101134f..41f85045 100644 --- a/internal/pkg/auth/user_login.go +++ b/internal/pkg/auth/user_login.go @@ -23,13 +23,18 @@ import ( ) const ( - defaultIDPEndpoint = "https://auth.01.idp.eu01.stackit.cloud/oauth" - cliClientID = "stackit-cli-client-id" + defaultIDPEndpoint = "https://accounts.stackit.cloud/oauth/v2" + cliClientID = "stackit-cli-0000-0000-000000000001" loginSuccessPath = "/login-successful" stackitLandingPage = "https://www.stackit.de" htmlTemplatesPath = "templates" loginSuccessfulHTMLFile = "login-successful.html" + + // The IDP doesn't support wildcards for the port, + // so we configure a range of ports from 8000 to 8020 + defaultPort = 8000 + configuredPortRange = 20 ) //go:embed templates/* @@ -60,22 +65,32 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { } } - listener, err := net.Listen("tcp", ":0") - if err != nil { - return fmt.Errorf("bind port for login redirect: %w", err) + var redirectURL string + var listener net.Listener + var listenerErr error + var port int + for i := range configuredPortRange { + port = defaultPort + i + portString := fmt.Sprintf(":%s", strconv.Itoa(port)) + p.Debug(print.DebugLevel, "trying to bind port %d for login redirect", port) + listener, listenerErr = net.Listen("tcp", portString) + if listenerErr == nil { + redirectURL = fmt.Sprintf("http://localhost:%d", port) + p.Debug(print.DebugLevel, "bound port %d for login redirect", port) + break + } + p.Debug(print.DebugLevel, "unable to bind port %d for login redirect: %s", port, listenerErr) } - address, ok := listener.Addr().(*net.TCPAddr) - if !ok { - return fmt.Errorf("assert listener address type to TCP address") + if listenerErr != nil { + return fmt.Errorf("unable to bind port for login redirect, tried from port %d to %d: %w", defaultPort, port, err) } - redirectURL := fmt.Sprintf("http://localhost:%d", address.Port) conf := &oauth2.Config{ ClientID: cliClientID, Endpoint: oauth2.Endpoint{ AuthURL: fmt.Sprintf("%s/authorize", idpEndpoint), }, - Scopes: []string{"openid"}, + Scopes: []string{"openid offline_access email"}, RedirectURL: redirectURL, } @@ -98,7 +113,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { p.Debug(print.DebugLevel, "received request from authentication server") // Close the server only if there was an error - // Otherwise, it will redirect to the succesfull login page + // Otherwise, it will redirect to the successful login page defer func() { if errServer != nil { fmt.Println(errServer) diff --git a/internal/pkg/auth/user_token_flow.go b/internal/pkg/auth/user_token_flow.go index ebb90b2a..a15c33c1 100644 --- a/internal/pkg/auth/user_token_flow.go +++ b/internal/pkg/auth/user_token_flow.go @@ -43,19 +43,18 @@ func (utf *userTokenFlow) RoundTrip(req *http.Request) (*http.Response, error) { } accessTokenValid := false - if accessTokenExpired, err := tokenExpired(utf.accessToken); err != nil { + accessTokenExpired, err := tokenExpired(utf.accessToken) + if err != nil { return nil, fmt.Errorf("check if access token has expired: %w", err) } else if !accessTokenExpired { accessTokenValid = true - } else if refreshTokenExpired, err := tokenExpired(utf.refreshToken); err != nil { - return nil, fmt.Errorf("check if refresh token has expired: %w", err) - } else if !refreshTokenExpired { + } else { utf.printer.Debug(print.DebugLevel, "access token expired, refreshing...") err = refreshTokens(utf) if err == nil { accessTokenValid = true } else { - utf.printer.Debug(print.ErrorLevel, "refresh access token: %v", err) + utf.printer.Debug(print.ErrorLevel, "refresh access token: %w", err) } } @@ -177,9 +176,6 @@ func buildRequestToRefreshTokens(utf *userTokenFlow) (*http.Request, error) { reqQuery.Set("token_format", "jwt") req.URL.RawQuery = reqQuery.Encode() - // without this header, the API returns error "An Authentication object was not found in the SecurityContext" - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - return req, nil } diff --git a/internal/pkg/auth/user_token_flow_test.go b/internal/pkg/auth/user_token_flow_test.go index e5113e67..b014c588 100644 --- a/internal/pkg/auth/user_token_flow_test.go +++ b/internal/pkg/auth/user_token_flow_test.go @@ -28,7 +28,11 @@ func (rt *clientTransport) RoundTrip(req *http.Request) (*http.Response, error) if reqURL == rt.requestURL { return rt.roundTripRequest() } - if fmt.Sprintf("https://%s", reqURL) == fmt.Sprintf("%s/token", defaultIDPEndpoint) { + idpEndpoint, err := getIDPEndpoint() + if err != nil { + rt.t.Fatalf("get IDP endpoint for test: %v", err) + } + if fmt.Sprintf("https://%s", reqURL) == fmt.Sprintf("%s/token", idpEndpoint) { return rt.roundTripRefreshTokens() } rt.t.Fatalf("unexpected request to %q", reqURL) @@ -163,6 +167,7 @@ func TestRoundTrip(t *testing.T) { desc: "tokens expired", accessTokenExpiresAt: time.Now().Add(-time.Hour), refreshTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokensFails: true, // Fails because refresh token is expired isValid: true, expectedReautorizeUserCalled: true, expectedTokensRefreshed: true, @@ -190,9 +195,10 @@ func TestRoundTrip(t *testing.T) { accessTokenExpiresAt: time.Now().Add(-time.Hour), refreshTokenExpiresAt: time.Now().Add(time.Hour), refreshTokenInvalid: true, - isValid: false, - expectedReautorizeUserCalled: false, - expectedTokensRefreshed: false, + refreshTokensFails: true, // Fails because refresh token is invalid + isValid: true, + expectedReautorizeUserCalled: true, + expectedTokensRefreshed: true, // Refreshed during reauthorization }, { desc: "refresh token invalid but unused", @@ -207,6 +213,7 @@ func TestRoundTrip(t *testing.T) { desc: "authorize user fails", accessTokenExpiresAt: time.Now().Add(-time.Hour), refreshTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokensFails: true, // Fails because refresh token is expired authorizeUserFails: true, isValid: false, expectedReautorizeUserCalled: true,