260 lines
7.3 KiB
Go
260 lines
7.3 KiB
Go
//go:build unix
|
|
|
|
package authn
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
|
|
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
|
|
)
|
|
|
|
func TestNewTokenSource_RequiresCallback(t *testing.T) {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("expected panic on nil onToken")
|
|
}
|
|
}()
|
|
NewTokenSource(fakekeyring.New(), "gitea",
|
|
Config{Issuer: "https://x", ClientID: "y"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"}, nil)
|
|
}
|
|
|
|
func TestNewTokenSource_RequiresLockPath(t *testing.T) {
|
|
defer func() {
|
|
if recover() == nil {
|
|
t.Fatal("expected panic on empty LockPath")
|
|
}
|
|
}()
|
|
NewTokenSource(fakekeyring.New(), "gitea",
|
|
Config{Issuer: "https://x", ClientID: "y"},
|
|
EnsureOptions{}, func(keyring.TokenSet) {})
|
|
}
|
|
|
|
func TestTokenSource_Start_DeliversInitialToken(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_ = store.Set("gitea", keyring.TokenSet{
|
|
IDToken: "fresh",
|
|
AccessToken: "at-0",
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
|
|
var got []string
|
|
var mu sync.Mutex
|
|
cb := func(ts keyring.TokenSet) {
|
|
mu.Lock()
|
|
got = append(got, ts.AccessToken)
|
|
mu.Unlock()
|
|
}
|
|
|
|
src := NewTokenSource(store, "gitea",
|
|
Config{Issuer: "https://example.test", ClientID: "y"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"}, cb)
|
|
|
|
ts, err := src.Start(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if ts.AccessToken != "at-0" {
|
|
t.Fatalf("Start returned %q, want at-0", ts.AccessToken)
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(got) != 1 || got[0] != "at-0" {
|
|
t.Fatalf("callback got %v, want [at-0]", got)
|
|
}
|
|
}
|
|
|
|
func TestTokenSource_Run_RenewsAndNotifies(t *testing.T) {
|
|
stub := newStubAuthentik(t)
|
|
store := fakekeyring.New()
|
|
// Seed a stale bearer with a still-fresh ID token. This mirrors
|
|
// providers that issue short-lived access tokens and longer-lived ID
|
|
// tokens; refresh must follow the bearer lifetime because MCPs send
|
|
// the access token to services.
|
|
_ = store.Set("gitea", keyring.TokenSet{
|
|
IDToken: "old",
|
|
AccessToken: "at-stale",
|
|
RefreshToken: "rt-1",
|
|
AccessExpAt: time.Now().Add(-time.Minute),
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: stub.srv.URL,
|
|
ClientID: "test-client",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
tokens := make(chan string, 4)
|
|
src := NewTokenSource(store, "gitea",
|
|
Config{Issuer: stub.srv.URL, ClientID: "test-client"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"},
|
|
func(ts keyring.TokenSet) { tokens <- ts.AccessToken })
|
|
src.minWait = time.Millisecond // refresh almost immediately
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
done := make(chan struct{})
|
|
go func() { _ = src.Run(ctx); close(done) }()
|
|
|
|
select {
|
|
case got := <-tokens:
|
|
if got != "at-refreshed" {
|
|
t.Fatalf("renewed token = %q, want at-refreshed", got)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for renewal callback")
|
|
}
|
|
|
|
// The rotated token must be persisted to the wallet too.
|
|
stored, err := store.Get("gitea")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if stored.AccessToken != "at-refreshed" {
|
|
t.Fatalf("wallet access token = %q, want at-refreshed", stored.AccessToken)
|
|
}
|
|
if stored.AccessExpAt.IsZero() {
|
|
t.Fatal("wallet access token expiry was not persisted")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run did not exit after cancel")
|
|
}
|
|
}
|
|
|
|
func TestTokenSource_Run_StopsOnCancel(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_ = store.Set("gitea", keyring.TokenSet{
|
|
IDToken: "fresh",
|
|
AccessToken: "at-0",
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
src := NewTokenSource(store, "gitea",
|
|
Config{Issuer: "https://example.test", ClientID: "y"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"},
|
|
func(keyring.TokenSet) {})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() { done <- src.Run(ctx) }()
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if err != context.Canceled {
|
|
t.Fatalf("Run returned %v, want context.Canceled", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Run did not return after cancel")
|
|
}
|
|
}
|
|
|
|
func TestTokenHolder(t *testing.T) {
|
|
h := NewTokenHolder()
|
|
if h.AccessToken() != "" {
|
|
t.Fatal("empty holder should return empty access token")
|
|
}
|
|
if _, err := h.Bearer(); err == nil {
|
|
t.Fatal("empty holder Bearer should error")
|
|
}
|
|
h.Set(keyring.TokenSet{AccessToken: "at-1"})
|
|
if h.AccessToken() != "at-1" {
|
|
t.Fatalf("AccessToken = %q, want at-1", h.AccessToken())
|
|
}
|
|
if b, err := h.Bearer(); err != nil || b != "Bearer at-1" {
|
|
t.Fatalf("Bearer = %q, %v", b, err)
|
|
}
|
|
h.Set(keyring.TokenSet{AccessToken: "at-2"})
|
|
if h.AccessToken() != "at-2" {
|
|
t.Fatalf("AccessToken = %q, want at-2 after update", h.AccessToken())
|
|
}
|
|
}
|
|
|
|
func TestEnsureStarted_LazyAndIdempotent(t *testing.T) {
|
|
store := fakekeyring.New()
|
|
_ = store.Set("gitea", keyring.TokenSet{
|
|
IDToken: "fresh",
|
|
AccessToken: "at-0",
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
|
|
var calls int
|
|
var mu sync.Mutex
|
|
src := NewTokenSource(store, "gitea",
|
|
Config{Issuer: "https://example.test", ClientID: "y"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"},
|
|
func(keyring.TokenSet) {
|
|
mu.Lock()
|
|
calls++
|
|
mu.Unlock()
|
|
}).Lifetime(context.Background())
|
|
|
|
// Nothing happened yet: no token delivered until EnsureStarted.
|
|
mu.Lock()
|
|
if calls != 0 {
|
|
t.Fatalf("callback fired %d times before EnsureStarted", calls)
|
|
}
|
|
mu.Unlock()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if err := src.EnsureStarted(context.Background()); err != nil {
|
|
t.Fatalf("EnsureStarted #%d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// Initial auth ran exactly once despite three calls (the renewer may
|
|
// add more later, but immediately after Start it's exactly one).
|
|
mu.Lock()
|
|
got := calls
|
|
mu.Unlock()
|
|
if got != 1 {
|
|
t.Fatalf("initial auth delivered %d tokens, want 1", got)
|
|
}
|
|
if src.Current().AccessToken != "at-0" {
|
|
t.Fatalf("Current = %q, want at-0", src.Current().AccessToken)
|
|
}
|
|
}
|
|
|
|
func TestEnsureStarted_RetriesAfterFailure(t *testing.T) {
|
|
// Empty wallet + an unreachable issuer makes the first EnsureStarted
|
|
// fail (no browser stub). started must stay false so a later call can
|
|
// succeed once the wallet is seeded.
|
|
store := fakekeyring.New()
|
|
src := NewTokenSource(store, "gitea",
|
|
Config{Issuer: "https://127.0.0.1:1/nope", ClientID: "y", LoopbackAddrOverride: "127.0.0.1:0"},
|
|
EnsureOptions{LockPath: t.TempDir() + "/lock"},
|
|
func(keyring.TokenSet) {}).Lifetime(context.Background())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := src.EnsureStarted(ctx); err == nil {
|
|
t.Fatal("expected first EnsureStarted to fail with empty wallet + bad issuer")
|
|
}
|
|
|
|
// Seed a fresh token; now EnsureStarted should succeed (refresh path,
|
|
// no issuer contact needed because the token is still fresh).
|
|
_ = store.Set("gitea", keyring.TokenSet{
|
|
IDToken: "fresh",
|
|
AccessToken: "at-late",
|
|
IDExpiresAt: time.Now().Add(time.Hour),
|
|
Issuer: "x",
|
|
ClientID: "y",
|
|
})
|
|
if err := src.EnsureStarted(context.Background()); err != nil {
|
|
t.Fatalf("EnsureStarted after seeding: %v", err)
|
|
}
|
|
if src.Current().AccessToken != "at-late" {
|
|
t.Fatalf("Current = %q, want at-late", src.Current().AccessToken)
|
|
}
|
|
}
|