feat(auth): interactive YouTube OAuth code flow
`tapir auth` mints a refresh token for the single Stage-0 user: bind a local redirect listener, print the consent URL (offline access + forced consent so Google returns a refresh token), validate the state param, exchange the code, and persist the refresh token through the SecretStore port. Written fresh on x/oauth2 (ADR-006). Token is never logged or returned. Tests cover exchange, missing-refresh-token rejection, and the full listener flow with httptest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/auth"
|
||||
)
|
||||
|
||||
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
||||
type fakeWriter struct {
|
||||
mu sync.Mutex
|
||||
ref, val string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (w *fakeWriter) Put(ref, value string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.ref, w.val, w.calls = ref, value, w.calls+1
|
||||
return nil
|
||||
}
|
||||
|
||||
// tokenServer fakes Google's token endpoint, returning the given JSON body for
|
||||
// any POST. No live Google contact.
|
||||
func tokenServer(t *testing.T, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
func cfg(srvURL string) auth.Config {
|
||||
return auth.Config{
|
||||
ClientID: "cid",
|
||||
ClientSecret: "csecret",
|
||||
RedirectURL: "http://localhost:18099/callback",
|
||||
TokenRef: "youtube/refresh_token",
|
||||
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchange_PersistsRefreshToken(t *testing.T) {
|
||||
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
||||
defer srv.Close()
|
||||
|
||||
w := &fakeWriter{}
|
||||
if err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code"); err != nil {
|
||||
t.Fatalf("Exchange: %v", err)
|
||||
}
|
||||
if w.ref != "youtube/refresh_token" {
|
||||
t.Errorf("persisted ref = %q", w.ref)
|
||||
}
|
||||
if w.val != "rt-secret" {
|
||||
t.Errorf("persisted token = %q, want rt-secret", w.val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchange_RejectsMissingRefreshToken(t *testing.T) {
|
||||
srv := tokenServer(t, `{"access_token":"at","token_type":"Bearer","expires_in":3600}`)
|
||||
defer srv.Close()
|
||||
|
||||
w := &fakeWriter{}
|
||||
err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code")
|
||||
if err == nil {
|
||||
t.Fatal("want error when no refresh token returned")
|
||||
}
|
||||
if w.calls != 0 {
|
||||
t.Errorf("nothing should be persisted on failure; Put called %d times", w.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// stateRe pulls the CSRF state out of the printed consent URL.
|
||||
var stateRe = regexp.MustCompile(`[?&]state=([a-f0-9]+)`)
|
||||
|
||||
// urlWriter forwards each Write to a channel so the test can read the consent
|
||||
// URL Run prints before it blocks on the redirect.
|
||||
type urlWriter struct{ ch chan string }
|
||||
|
||||
func (w urlWriter) Write(p []byte) (int, error) {
|
||||
w.ch <- string(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestRun_FullFlow(t *testing.T) {
|
||||
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
||||
defer srv.Close()
|
||||
|
||||
// Ensure the fixed redirect port is free before binding.
|
||||
if ln, err := net.Listen("tcp", "localhost:18099"); err == nil {
|
||||
_ = ln.Close()
|
||||
} else {
|
||||
t.Skipf("redirect port 18099 unavailable: %v", err)
|
||||
}
|
||||
|
||||
w := &fakeWriter{}
|
||||
out := urlWriter{ch: make(chan string, 4)}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }()
|
||||
|
||||
// First message carries the consent URL with the state param.
|
||||
var state string
|
||||
select {
|
||||
case msg := <-out.ch:
|
||||
m := stateRe.FindStringSubmatch(msg)
|
||||
if m == nil {
|
||||
t.Fatalf("no state in consent message: %q", msg)
|
||||
}
|
||||
state = m[1]
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for consent URL")
|
||||
}
|
||||
|
||||
// Simulate the browser hitting the local redirect with code + state.
|
||||
resp, err := http.Get("http://localhost:18099/callback?state=" + state + "&code=the-code")
|
||||
if err != nil {
|
||||
t.Fatalf("callback GET: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not complete after callback")
|
||||
}
|
||||
|
||||
if w.val != "rt-secret" {
|
||||
t.Errorf("persisted token = %q, want rt-secret", w.val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RejectsStateMismatch(t *testing.T) {
|
||||
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
||||
defer srv.Close()
|
||||
|
||||
if ln, err := net.Listen("tcp", "localhost:18099"); err == nil {
|
||||
_ = ln.Close()
|
||||
} else {
|
||||
t.Skipf("redirect port 18099 unavailable: %v", err)
|
||||
}
|
||||
|
||||
w := &fakeWriter{}
|
||||
out := urlWriter{ch: make(chan string, 4)}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }()
|
||||
|
||||
select {
|
||||
case <-out.ch: // drain consent URL
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for consent URL")
|
||||
}
|
||||
|
||||
resp, err := http.Get("http://localhost:18099/callback?state=WRONG&code=the-code")
|
||||
if err != nil {
|
||||
t.Fatalf("callback GET: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
if err == nil {
|
||||
t.Fatal("want error on state mismatch")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after bad callback")
|
||||
}
|
||||
if w.calls != 0 {
|
||||
t.Errorf("no token should be persisted on state mismatch; Put called %d times", w.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user