217 lines
5.9 KiB
Go
217 lines
5.9 KiB
Go
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 ""
|
|
}
|