chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user