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