Files
sherlock/internal/authn/flow_test.go
T
amacocian fe71a4e60c
CI / build (push) Successful in 1m5s
Code fixes
2026-05-25 09:51:14 +02:00

200 lines
6.0 KiB
Go

package authn_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"gitea.alexandru.macocian.me/Charlie/sherlock/internal/authn"
)
// stubAuthentik is a minimal OIDC provider sufficient to drive Flow
// end-to-end: it implements discovery, an authorize endpoint that
// immediately redirects with a code, and a token endpoint that returns
// a signed id_token.
type stubAuthentik struct {
server *httptest.Server
signer jose.Signer
pubJWK jose.JSONWebKey
issuer string
clientID string
// State remembered between authorize and token calls.
expectedCodeChallenge string
}
func newStub(t *testing.T, clientID string) *stubAuthentik {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "test"))
if err != nil {
t.Fatalf("signer: %v", err)
}
pub := jose.JSONWebKey{Key: &key.PublicKey, KeyID: "test", Algorithm: "ES256", Use: "sig"}
s := &stubAuthentik{signer: signer, pubJWK: pub, clientID: clientID}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": s.issuer,
"authorization_endpoint": s.issuer + "/auth",
"token_endpoint": s.issuer + "/token",
"jwks_uri": s.issuer + "/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) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{pub}})
})
mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) {
// Capture code_challenge so we can demand the right verifier
// on the token call. Redirect to the redirect_uri with state+code.
s.expectedCodeChallenge = r.URL.Query().Get("code_challenge")
redirect := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
u, _ := url.Parse(redirect)
q := u.Query()
q.Set("code", "stub-code")
q.Set("state", state)
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
verifier := r.PostForm.Get("code_verifier")
sum := sha256.Sum256([]byte(verifier))
got := base64.RawURLEncoding.EncodeToString(sum[:])
if got != s.expectedCodeChallenge {
http.Error(w, "verifier mismatch", http.StatusBadRequest)
return
}
now := time.Now()
claims := struct {
jwt.Claims
Email string `json:"email"`
Name string `json:"name"`
}{
Claims: jwt.Claims{
Issuer: s.issuer,
Subject: "stub-user",
Audience: jwt.Audience{s.clientID},
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
},
Email: "stub@example",
Name: "Stub User",
}
idToken, err := jwt.Signed(s.signer).Claims(claims).Serialize()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-1",
"token_type": "Bearer",
"refresh_token": "refresh-1",
"expires_in": 3600,
"refresh_expires_in": 30 * 24 * 3600,
"id_token": idToken,
})
})
s.server = httptest.NewServer(mux)
s.issuer = s.server.URL
t.Cleanup(s.server.Close)
return s
}
// ensure big.Int is "imported" (referenced) so future tweaks don't
// require touching imports.
var _ = big.NewInt
func TestFlow_EndToEnd(t *testing.T) {
stub := newStub(t, "sherlock-client")
flow := authn.NewFlow(authn.Config{
Issuer: stub.issuer,
ClientID: "sherlock-client",
Scopes: []string{"openid", "profile", "email"},
}, nil)
defer func() { _ = flow.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start, err := flow.StartFlow(ctx)
if err != nil {
t.Fatalf("StartFlow: %v", err)
}
if start.State == "" || !strings.HasPrefix(start.AuthURL, stub.issuer+"/auth?") {
t.Fatalf("bad start: %+v", start)
}
// Simulate the browser: follow the redirect chain. We DON'T want
// the http.Client to follow into our loopback callback as part of
// the same client (it would try and the cb would 200), so just let
// the chain go.
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(start.AuthURL)
if err != nil {
t.Fatalf("GET auth: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
t.Fatalf("auth chain ended at %s", resp.Status)
}
res, err := flow.AwaitCallback(ctx, start.State, 5*time.Second)
if err != nil {
t.Fatalf("AwaitCallback: %v", err)
}
if res.Subject != "stub-user" || res.Email != "stub@example" {
t.Fatalf("bad claims: %+v", res)
}
if res.IDToken == "" || res.RefreshToken != "refresh-1" {
t.Fatalf("bad tokens: %+v", res)
}
if time.Until(res.IDExpiresAt) < 30*time.Minute {
t.Fatalf("id token expiry too soon: %v", res.IDExpiresAt)
}
}
func TestFlow_NotConfigured(t *testing.T) {
flow := authn.NewFlow(authn.Config{}, nil)
defer func() { _ = flow.Close() }()
_, err := flow.StartFlow(context.Background())
if err == nil || !strings.Contains(err.Error(), "not configured") {
t.Fatalf("expected not-configured error, got %v", err)
}
}
// Sanity check that fmt is referenced by tests so an unused import
// linter doesn't complain after future edits.
var _ = fmt.Sprintf