Finish initial github login flow

Also:
- fix github teams query: fix bug and sort/unique the results
- add IDP display name to github downstream subject
- fix error types returned by LoginFromCallback
- add trace logs to github API results
- update e2e test
- implement placeholder version of refresh for github
This commit is contained in:
Ryan Richard
2024-05-22 21:21:45 -05:00
committed by Joshua Casey
parent ba2d122308
commit 8923704f3c
14 changed files with 453 additions and 275 deletions
+28 -16
View File
@@ -1210,8 +1210,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
})
t.Run("with Supervisor GitHub upstream IDP and browser flow with with form_post automatic authcode delivery to CLI", func(t *testing.T) {
// TODO only skip this test when the GitHub test env vars are not set
t.Skip("always skipping for now, this test is still a work in progress and it always fails at the moment")
testlib.SkipTestWhenGitHubIsUnavailable(t)
testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
@@ -1221,16 +1220,32 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
// Start a fresh browser driver because we don't want to share cookies between the various tests in this file.
browser := browsertest.OpenBrowser(t)
// TODO create clusterrolebinding for expected user and WaitForUserToHaveAccess. doesn't matter until login fully works.
expectedUsername := env.SupervisorUpstreamGithub.TestUserUsername + ":" + env.SupervisorUpstreamGithub.TestUserID
expectedGroups := env.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs
// Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster.
testlib.CreateTestClusterRoleBinding(t,
rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedUsername},
rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"},
)
testlib.WaitForUserToHaveAccess(t, expectedUsername, []string{}, &authorizationv1.ResourceAttributes{
Verb: "get",
Group: "",
Version: "v1",
Resource: "namespaces",
})
// Create upstream GitHub provider and wait for it to become ready.
// TODO use return value when calling requireUserCanUseKubectlWithoutAuthenticatingAgain below
_ = testlib.CreateTestGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{
createdProvider := testlib.CreateTestGitHubIdentityProvider(t, idpv1alpha1.GitHubIdentityProviderSpec{
AllowAuthentication: idpv1alpha1.GitHubAllowAuthenticationSpec{
Organizations: idpv1alpha1.GitHubOrganizationsSpec{
Policy: ptr.To(idpv1alpha1.GitHubAllowedAuthOrganizationsPolicyAllGitHubUsers),
},
},
Claims: idpv1alpha1.GitHubClaims{
Username: ptr.To(idpv1alpha1.GitHubUsernameLoginAndID),
Groups: ptr.To(idpv1alpha1.GitHubUseTeamSlugForGroupName),
},
Client: idpv1alpha1.GitHubClientSpec{
SecretName: testlib.CreateGitHubClientCredentialsSecret(t,
env.SupervisorUpstreamGithub.GithubAppClientID,
@@ -1262,8 +1277,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...)
// Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser.
// TODO use return value when calling requireKubectlGetNamespaceOutput below
_ = startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser)
kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser)
// Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form.
browsertest.LoginToUpstreamGitHub(t, browser, env.SupervisorUpstreamGithub)
@@ -1272,16 +1286,14 @@ func TestE2EFullIntegration_Browser(t *testing.T) {
t.Logf("waiting for response page %s", federationDomain.Spec.Issuer)
browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer)))
// TODO When you turn off headless and watch this test run,
// the browser is indeed redirected back to the Supervisor at this point with a code,
// but the Supervisor's callback endpoint does not yet work for github IDPs so it returns an error page,
// and the Supervisor's form_post page is not loaded, so it does not automatically post the callback to the CLI's callback listener.
// The test eventually times out and fails at this point.
// The response page should have done the background fetch() and POST'ed to the CLI's callback.
// It should now be in the "success" state.
formpostExpectSuccessState(t, browser)
// TODO
// formpostExpectSuccessState
// requireKubectlGetNamespaceOutput
// requireUserCanUseKubectlWithoutAuthenticatingAgain
requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan))
requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath,
sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes)
})
t.Run("with multiple IDPs: one OIDC and one LDAP", func(t *testing.T) {
+44 -11
View File
@@ -398,14 +398,27 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub
t.Logf("entering GitHub OTP code")
b.SendKeysToFirstMatch(t, otpSelector, code)
// Keep looping until we get to a page that we do not know how to handle. Then return to allow the test to move on.
for handleOccasionalGithubLoginPage(t, b) {
continue
}
}
// handleOccasionalGithubLoginPage handles the interstitial pages which GitHub might show during a login flow.
// None of these will always happen.
func handleOccasionalGithubLoginPage(t *testing.T, b *Browser) bool {
t.Helper()
t.Log("sleeping for 2 seconds before looking at page title")
time.Sleep(2 * time.Second)
pageTitle := b.Title(t)
t.Logf("saw page title %q", pageTitle)
lowercaseTitle := strings.ToLower(pageTitle)
// Next Github might go to another page asking if you authorize the GitHub App to act on your behalf,
// if this user has never authorized this app.
if strings.HasPrefix(pageTitle, "Authorize ") { // the title is "Authorize <App Name>"
switch {
case strings.HasPrefix(lowercaseTitle, "authorize "): // the title is "Authorize <App Name>"
// Next Github might go to another page asking if you authorize the GitHub App to act on your behalf,
// if this user has never authorized this app.
// Wait for the authorize app page to be rendered.
t.Logf("waiting for GitHub authorize button")
// There are unfortunately two very similar buttons on this page:
@@ -415,16 +428,23 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub
b.WaitForVisibleElements(t, submitAuthorizeAppButtonSelector)
t.Logf("clicking authorize button")
b.ClickFirstMatch(t, submitAuthorizeAppButtonSelector)
return true
t.Log("sleeping for 2 seconds before looking at page title again")
time.Sleep(2 * time.Second)
pageTitle = b.Title(t)
t.Logf("saw page title %q", pageTitle)
}
case strings.HasPrefix(lowercaseTitle, "confirm your account recovery settings"):
// Next Github might occasionally as you to confirm your recovery settings.
// Wait for the page to be rendered.
t.Logf("waiting for GitHub confirm button")
// There are several buttons and links. We want to click this confirm button to confirm our settings:
// <button type="submit" name="type" value="confirmed" class="btn btn-block btn-primary ml-3">Confirm</button>
submitConfirmButtonSelector := "button.btn-primary"
b.WaitForVisibleElements(t, submitConfirmButtonSelector)
t.Logf("clicking confirm button")
b.ClickFirstMatch(t, submitConfirmButtonSelector)
return true
// TODO I only saw this happen once, so I did not get a chance to finish this code. Not sure if it will happen again?
// Next GitHub might ask if we want to configure a passkey for auth.
if strings.HasPrefix(pageTitle, "Passkey TODO GET THIS PAGE TITLE") {
case strings.HasPrefix(lowercaseTitle, "configure passwordless authentication"):
// Next GitHub might occasionally ask if we want to configure a passkey for auth.
// The URL bar shows https://github.com/sessions/trusted-device for this page.
// The link that we want to click looks like this:
// <input class="btn-link" type="submit" value="Don't ask again for this browser">
dontAskAgainLinkSelector := `input[value="Don't ask again for this browser"]`
@@ -434,6 +454,19 @@ func LoginToUpstreamGitHub(t *testing.T, b *Browser, upstream testlib.TestGithub
// Tell it that we do not want to use a passkey.
t.Logf("clicking don't ask again button")
b.ClickFirstMatch(t, dontAskAgainLinkSelector)
return true
case strings.HasPrefix(lowercaseTitle, "server error"):
// Sometimes this happens after the OTP page. Not sure why. The page has a cute cartoon, but no helpful information.
// The URL bar shows https://github.com/sessions/trusted-device for this error page, which is the URL that usually
// asks if you want to configure passwordless authentication (aka passkey).
t.Fatal("Got GitHub server error page during login flow. This is not expected, but is unfortunately unrecoverable.")
return false // we recognized the title, but we don't know how to handle this page because it has no buttons or other way forward
default:
// We did not know how to handle the page given its title.
// Maybe we successfully got through all the interstitial pages and finished the login.
return false
}
}
+20 -10
View File
@@ -112,11 +112,15 @@ type TestLDAPUpstream struct {
}
type TestGithubUpstream struct {
GithubAppClientID string `json:"githubAppClientId"`
GithubAppClientSecret string `json:"githubAppClientSecret"`
TestUserUsername string `json:"testUserUsername"`
TestUserPassword string `json:"testUserPassword"`
TestUserOTPSecret string `json:"testUserOTPSecret"`
GithubAppClientID string `json:"githubAppClientId"`
GithubAppClientSecret string `json:"githubAppClientSecret"`
TestUserUsername string `json:"testUserUsername"` // the "login" attribute value for the user
TestUserPassword string `json:"testUserPassword"`
TestUserOTPSecret string `json:"testUserOTPSecret"`
TestUserID string `json:"testUserID"` // the "id" attribute value for the user
TestUserOrganization string `json:"testUserOrganization"` // an org to which the user belongs
TestUserExpectedTeamNames []string `json:"testUserExpectedTeamNames"`
TestUserExpectedTeamSlugs []string `json:"testUserExpectedTeamSlugs"`
}
// ProxyEnv returns a set of environment variable strings (e.g., to combine with os.Environ()) which set up the configured test HTTP proxy.
@@ -329,11 +333,15 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
}
result.SupervisorUpstreamGithub = TestGithubUpstream{
GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""),
GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""),
TestUserUsername: wantEnv("PINNIPED_TEST_GITHUB_USER_USERNAME", ""),
TestUserPassword: wantEnv("PINNIPED_TEST_GITHUB_USER_PASSWORD", ""),
TestUserOTPSecret: wantEnv("PINNIPED_TEST_GITHUB_USER_OTP_SECRET", ""),
GithubAppClientID: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_ID", ""),
GithubAppClientSecret: wantEnv("PINNIPED_TEST_GITHUB_APP_CLIENT_SECRET", ""),
TestUserUsername: wantEnv("PINNIPED_TEST_GITHUB_USER_USERNAME", ""),
TestUserPassword: wantEnv("PINNIPED_TEST_GITHUB_USER_PASSWORD", ""),
TestUserOTPSecret: wantEnv("PINNIPED_TEST_GITHUB_USER_OTP_SECRET", ""),
TestUserID: wantEnv("PINNIPED_TEST_GITHUB_USERID", ""),
TestUserOrganization: wantEnv("PINNIPED_TEST_GITHUB_ORG", ""),
TestUserExpectedTeamNames: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_NAMES", ""), ",")),
TestUserExpectedTeamSlugs: filterEmpty(strings.Split(wantEnv("PINNIPED_TEST_GITHUB_EXPECTED_TEAM_SLUGS", ""), ",")),
}
sort.Strings(result.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs)
@@ -341,6 +349,8 @@ func loadEnvVars(t *testing.T, result *TestEnv) {
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsCNs)
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserDirectGroupsDNs)
sort.Strings(result.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountNames)
sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamNames)
sort.Strings(result.SupervisorUpstreamGithub.TestUserExpectedTeamSlugs)
}
func (e *TestEnv) HasCapability(cap Capability) bool {
+9 -1
View File
@@ -1,4 +1,4 @@
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testlib
@@ -33,3 +33,11 @@ func SkipTestWhenActiveDirectoryIsUnavailable(t *testing.T, env *TestEnv) {
t.Skip("Active Directory hostname not specified")
}
}
func SkipTestWhenGitHubIsUnavailable(t *testing.T) {
t.Helper()
if IntegrationEnv(t).SupervisorUpstreamGithub.GithubAppClientID == "" {
t.Skip("GitHub test env vars not specified")
}
}