Files
sherlock/internal/authn/login_test.go
T
amacocian 59eaf9e47d
Release / release (push) Failing after 6s
SearXNG support
2026-06-14 21:33:22 +02:00

335 lines
9.5 KiB
Go

package authn
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring"
fakekeyring "gitea.alexandru.macocian.me/amacocian/sherlock/internal/keyring/fake"
)
// stubAuthentik signs ES256 ID tokens and returns them through a real
// httptest.Server, exercising the full Login path end-to-end without
// touching a live Authentik instance.
type stubAuthentik struct {
srv *httptest.Server
signer jose.Signer
pubJWK jose.JSONWebKey
verifyCalled bool
mu sync.Mutex
codeToPKCE map[string]string
}
func newStubAuthentik(t *testing.T) *stubAuthentik {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: priv},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
if err != nil {
t.Fatal(err)
}
stub := &stubAuthentik{
signer: signer,
pubJWK: jose.JSONWebKey{
Key: priv.Public(),
KeyID: "test",
Algorithm: "ES256",
Use: "sig",
},
codeToPKCE: map[string]string{},
}
mux := http.NewServeMux()
stub.srv = httptest.NewServer(mux)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": stub.srv.URL,
"authorization_endpoint": stub.srv.URL + "/authorize",
"token_endpoint": stub.srv.URL + "/token",
"jwks_uri": stub.srv.URL + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"ES256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{stub.pubJWK}})
})
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
stub.mu.Lock()
stub.codeToPKCE["code-"+q.Get("state")] = q.Get("code_challenge")
stub.mu.Unlock()
// Redirect to the loopback redirect URI with the code+state.
ru, _ := url.Parse(q.Get("redirect_uri"))
rq := ru.Query()
rq.Set("code", "code-"+q.Get("state"))
rq.Set("state", q.Get("state"))
ru.RawQuery = rq.Encode()
http.Redirect(w, r, ru.String(), http.StatusFound)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
// Refresh grant: hand back a rotated access token (and a fresh
// id_token) without re-running the PKCE dance. Used by the
// TokenSource renewal tests.
if r.Form.Get("grant_type") == "refresh_token" {
clientID := r.Form.Get("client_id")
if clientID == "" {
if user, _, ok := r.BasicAuth(); ok {
clientID = user
}
}
if clientID == "" {
clientID = "test-client"
}
idToken := stub.signIDToken(t, clientID, map[string]any{
"sub": "user-1",
"email": "ops@example.com",
"name": "Test Operator",
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "at-refreshed",
"refresh_token": "rt-2",
"id_token": idToken,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_expires_in": 86400,
})
return
}
code := r.Form.Get("code")
verifier := r.Form.Get("code_verifier")
stub.mu.Lock()
challenge, ok := stub.codeToPKCE[code]
stub.mu.Unlock()
if !ok {
http.Error(w, "unknown code", http.StatusBadRequest)
return
}
sum := sha256.Sum256([]byte(verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if want != challenge {
http.Error(w, "bad code_verifier", http.StatusBadRequest)
return
}
stub.verifyCalled = true
// Public PKCE clients don't always send client_id in the body
// (oauth2 prefers Basic-Auth when AuthStyle defaults to header).
// Pull it from Authorization or fall back to the form/test value.
clientID := r.Form.Get("client_id")
if clientID == "" {
if user, _, ok := r.BasicAuth(); ok {
clientID = user
}
}
if clientID == "" {
clientID = "test-client"
}
idToken := stub.signIDToken(t, clientID, map[string]any{
"sub": "user-1",
"email": "ops@example.com",
"name": "Test Operator",
})
resp := map[string]any{
"access_token": "at-1",
"refresh_token": "rt-1",
"id_token": idToken,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_expires_in": 86400,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
t.Cleanup(stub.srv.Close)
return stub
}
func (s *stubAuthentik) signIDToken(t *testing.T, audience string, claims map[string]any) string {
t.Helper()
now := time.Now()
all := map[string]any{
"iss": s.srv.URL,
"aud": audience,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
for k, v := range claims {
all[k] = v
}
tok, err := jwt.Signed(s.signer).Claims(all).Serialize()
if err != nil {
t.Fatal(err)
}
return tok
}
func TestLogin_HappyPath(t *testing.T) {
stub := newStubAuthentik(t)
cfg := Config{
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid", "profile", "email"},
LoopbackAddrOverride: "127.0.0.1:0",
}
// The browser-open hook drives the flow programmatically.
var browserErr error
opts := LoginOptions{
OnAuthURL: func(u string) {
resp, err := (&http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}).Get(u)
if err != nil {
browserErr = err
return
}
defer func() { _ = resp.Body.Close() }()
loc := resp.Header.Get("Location")
if loc == "" {
browserErr = fmt.Errorf("expected redirect, got %d", resp.StatusCode)
return
}
// Follow the redirect into the loopback listener.
cb, err := http.Get(loc)
if err != nil {
browserErr = err
return
}
_, _ = io.Copy(io.Discard, cb.Body)
_ = cb.Body.Close()
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := Login(ctx, cfg, opts)
if err != nil {
t.Fatalf("Login: %v", err)
}
if browserErr != nil {
t.Fatalf("browser stub: %v", browserErr)
}
if res.Tokens.IDToken == "" || res.Tokens.RefreshToken == "" {
t.Fatalf("missing tokens: %+v", res.Tokens)
}
if res.Tokens.Subject != "user-1" || res.Tokens.Email != "ops@example.com" {
t.Fatalf("claims wrong: %+v", res.Tokens)
}
if res.Tokens.ClientID != "test-client" {
t.Fatalf("ClientID not persisted: %q", res.Tokens.ClientID)
}
if res.Tokens.AccessExpAt.IsZero() {
t.Fatal("AccessExpAt was not persisted")
}
if !strings.HasPrefix(res.Tokens.Issuer, "http://127.0.0.1:") {
t.Fatalf("Issuer = %q", res.Tokens.Issuer)
}
}
func TestLogin_NotConfigured(t *testing.T) {
_, err := Login(context.Background(), Config{}, LoginOptions{})
if err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
func TestLogin_ContextCancel(t *testing.T) {
stub := newStubAuthentik(t)
cfg := Config{
Issuer: stub.srv.URL,
ClientID: "test-client",
Scopes: []string{"openid"},
LoopbackAddrOverride: "127.0.0.1:0",
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
_, err := Login(ctx, cfg, LoginOptions{OnAuthURL: func(string) {}})
if err == nil {
t.Fatal("expected error on cancel")
}
}
// Sanity check that the verifier built off the stub provider works
// outside Login too — guards against accidental client-ID drift.
func TestIDTokenVerifies_AgainstStub(t *testing.T) {
stub := newStubAuthentik(t)
provider, err := oidc.NewProvider(context.Background(), stub.srv.URL)
if err != nil {
t.Fatal(err)
}
raw := stub.signIDToken(t, "client-x", map[string]any{"sub": "u"})
if _, err := provider.Verifier(&oidc.Config{ClientID: "client-x"}).Verify(context.Background(), raw); err != nil {
t.Fatal(err)
}
}
func TestEnsureFresh_FastPath(t *testing.T) {
store := fakekeyring.New()
_ = store.Set("test", keyring.TokenSet{
IDToken: "ok",
IDExpiresAt: time.Now().Add(time.Hour),
Issuer: "x",
ClientID: "y",
})
ts, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != nil {
t.Fatalf("EnsureFresh: %v", err)
}
if ts.IDToken != "ok" {
t.Fatal("fast path should return current token")
}
}
func TestEnsureFresh_NoTokens(t *testing.T) {
store := fakekeyring.New()
_, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != keyring.ErrNoTokens {
t.Fatalf("err = %v, want ErrNoTokens", err)
}
}
func TestEnsureFresh_NeedsRefreshButNoRefreshToken(t *testing.T) {
store := fakekeyring.New()
_ = store.Set("test", keyring.TokenSet{
IDToken: "stale",
IDExpiresAt: time.Now().Add(-time.Minute),
Issuer: "x",
ClientID: "y",
})
_, err := EnsureFresh(context.Background(), store, "test", EnsureFreshOptions{LockPath: t.TempDir() + "/lock"})
if err != ErrNoRefreshToken {
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
}
}