chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// fakeGitHub is an httptest-backed stand-in for GitHub's OAuth endpoints. It
|
||||
// implements the authorize redirect, the token endpoint (authorization_code,
|
||||
// refresh_token, and device_code grants), and the device-code endpoint, while
|
||||
// recording what it received so tests can assert on real protocol behavior
|
||||
// (PKCE challenge/verifier, grant sequence) rather than re-implementing it.
|
||||
type fakeGitHub struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
grants []string // grant_type of each token request, in order
|
||||
codeChallenge string
|
||||
codeChallengeMethod string
|
||||
codeVerifier string
|
||||
devicePending int // number of authorization_pending responses before success
|
||||
|
||||
// Token values returned per grant. A positive expires field is sent as
|
||||
// expires_in (and makes the token expiring/refreshable).
|
||||
authToken string
|
||||
authRefresh string
|
||||
authExpires int
|
||||
refreshToken string
|
||||
refreshExpires int
|
||||
deviceToken string
|
||||
}
|
||||
|
||||
func newFakeGitHub(t *testing.T) *fakeGitHub {
|
||||
t.Helper()
|
||||
f := &fakeGitHub{
|
||||
authToken: "gho_access",
|
||||
deviceToken: "gho_device",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login/oauth/authorize", f.handleAuthorize)
|
||||
mux.HandleFunc("/login/oauth/access_token", f.handleToken)
|
||||
mux.HandleFunc("/login/device/code", f.handleDeviceCode)
|
||||
|
||||
f.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(f.Server.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) endpoint() oauth2.Endpoint {
|
||||
return oauth2.Endpoint{
|
||||
AuthURL: f.URL + "/login/oauth/authorize",
|
||||
TokenURL: f.URL + "/login/oauth/access_token",
|
||||
DeviceAuthURL: f.URL + "/login/device/code",
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) handleAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f.mu.Lock()
|
||||
f.codeChallenge = q.Get("code_challenge")
|
||||
f.codeChallengeMethod = q.Get("code_challenge_method")
|
||||
f.mu.Unlock()
|
||||
|
||||
redirect := q.Get("redirect_uri") + "?code=authcode&state=" + url.QueryEscape(q.Get("state"))
|
||||
http.Redirect(w, r, redirect, http.StatusFound)
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
grant := r.Form.Get("grant_type")
|
||||
|
||||
f.mu.Lock()
|
||||
f.grants = append(f.grants, grant)
|
||||
switch grant {
|
||||
case "authorization_code":
|
||||
f.codeVerifier = r.Form.Get("code_verifier")
|
||||
f.mu.Unlock()
|
||||
writeToken(w, f.authToken, f.authRefresh, f.authExpires)
|
||||
case "refresh_token":
|
||||
f.mu.Unlock()
|
||||
writeToken(w, f.refreshToken, "", f.refreshExpires)
|
||||
case "urn:ietf:params:oauth:grant-type:device_code":
|
||||
pending := f.devicePending
|
||||
if pending > 0 {
|
||||
f.devicePending--
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if pending > 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "authorization_pending"})
|
||||
return
|
||||
}
|
||||
writeToken(w, f.deviceToken, "", 0)
|
||||
default:
|
||||
f.mu.Unlock()
|
||||
http.Error(w, "unsupported grant_type", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) handleDeviceCode(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"device_code": "devicecode",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": f.URL + "/device",
|
||||
"expires_in": 900,
|
||||
"interval": 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (f *fakeGitHub) recordedGrants() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.grants...)
|
||||
}
|
||||
|
||||
func writeToken(w http.ResponseWriter, access, refresh string, expiresIn int) {
|
||||
body := map[string]any{
|
||||
"access_token": access,
|
||||
"token_type": "bearer",
|
||||
}
|
||||
if refresh != "" {
|
||||
body["refresh_token"] = refresh
|
||||
}
|
||||
if expiresIn != 0 {
|
||||
body["expires_in"] = expiresIn
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// fakePrompter is a configurable Prompter. The on* hooks simulate the user
|
||||
// acting on the prompt; a nil hook means the prompt is shown and acknowledged.
|
||||
type fakePrompter struct {
|
||||
urlCapable bool
|
||||
formCapable bool
|
||||
onURL func(context.Context, Prompt) error
|
||||
onForm func(context.Context, Prompt) error
|
||||
|
||||
mu sync.Mutex
|
||||
urlCalls []Prompt
|
||||
}
|
||||
|
||||
func (p *fakePrompter) CanPromptURL() bool { return p.urlCapable }
|
||||
|
||||
func (p *fakePrompter) PromptURL(ctx context.Context, prompt Prompt) error {
|
||||
p.mu.Lock()
|
||||
p.urlCalls = append(p.urlCalls, prompt)
|
||||
p.mu.Unlock()
|
||||
if p.onURL != nil {
|
||||
return p.onURL(ctx, prompt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *fakePrompter) CanPromptForm() bool { return p.formCapable }
|
||||
|
||||
func (p *fakePrompter) PromptForm(ctx context.Context, prompt Prompt) error {
|
||||
if p.onForm != nil {
|
||||
return p.onForm(ctx, prompt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// browserGet simulates a user completing the authorization-code flow by opening
|
||||
// the URL: it follows the authorize redirect to the local callback, delivering
|
||||
// the code to the manager's callback server. Used both as an openURL seam and
|
||||
// inside prompter hooks.
|
||||
func browserGet(rawurl string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawurl, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// waitForToken polls until the manager has a token or the deadline passes. The
|
||||
// authorization-code flow completes asynchronously after the callback fires, so
|
||||
// tests wait for the resulting token rather than sleeping a fixed duration.
|
||||
func waitForToken(t *testing.T, m *Manager) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if tok := m.AccessToken(); tok != "" {
|
||||
return tok
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out waiting for access token")
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user