package oauth import ( "context" "errors" "net" "strconv" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // newManager wires a Manager to the fake GitHub server. By default the browser // auto-opens (driving the callback) and Docker detection is off. func newManager(t *testing.T, f *fakeGitHub) *Manager { t.Helper() cfg := Config{ ClientID: "client-id", ClientSecret: "client-secret", Scopes: []string{"repo"}, Endpoint: f.endpoint(), } m := NewManager(cfg, testLogger()) m.openURL = browserGet m.inDocker = func() bool { return false } return m } func TestAuthenticatePKCEViaBrowser(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, nil) require.NoError(t, err) assert.Nil(t, out, "browser flow completes without a user action") assert.Equal(t, "gho_access", m.AccessToken()) // PKCE must have been exercised end to end. f.mu.Lock() defer f.mu.Unlock() assert.Equal(t, "S256", f.codeChallengeMethod) assert.NotEmpty(t, f.codeChallenge, "authorize must receive a code_challenge") assert.NotEmpty(t, f.codeVerifier, "token exchange must send a code_verifier") assert.Equal(t, []string{"authorization_code"}, f.grants) } func TestAuthenticateRefreshesExpiringGitHubAppToken(t *testing.T) { f := newFakeGitHub(t) // GitHub App: the initial token expires immediately and carries a refresh // token, so the very next read must refresh transparently. f.authToken = "ghu_initial" f.authRefresh = "ghr_refresh" f.authExpires = 1 f.refreshToken = "ghu_refreshed" f.refreshExpires = 3600 m := newManager(t, f) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := m.Authenticate(ctx, nil) require.NoError(t, err) assert.Equal(t, "ghu_refreshed", m.AccessToken(), "expired token must be refreshed") assert.Equal(t, []string{"authorization_code", "refresh_token"}, f.recordedGrants()) } func TestAuthenticateURLElicitation(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) m.openURL = func(string) error { return errors.New("no browser") } prompter := &fakePrompter{ urlCapable: true, onURL: func(_ context.Context, p Prompt) error { return browserGet(p.URL) // user opens the URL and authorizes }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, prompter) require.NoError(t, err) assert.Nil(t, out) assert.Equal(t, "gho_access", m.AccessToken()) prompter.mu.Lock() defer prompter.mu.Unlock() require.Len(t, prompter.urlCalls, 1) assert.NotEmpty(t, prompter.urlCalls[0].URL) } func TestAuthenticateDeclinedPromptFails(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) m.openURL = func(string) error { return errors.New("no browser") } prompter := &fakePrompter{ urlCapable: true, onURL: func(_ context.Context, _ Prompt) error { return ErrPromptDeclined }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err := m.Authenticate(ctx, prompter) require.Error(t, err, "declining the prompt must abort the flow") assert.Empty(t, m.AccessToken()) } func TestAuthenticateUndeliverablePromptFallsBack(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) m.openURL = func(string) error { return errors.New("no browser") } // The client advertised URL elicitation but delivering the prompt fails (a // transport/protocol error, not a user decision). This must degrade to the // manual instructions rather than aborting like a decline does. prompter := &fakePrompter{ urlCapable: true, onURL: func(_ context.Context, _ Prompt) error { return ErrPromptUnavailable }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, prompter) require.NoError(t, err, "an undeliverable prompt must not abort the flow") require.NotNil(t, out) require.NotNil(t, out.UserAction, "an undeliverable prompt must fall back to a user action") assert.NotEmpty(t, out.UserAction.URL) assert.Contains(t, out.UserAction.Message, securityAdvisory) // A concurrent retry while awaiting the user returns the same fallback action. out2, err := m.Authenticate(ctx, nil) require.NoError(t, err) require.NotNil(t, out2.UserAction) assert.Equal(t, out.UserAction.URL, out2.UserAction.URL) // The background flow stayed alive: opening the URL out of band completes it. require.NoError(t, browserGet(out.UserAction.URL)) assert.Equal(t, "gho_access", waitForToken(t, m)) } func TestAuthenticateLastDitchUserAction(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) m.openURL = func(string) error { return errors.New("no browser") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() // No browser and a nil prompter: the only channel left is a user action // returned to the caller. out, err := m.Authenticate(ctx, nil) require.NoError(t, err) require.NotNil(t, out) require.NotNil(t, out.UserAction) assert.NotEmpty(t, out.UserAction.URL) assert.Contains(t, out.UserAction.Message, "open this URL") assert.Contains(t, out.UserAction.Message, securityAdvisory, "missing URL elicitation should trigger the security advisory") // A concurrent retry while awaiting the user returns the same action, not a // second flow. out2, err := m.Authenticate(ctx, nil) require.NoError(t, err) require.NotNil(t, out2.UserAction) assert.Equal(t, out.UserAction.URL, out2.UserAction.URL) // The user opens the URL out of band; the background flow then completes. require.NoError(t, browserGet(out.UserAction.URL)) assert.Equal(t, "gho_access", waitForToken(t, m)) } func TestAuthenticateDeviceFlow(t *testing.T) { f := newFakeGitHub(t) f.deviceToken = "gho_device_token" m := newManager(t, f) // Inside Docker with a random port, PKCE is impossible, so the device flow // is selected. m.inDocker = func() bool { return true } m.openURL = func(string) error { return errors.New("no browser") } prompter := &fakePrompter{urlCapable: true} // shows the code, no action needed ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, prompter) require.NoError(t, err) assert.Nil(t, out) assert.Equal(t, "gho_device_token", m.AccessToken()) assert.Contains(t, f.recordedGrants(), "urn:ietf:params:oauth:grant-type:device_code") } func TestAuthenticateDevicePollingPending(t *testing.T) { f := newFakeGitHub(t) f.deviceToken = "gho_device_token" f.devicePending = 1 // one authorization_pending before success m := newManager(t, f) m.inDocker = func() bool { return true } m.openURL = func(string) error { return errors.New("no browser") } ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() _, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true}) require.NoError(t, err) assert.Equal(t, "gho_device_token", m.AccessToken()) } func TestAuthenticateHeadlessPrefersDeviceFlow(t *testing.T) { f := newFakeGitHub(t) f.deviceToken = "gho_device_token" m := newManager(t, f) // A headless host (no display server) with a random callback port: a PKCE // redirect to this machine's localhost is unreachable from a browser on // another machine, so device flow must be chosen even though the client can // elicit a URL (which would otherwise win over device flow). m.openURL = func(string) error { return errNoDisplay } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true}) require.NoError(t, err) assert.Nil(t, out) assert.Equal(t, "gho_device_token", m.AccessToken()) grants := f.recordedGrants() assert.Contains(t, grants, "urn:ietf:params:oauth:grant-type:device_code") assert.NotContains(t, grants, "authorization_code", "headless host must skip the unreachable PKCE authorization-code flow") } func TestAuthenticateFixedCallbackPortUnavailableIsFatal(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) // Occupy the fixed callback port so the OAuth listener cannot bind it. A held // port could belong to another user's process that would receive the redirect, // so the flow must fail loudly rather than quietly downgrade to device flow. squatter, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer squatter.Close() port := squatter.Addr().(*net.TCPAddr).Port m.config.CallbackPort = port // A browser that would have completed PKCE, proving the abort is caused by the // unavailable port and not by a missing display channel. m.openURL = browserGet ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() out, err := m.Authenticate(ctx, &fakePrompter{urlCapable: true}) require.Error(t, err) assert.Nil(t, out) assert.Contains(t, err.Error(), strconv.Itoa(port)) assert.Empty(t, m.AccessToken()) // The decisive check: no device-code grant was attempted, so the flow did not // silently fall back when the deliberately chosen port was unavailable. assert.Empty(t, f.recordedGrants(), "fixed-port bind failure must not fall back to device flow") } func TestAuthenticateNoTokenInitially(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) assert.False(t, m.HasToken()) assert.Empty(t, m.AccessToken()) } func TestAuthenticateSingleFlight(t *testing.T) { f := newFakeGitHub(t) m := newManager(t, f) // Hold the owner inside begin() (browser open blocks) so a concurrent caller // observes the in-progress flow rather than starting its own. entered := make(chan struct{}) release := make(chan struct{}) var once sync.Once m.openURL = func(u string) error { once.Do(func() { close(entered) }) <-release return browserGet(u) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() ownerDone := make(chan error, 1) go func() { _, err := m.Authenticate(ctx, nil) ownerDone <- err }() <-entered // owner is now mid-flow with status "starting" out, err := m.Authenticate(ctx, nil) require.NoError(t, err) require.NotNil(t, out.UserAction) assert.Contains(t, out.UserAction.Message, "already in progress") close(release) require.NoError(t, <-ownerDone) assert.Equal(t, "gho_access", waitForToken(t, m)) // Exactly one authorization happened despite the concurrent callers. assert.Equal(t, []string{"authorization_code"}, f.recordedGrants()) }