Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
package authapi
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// signingKey is generated once per test binary; RSA generation is expensive.
var signingKey = sync.OnceValue(func() *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return key
})
// fakeIdP is a minimal OIDC provider: discovery document, JWKS and token endpoint.
// Tests drive its behaviour through the exported fields before calling the callback.
type fakeIdP struct {
server *httptest.Server
key *rsa.PrivateKey
clientID string
mu sync.Mutex
expectedChallenge string
nonce string
subject string
groups []string
tokenFails bool
omitIDToken bool
issuerOverride string
verifierSeen string
}
func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
t.Helper()
idp := &fakeIdP{key: signingKey(), clientID: clientID, subject: "user-1", groups: []string{"pulse-operator"}}
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", idp.discovery)
mux.HandleFunc("/jwks", idp.jwks)
mux.HandleFunc("/token", idp.token)
mux.HandleFunc("/authorize", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusOK)
})
idp.server = httptest.NewServer(mux)
t.Cleanup(idp.server.Close)
return idp
}
func (idp *fakeIdP) configure(mutate func(*fakeIdP)) {
idp.mu.Lock()
defer idp.mu.Unlock()
mutate(idp)
}
func (idp *fakeIdP) codeVerifier() string {
idp.mu.Lock()
defer idp.mu.Unlock()
return idp.verifierSeen
}
func (idp *fakeIdP) discovery(response http.ResponseWriter, _ *http.Request) {
writeJSON(response, http.StatusOK, map[string]any{
"issuer": idp.server.URL,
"authorization_endpoint": idp.server.URL + "/authorize",
"token_endpoint": idp.server.URL + "/token",
"jwks_uri": idp.server.URL + "/jwks",
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
})
}
func (idp *fakeIdP) jwks(response http.ResponseWriter, _ *http.Request) {
public := &idp.key.PublicKey
writeJSON(response, http.StatusOK, map[string]any{"keys": []map[string]any{{
"kty": "RSA",
"kid": "test-key",
"alg": "RS256",
"use": "sig",
"n": base64.RawURLEncoding.EncodeToString(public.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(public.E)).Bytes()),
}}})
}
func (idp *fakeIdP) token(response http.ResponseWriter, request *http.Request) {
if err := request.ParseForm(); err != nil {
writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_request"})
return
}
idp.mu.Lock()
defer idp.mu.Unlock()
idp.verifierSeen = request.PostForm.Get("code_verifier")
if idp.tokenFails {
writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_grant"})
return
}
if idp.expectedChallenge != "" {
digest := sha256.Sum256([]byte(idp.verifierSeen))
if base64.RawURLEncoding.EncodeToString(digest[:]) != idp.expectedChallenge {
writeJSON(response, http.StatusBadRequest, map[string]any{"error": "invalid_grant"})
return
}
}
body := map[string]any{"access_token": "opaque-access-token", "token_type": "Bearer", "expires_in": 3600}
if !idp.omitIDToken {
issuer := idp.server.URL
if idp.issuerOverride != "" {
issuer = idp.issuerOverride
}
now := time.Now()
body["id_token"] = idp.sign(map[string]any{
"iss": issuer,
"aud": idp.clientID,
"sub": idp.subject,
"iat": now.Unix(),
"exp": now.Add(5 * time.Minute).Unix(),
"nonce": idp.nonce,
"groups": idp.groups,
})
}
writeJSON(response, http.StatusOK, body)
}
func (idp *fakeIdP) sign(claims map[string]any) string {
segments := []string{encodeSegment(map[string]any{"alg": "RS256", "typ": "JWT", "kid": "test-key"}), encodeSegment(claims)}
input := strings.Join(segments, ".")
digest := sha256.Sum256([]byte(input))
signature, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:])
if err != nil {
panic(err)
}
return input + "." + base64.RawURLEncoding.EncodeToString(signature)
}
func encodeSegment(value map[string]any) string {
encoded, err := json.Marshal(value)
if err != nil {
panic(err)
}
return base64.RawURLEncoding.EncodeToString(encoded)
}
func writeJSON(response http.ResponseWriter, status int, body any) {
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(status)
_ = json.NewEncoder(response).Encode(body)
}
+116
View File
@@ -0,0 +1,116 @@
package authapi
import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
"github.com/itworx/pulse/internal/auth"
)
const (
defaultFlowTTL = 10 * time.Minute
defaultMaxFlows = 1024
flowIDByteLength = 32
)
// flow is the server-side state of one in-progress authorization code flow. Only
// an opaque identifier for it ever reaches the browser.
type flow struct {
authorization auth.Authorization
redirect string
createdAt time.Time
}
// flowStore keeps pending flows in memory. It is bounded by TTL and by a maximum
// entry count so an unauthenticated caller cannot grow it without limit, and it is
// safe for concurrent use.
type flowStore struct {
mu sync.Mutex
flows map[string]flow
ttl time.Duration
max int
}
func newFlowStore(ttl time.Duration, max int) *flowStore {
if ttl <= 0 {
ttl = defaultFlowTTL
}
if max <= 0 {
max = defaultMaxFlows
}
return &flowStore{flows: make(map[string]flow), ttl: ttl, max: max}
}
// create stores one pending flow and returns its opaque identifier. Expired entries
// are removed first; if the store is still at capacity the oldest entry is dropped
// so a flood of abandoned flows cannot deny logins permanently.
func (store *flowStore) create(entry flow, now time.Time) (string, error) {
id, err := randomFlowID()
if err != nil {
return "", errors.New("generate authorization flow identifier")
}
entry.createdAt = now
store.mu.Lock()
defer store.mu.Unlock()
store.purge(now)
for len(store.flows) >= store.max && store.evictOldest() {
}
store.flows[id] = entry
return id, nil
}
// take returns a pending flow and always removes it, so a flow identifier can be
// used at most once. An unknown, replayed or expired identifier returns false.
func (store *flowStore) take(id string, now time.Time) (flow, bool) {
if id == "" {
return flow{}, false
}
store.mu.Lock()
defer store.mu.Unlock()
entry, ok := store.flows[id]
delete(store.flows, id)
if !ok || !now.Before(entry.createdAt.Add(store.ttl)) {
return flow{}, false
}
return entry, true
}
func (store *flowStore) size() int {
store.mu.Lock()
defer store.mu.Unlock()
return len(store.flows)
}
func (store *flowStore) purge(now time.Time) {
for id, entry := range store.flows {
if !now.Before(entry.createdAt.Add(store.ttl)) {
delete(store.flows, id)
}
}
}
func (store *flowStore) evictOldest() bool {
oldest := ""
var oldestAt time.Time
for id, entry := range store.flows {
if oldest == "" || entry.createdAt.Before(oldestAt) {
oldest, oldestAt = id, entry.createdAt
}
}
if oldest == "" {
return false
}
delete(store.flows, oldest)
return true
}
func randomFlowID() (string, error) {
buffer := make([]byte, flowIDByteLength)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
+154
View File
@@ -0,0 +1,154 @@
package authapi
import (
"sync"
"testing"
"time"
"github.com/itworx/pulse/internal/auth"
)
func testFlow(state string) flow {
return flow{authorization: auth.Authorization{State: state, Nonce: "nonce", CodeVerifier: "verifier"}, redirect: "/"}
}
func TestFlowStoreSingleUseAndExpiry(t *testing.T) {
now := time.Date(2026, 8, 4, 10, 0, 0, 0, time.UTC)
for _, test := range []struct {
name string
takeAt time.Time
twice bool
wantOK bool
wantAll int
}{
{name: "within ttl", takeAt: now.Add(time.Minute), wantOK: true},
{name: "at ttl boundary", takeAt: now.Add(defaultFlowTTL), wantOK: false},
{name: "after ttl", takeAt: now.Add(defaultFlowTTL + time.Second), wantOK: false},
{name: "replayed", takeAt: now.Add(time.Minute), twice: true, wantOK: false},
} {
t.Run(test.name, func(t *testing.T) {
store := newFlowStore(0, 0)
id, err := store.create(testFlow("state-1"), now)
if err != nil {
t.Fatalf("create: %v", err)
}
if test.twice {
if _, ok := store.take(id, test.takeAt); !ok {
t.Fatal("first take failed")
}
}
entry, ok := store.take(id, test.takeAt)
if ok != test.wantOK {
t.Fatalf("take ok = %v, want %v", ok, test.wantOK)
}
if ok && entry.authorization.State != "state-1" {
t.Fatalf("state = %q", entry.authorization.State)
}
if store.size() != 0 {
t.Fatalf("take left %d entries behind", store.size())
}
})
}
}
func TestFlowStoreRejectsUnknownIdentifiers(t *testing.T) {
store := newFlowStore(0, 0)
for _, id := range []string{"", "unknown", " "} {
if _, ok := store.take(id, time.Now()); ok {
t.Fatalf("identifier %q was accepted", id)
}
}
}
func TestFlowStoreIsBounded(t *testing.T) {
now := time.Now().UTC()
store := newFlowStore(time.Minute, 4)
for index := range 50 {
if _, err := store.create(testFlow("state"), now.Add(time.Duration(index)*time.Second)); err != nil {
t.Fatalf("create: %v", err)
}
}
if store.size() != 4 {
t.Fatalf("size = %d, want 4", store.size())
}
expired, err := store.create(testFlow("expired"), now)
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := store.create(testFlow("fresh"), now.Add(2*time.Minute)); err != nil {
t.Fatalf("create: %v", err)
}
if _, ok := store.take(expired, now.Add(2*time.Minute)); ok {
t.Fatal("expired flow survived the purge")
}
if store.size() > 4 {
t.Fatalf("size = %d, want at most 4", store.size())
}
}
func TestFlowStoreConcurrentAccess(t *testing.T) {
const workers = 128
store := newFlowStore(time.Minute, 64)
now := time.Now().UTC()
identifiers := make([]string, workers)
var wait sync.WaitGroup
for index := range workers {
wait.Add(1)
go func() {
defer wait.Done()
id, err := store.create(testFlow("state"), now)
if err != nil {
t.Errorf("create: %v", err)
return
}
identifiers[index] = id
}()
}
wait.Wait()
unique := make(map[string]struct{}, workers)
for _, id := range identifiers {
if id == "" {
t.Fatal("empty flow identifier")
}
unique[id] = struct{}{}
}
if len(unique) != workers {
t.Fatalf("unique identifiers = %d, want %d", len(unique), workers)
}
if store.size() > 64 {
t.Fatalf("size = %d, want at most 64", store.size())
}
var taken sync.WaitGroup
results := make(chan bool, 2*workers)
for _, id := range identifiers {
taken.Add(1)
go func() {
defer taken.Done()
_, ok := store.take(id, now)
results <- ok
}()
taken.Add(1)
go func() {
defer taken.Done()
_, ok := store.take(id, now)
results <- ok
}()
}
taken.Wait()
close(results)
accepted := 0
for ok := range results {
if ok {
accepted++
}
}
if accepted > 64 {
t.Fatalf("accepted %d flows, want at most the store capacity", accepted)
}
if store.size() != 0 {
t.Fatalf("size = %d, want 0", store.size())
}
}
+347
View File
@@ -0,0 +1,347 @@
// Package authapi exposes the two browser-facing OIDC endpoints that complete the
// authorization code flow implemented in internal/auth: GET /auth/login starts a
// flow and GET /auth/callback finishes it by issuing a Pulse session.
//
// Flow state (state, nonce, PKCE verifier and the post-login path) never leaves the
// server; the browser only carries a short-lived opaque flow identifier cookie.
// Every failure path destroys the identified flow, issues no session and redirects
// to a fixed in-app error route with a reason code from a closed set. A flow whose
// identifier never comes back simply expires. Provider-supplied
// text is never reflected into a response, and tokens, codes and PKCE verifiers are
// never logged.
//
// Wiring in cmd/api/main.go, after the session manager exists:
//
// oidcAuth, err := authapi.New(authapi.Options{
// OIDC: auth.OIDCConfig{
// Issuer: application.OIDCIssuer,
// ClientID: application.OIDCClientID,
// ClientSecret: application.OIDCClientSecret,
// RedirectURL: application.OIDCRedirectURL,
// },
// RoleMapping: map[string]auth.Role{
// "pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator,
// "pulse-editor": auth.RoleEditor, "pulse-admin": auth.RoleAdministrator,
// },
// Sessions: sessions,
// Secure: application.Environment == config.Production,
// Logger: logger,
// Audit: func(ctx context.Context, actor, result string) error {
// if pool == nil {
// return nil
// }
// return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
// },
// })
// if err != nil {
// return err
// }
// mux.Handle("/auth/login", oidcAuth.LoginHandler())
// mux.Handle("/auth/callback", oidcAuth.CallbackHandler())
//
// New only fails on incomplete configuration, so registration is safe when
// PULSE_AUTH_MODE is oidc; guard it with `if application.AuthMode == "oidc"` so a
// mock-mode development run keeps working. The callback path registered here must
// equal the path of PULSE_OIDC_REDIRECT_URL. Provider discovery happens lazily on
// the first login and is cached, so a temporarily unreachable IdP does not prevent
// the API from starting.
//
// The runtime mapping is supplied by PULSE_OIDC_ROLE_MAPPING through
// internal/config. An empty mapping authorizes nobody, and production startup
// rejects it before the handlers are registered.
package authapi
import (
"context"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/problem"
)
const (
flowCookieName = "pulse_auth_flow"
defaultErrorPath = "/login/error"
defaultRedirect = "/"
maxRedirectLength = 512
discoveryTimeout = 10 * time.Second
tokenTimeout = 15 * time.Second
)
// Reason codes are a closed set; the provider never influences their value.
const (
reasonInvalidRequest = "invalid_request"
reasonExpired = "expired"
reasonDenied = "denied"
reasonProviderUnavailable = "provider_unavailable"
reasonNotAuthorized = "not_authorized"
reasonUnavailable = "unavailable"
)
// SessionIssuer is the part of *auth.SessionManager the callback needs.
type SessionIssuer interface {
Issue(response http.ResponseWriter, principal auth.Principal, now time.Time) error
}
type Options struct {
// OIDC is the provider configuration; issuer, client ID and redirect URL are required.
OIDC auth.OIDCConfig
// RoleMapping maps IdP group claim values to Pulse roles. Empty means nobody can log in.
RoleMapping map[string]auth.Role
// GroupsClaim is the ID token claim holding role values; defaults to "groups".
GroupsClaim string
// Sessions issues the Pulse session cookie after a verified login.
Sessions SessionIssuer
// Secure marks the flow cookie Secure; set it in production.
Secure bool
// FlowTTL bounds how long a started flow stays valid; defaults to 10 minutes.
FlowTTL time.Duration
// MaxFlows caps concurrently pending flows; defaults to 1024.
MaxFlows int
// DefaultRedirect is the post-login path when none was requested; defaults to "/".
DefaultRedirect string
// ErrorPath is the in-app route failures redirect to; defaults to "/login/error".
ErrorPath string
// Logger receives structured, secret-free flow events; optional.
Logger *slog.Logger
// Now overrides the clock; defaults to time.Now().UTC(). It must stay close to
// real time because the OIDC provider validates token freshness independently.
Now func() time.Time
// Audit records the security event before a session is issued. A returned error
// fails the login closed; optional.
Audit func(ctx context.Context, actor, result string) error
}
// Handler serves the login and callback endpoints. Create it with New.
type Handler struct {
options Options
flows *flowStore
mu sync.Mutex
discovery auth.Discovery
resolved bool
}
func New(options Options) (*Handler, error) {
if strings.TrimSpace(options.OIDC.Issuer) == "" || strings.TrimSpace(options.OIDC.ClientID) == "" || strings.TrimSpace(options.OIDC.RedirectURL) == "" {
return nil, &configError{"OIDC issuer, client ID and redirect URL are required"}
}
if options.Sessions == nil {
return nil, &configError{"session issuer is required"}
}
if options.GroupsClaim == "" {
options.GroupsClaim = "groups"
}
options.DefaultRedirect = safePath(options.DefaultRedirect, defaultRedirect)
if strings.ContainsAny(options.ErrorPath, "?#") {
options.ErrorPath = ""
}
options.ErrorPath = safePath(options.ErrorPath, defaultErrorPath)
if options.Logger == nil {
options.Logger = slog.New(slog.DiscardHandler)
}
if options.Now == nil {
options.Now = func() time.Time { return time.Now().UTC() }
}
return &Handler{options: options, flows: newFlowStore(options.FlowTTL, options.MaxFlows)}, nil
}
type configError struct{ detail string }
func (e *configError) Error() string { return "authapi configuration invalid: " + e.detail }
// LoginHandler starts the authorization code flow. Register it on /auth/login.
func (handler *Handler) LoginHandler() http.Handler { return http.HandlerFunc(handler.login) }
// CallbackHandler completes the flow. Register it on the path of the configured
// OIDC redirect URL, normally /auth/callback.
func (handler *Handler) CallbackHandler() http.Handler { return http.HandlerFunc(handler.callback) }
func (handler *Handler) login(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
methodNotAllowed(response, request)
return
}
now := handler.options.Now()
discovery, err := handler.discover(request.Context())
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
return
}
authorization, err := auth.BeginAuthorization(discovery.Endpoint, handler.options.OIDC, now)
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "authorization_start_failed")
return
}
redirect := safePath(request.URL.Query().Get("redirect"), handler.options.DefaultRedirect)
id, err := handler.flows.create(flow{authorization: authorization, redirect: redirect}, now)
if err != nil {
handler.reject(response, request, reasonUnavailable, "flow_not_stored")
return
}
http.SetCookie(response, &http.Cookie{
Name: flowCookieName,
Value: id,
Path: "/",
MaxAge: int(handler.flows.ttl.Seconds()),
Expires: now.Add(handler.flows.ttl),
HttpOnly: true,
Secure: handler.options.Secure,
SameSite: http.SameSiteLaxMode,
})
handler.options.Logger.Info("oidc login started", "correlation_id", correlation.FromContext(request.Context()), "pending_flows", handler.flows.size())
http.Redirect(response, request, authorization.URL, http.StatusFound)
}
func (handler *Handler) callback(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
methodNotAllowed(response, request)
return
}
now := handler.options.Now()
cookie, err := request.Cookie(flowCookieName)
handler.clearFlowCookie(response)
if err != nil || cookie.Value == "" {
handler.reject(response, request, reasonInvalidRequest, "flow_cookie_missing")
return
}
pending, ok := handler.flows.take(cookie.Value, now)
if !ok {
handler.reject(response, request, reasonExpired, "flow_unknown_or_expired")
return
}
query := request.URL.Query()
if providerError := query.Get("error"); providerError != "" {
reason := reasonProviderUnavailable
if providerError == "access_denied" {
reason = reasonDenied
}
handler.reject(response, request, reason, "provider_reported_error")
return
}
state, code := query.Get("state"), query.Get("code")
if err := auth.ValidateCallback(pending.authorization, state, code, now); err != nil {
handler.reject(response, request, reasonInvalidRequest, "callback_validation_failed")
return
}
discovery, err := handler.discover(request.Context())
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
return
}
ctx, cancel := context.WithTimeout(request.Context(), tokenTimeout)
defer cancel()
token, err := auth.Exchange(ctx, pending.authorization, handler.options.OIDC, discovery.Endpoint, state, code)
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "token_exchange_failed")
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
handler.reject(response, request, reasonProviderUnavailable, "id_token_missing")
return
}
idToken, err := auth.VerifyIDToken(ctx, discovery.Verifier, rawIDToken, pending.authorization.Nonce)
if err != nil {
handler.reject(response, request, reasonInvalidRequest, "id_token_rejected")
return
}
identity, err := auth.ExtractIdentity(idToken, handler.options.GroupsClaim)
if err != nil {
handler.reject(response, request, reasonInvalidRequest, "identity_incomplete")
return
}
role, err := auth.MapRoles(identity.Groups, handler.options.RoleMapping)
if err != nil {
handler.reject(response, request, reasonNotAuthorized, "no_authorized_role")
return
}
principal := auth.Principal{Subject: identity.Subject, Role: role}
if handler.options.Audit != nil {
if err := handler.options.Audit(request.Context(), principal.Subject, "success"); err != nil {
handler.reject(response, request, reasonUnavailable, "audit_unavailable")
return
}
}
if err := handler.options.Sessions.Issue(response, principal, now); err != nil {
handler.reject(response, request, reasonUnavailable, "session_not_issued")
return
}
handler.options.Logger.Info("oidc login completed", "correlation_id", correlation.FromContext(request.Context()), "role", string(role))
http.Redirect(response, request, safePath(pending.redirect, handler.options.DefaultRedirect), http.StatusFound)
}
// discover resolves and caches the provider endpoints and verifier.
func (handler *Handler) discover(ctx context.Context) (auth.Discovery, error) {
handler.mu.Lock()
defer handler.mu.Unlock()
if handler.resolved {
return handler.discovery, nil
}
discoveryContext, cancel := context.WithTimeout(ctx, discoveryTimeout)
defer cancel()
discovery, err := auth.Discover(discoveryContext, handler.options.OIDC)
if err != nil {
return auth.Discovery{}, err
}
handler.discovery, handler.resolved = discovery, true
return discovery, nil
}
// reject issues no session and sends the browser to the in-app error route with a
// fixed reason code. The flow state is already removed by the time it is called.
func (handler *Handler) reject(response http.ResponseWriter, request *http.Request, reason, event string) {
handler.options.Logger.Warn("oidc flow rejected", "correlation_id", correlation.FromContext(request.Context()), "reason", reason, "event", event)
target := handler.options.ErrorPath + "?" + url.Values{"reason": []string{reason}}.Encode()
http.Redirect(response, request, target, http.StatusFound)
}
func (handler *Handler) clearFlowCookie(response http.ResponseWriter) {
http.SetCookie(response, &http.Cookie{
Name: flowCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: handler.options.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func methodNotAllowed(response http.ResponseWriter, request *http.Request) {
problem.Write(response, request, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", http.StatusText(http.StatusMethodNotAllowed), "This method is not supported.", nil)
}
// safePath accepts only in-app absolute paths: one leading slash, no scheme, no
// authority, no backslash and no control characters. Anything else falls back.
func safePath(candidate, fallback string) string {
target := strings.TrimSpace(candidate)
if target == "" || len(target) > maxRedirectLength {
return fallback
}
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
return fallback
}
if strings.Contains(target, "\\") {
return fallback
}
for _, character := range target {
if character < 0x20 || character == 0x7f {
return fallback
}
}
parsed, err := url.Parse(target)
if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.Opaque != "" || parsed.User != nil {
return fallback
}
if !strings.HasPrefix(parsed.Path, "/") {
return fallback
}
return target
}
+578
View File
@@ -0,0 +1,578 @@
package authapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/itworx/pulse/internal/auth"
)
const testClientID = "pulse-test-client"
type recordingSessions struct {
mu sync.Mutex
principals []auth.Principal
failure error
}
func (sessions *recordingSessions) Issue(response http.ResponseWriter, principal auth.Principal, _ time.Time) error {
sessions.mu.Lock()
defer sessions.mu.Unlock()
if sessions.failure != nil {
return sessions.failure
}
sessions.principals = append(sessions.principals, principal)
http.SetCookie(response, &http.Cookie{Name: "pulse_session", Value: "issued", Path: "/", HttpOnly: true})
return nil
}
func (sessions *recordingSessions) issued() []auth.Principal {
sessions.mu.Lock()
defer sessions.mu.Unlock()
return append([]auth.Principal(nil), sessions.principals...)
}
type harness struct {
handler *Handler
idp *fakeIdP
sessions *recordingSessions
clock func() time.Time
offset *time.Duration
}
func newHarness(t *testing.T, mutate func(*Options)) *harness {
t.Helper()
idp := newFakeIdP(t, testClientID)
sessions := &recordingSessions{}
offset := time.Duration(0)
options := Options{
OIDC: auth.OIDCConfig{
Issuer: idp.server.URL,
ClientID: testClientID,
ClientSecret: "test-secret",
RedirectURL: "https://pulse.example/auth/callback",
},
RoleMapping: map[string]auth.Role{"pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator, "pulse-admin": auth.RoleAdministrator},
Sessions: sessions,
Now: func() time.Time { return time.Now().UTC().Add(offset) },
}
if mutate != nil {
mutate(&options)
}
handler, err := New(options)
if err != nil {
t.Fatalf("New: %v", err)
}
return &harness{handler: handler, idp: idp, sessions: sessions, clock: options.Now, offset: &offset}
}
// begin runs GET /auth/login and returns the flow cookie value and the query the
// browser would have sent to the IdP.
func (h *harness) begin(t *testing.T, target string) (string, url.Values, *httptest.ResponseRecorder) {
t.Helper()
request := httptest.NewRequest(http.MethodGet, target, nil)
response := httptest.NewRecorder()
h.handler.LoginHandler().ServeHTTP(response, request)
if response.Code != http.StatusFound {
t.Fatalf("login status = %d, want %d", response.Code, http.StatusFound)
}
authorizationURL, err := url.Parse(response.Header().Get("Location"))
if err != nil {
t.Fatalf("parse authorization URL: %v", err)
}
query := authorizationURL.Query()
h.idp.configure(func(idp *fakeIdP) {
idp.nonce = query.Get("nonce")
idp.expectedChallenge = query.Get("code_challenge")
})
return flowCookie(t, response), query, response
}
// complete runs GET /auth/callback with the supplied cookie and query.
func (h *harness) complete(t *testing.T, cookie string, query url.Values) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodGet, "/auth/callback?"+query.Encode(), nil)
if cookie != "" {
request.AddCookie(&http.Cookie{Name: flowCookieName, Value: cookie})
}
response := httptest.NewRecorder()
h.handler.CallbackHandler().ServeHTTP(response, request)
return response
}
func flowCookie(t *testing.T, response *httptest.ResponseRecorder) string {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == flowCookieName {
return cookie.Value
}
}
t.Fatal("flow cookie was not set")
return ""
}
func cookieByName(response *httptest.ResponseRecorder, name string) *http.Cookie {
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
return nil
}
func errorReason(t *testing.T, response *httptest.ResponseRecorder) string {
t.Helper()
if response.Code != http.StatusFound {
t.Fatalf("status = %d, want %d", response.Code, http.StatusFound)
}
location, err := url.Parse(response.Header().Get("Location"))
if err != nil {
t.Fatalf("parse location: %v", err)
}
if location.Path != defaultErrorPath {
t.Fatalf("location path = %q, want %q", location.Path, defaultErrorPath)
}
return location.Query().Get("reason")
}
func TestLoginStartsBoundedServerSideFlow(t *testing.T) {
harness := newHarness(t, func(options *Options) { options.Secure = true })
cookie, query, response := harness.begin(t, "/auth/login")
for _, key := range []string{"state", "nonce", "code_challenge", "client_id", "redirect_uri"} {
if query.Get(key) == "" {
t.Fatalf("authorization URL missing %s", key)
}
}
if query.Get("code_challenge_method") != "S256" {
t.Fatalf("code_challenge_method = %q", query.Get("code_challenge_method"))
}
if strings.Contains(response.Header().Get("Location"), cookie) {
t.Fatal("flow identifier leaked into the authorization URL")
}
if cookieByName(response, "pulse_session") != nil {
t.Fatal("login issued a session")
}
flowCookie := cookieByName(response, flowCookieName)
if !flowCookie.HttpOnly || !flowCookie.Secure || flowCookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("flow cookie attributes are unsafe: %#v", flowCookie)
}
if flowCookie.MaxAge <= 0 || flowCookie.MaxAge > int(defaultFlowTTL.Seconds()) {
t.Fatalf("flow cookie MaxAge = %d", flowCookie.MaxAge)
}
if harness.handler.flows.size() != 1 {
t.Fatalf("pending flows = %d, want 1", harness.handler.flows.size())
}
pending, ok := harness.handler.flows.take(cookie, harness.clock())
if !ok || pending.authorization.State != query.Get("state") || pending.authorization.Nonce != query.Get("nonce") || pending.authorization.CodeVerifier == "" {
t.Fatal("flow state was not stored server-side")
}
}
func TestCallbackHappyPathIssuesSession(t *testing.T) {
harness := newHarness(t, nil)
cookie, query, _ := harness.begin(t, "/auth/login?redirect=%2Fincidents")
response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
if response.Code != http.StatusFound || response.Header().Get("Location") != "/incidents" {
t.Fatalf("status = %d, location = %q", response.Code, response.Header().Get("Location"))
}
issued := harness.sessions.issued()
if len(issued) != 1 || issued[0].Subject != "user-1" || issued[0].Role != auth.RoleOperator {
t.Fatalf("issued sessions = %#v", issued)
}
if verifier := harness.idp.codeVerifier(); verifier == "" {
t.Fatal("PKCE verifier was not sent to the token endpoint")
}
cleared := cookieByName(response, flowCookieName)
if cleared == nil || cleared.MaxAge >= 0 || cleared.Value != "" {
t.Fatalf("flow cookie was not cleared: %#v", cleared)
}
if harness.handler.flows.size() != 0 {
t.Fatal("flow state survived the callback")
}
}
func TestCallbackWorksWithRealSessionManager(t *testing.T) {
manager := auth.NewSessionManager("pulse_session", time.Hour, false)
harness := newHarness(t, func(options *Options) { options.Sessions = manager })
cookie, query, _ := harness.begin(t, "/auth/login")
response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
sessionCookie := cookieByName(response, "pulse_session")
if sessionCookie == nil {
t.Fatal("session cookie was not set")
}
next := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
next.AddCookie(sessionCookie)
principal, ok := manager.Principal(next, time.Now().UTC())
if !ok || principal.Subject != "user-1" || principal.Role != auth.RoleOperator {
t.Fatalf("principal = %#v, ok = %v", principal, ok)
}
}
func TestCallbackFailurePathsIssueNoSession(t *testing.T) {
for _, test := range []struct {
name string
// arrange starts a flow and returns the callback cookie and query.
arrange func(t *testing.T, h *harness) (string, url.Values)
reason string
// pendingFlows is what may still be stored afterwards: an untouched flow
// stays pending until it expires, an identified one is always destroyed.
pendingFlows int
}{
{
name: "missing flow cookie",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
_, query, _ := h.begin(t, "/auth/login")
return "", url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonInvalidRequest,
pendingFlows: 1,
},
{
name: "unknown flow identifier",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
_, query, _ := h.begin(t, "/auth/login")
return "not-a-known-flow", url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonExpired,
pendingFlows: 1,
},
{
name: "replayed flow identifier",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
values := url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
if first := h.complete(t, cookie, values); first.Header().Get("Location") != "/" {
t.Fatalf("first callback did not succeed: %q", first.Header().Get("Location"))
}
h.sessions.mu.Lock()
h.sessions.principals = nil
h.sessions.mu.Unlock()
return cookie, values
},
reason: reasonExpired,
},
{
name: "expired flow",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
*h.offset = defaultFlowTTL + time.Minute
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonExpired,
},
{
name: "state mismatch",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, _, _ := h.begin(t, "/auth/login")
return cookie, url.Values{"state": {"forged-state"}, "code": {"authorization-code"}}
},
reason: reasonInvalidRequest,
},
{
name: "missing authorization code",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
return cookie, url.Values{"state": {query.Get("state")}}
},
reason: reasonInvalidRequest,
},
{
name: "nonce mismatch",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.idp.configure(func(idp *fakeIdP) { idp.nonce = "replayed-nonce" })
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonInvalidRequest,
},
{
name: "provider access denied",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
return cookie, url.Values{"state": {query.Get("state")}, "error": {"access_denied"}, "error_description": {"<script>alert(1)</script> denied by policy"}}
},
reason: reasonDenied,
},
{
name: "provider error response",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
return cookie, url.Values{"state": {query.Get("state")}, "error": {"server_error"}}
},
reason: reasonProviderUnavailable,
},
{
name: "token exchange failure",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.idp.configure(func(idp *fakeIdP) { idp.tokenFails = true })
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonProviderUnavailable,
},
{
name: "id token missing",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.idp.configure(func(idp *fakeIdP) { idp.omitIDToken = true })
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonProviderUnavailable,
},
{
name: "id token from wrong issuer",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.idp.configure(func(idp *fakeIdP) { idp.issuerOverride = "https://attacker.example" })
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonInvalidRequest,
},
{
name: "no mapped role",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.idp.configure(func(idp *fakeIdP) { idp.groups = []string{"some-other-group"} })
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonNotAuthorized,
},
{
name: "session issue failure",
arrange: func(t *testing.T, h *harness) (string, url.Values) {
cookie, query, _ := h.begin(t, "/auth/login")
h.sessions.mu.Lock()
h.sessions.failure = errors.New("session store unavailable")
h.sessions.mu.Unlock()
return cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}}
},
reason: reasonUnavailable,
},
} {
t.Run(test.name, func(t *testing.T) {
harness := newHarness(t, nil)
cookie, query := test.arrange(t, harness)
response := harness.complete(t, cookie, query)
if reason := errorReason(t, response); reason != test.reason {
t.Fatalf("reason = %q, want %q", reason, test.reason)
}
if issued := harness.sessions.issued(); len(issued) != 0 {
t.Fatalf("a session was issued on a failure path: %#v", issued)
}
if cookieByName(response, "pulse_session") != nil {
t.Fatal("a session cookie was set on a failure path")
}
if harness.handler.flows.size() != test.pendingFlows {
t.Fatalf("pending flows = %d, want %d", harness.handler.flows.size(), test.pendingFlows)
}
body := response.Body.String()
for _, forbidden := range []string{"<script>", "denied by policy", "server_error", "access_denied", "authorization-code"} {
if strings.Contains(body, forbidden) {
t.Fatalf("response body reflected untrusted text %q: %s", forbidden, body)
}
}
})
}
}
func TestAuditFailureBlocksSession(t *testing.T) {
harness := newHarness(t, func(options *Options) {
options.Audit = func(context.Context, string, string) error { return errors.New("audit unavailable") }
})
cookie, query, _ := harness.begin(t, "/auth/login")
response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
if reason := errorReason(t, response); reason != reasonUnavailable {
t.Fatalf("reason = %q, want %q", reason, reasonUnavailable)
}
if issued := harness.sessions.issued(); len(issued) != 0 {
t.Fatalf("session issued despite audit failure: %#v", issued)
}
}
func TestAuditRecordsSuccessfulLogin(t *testing.T) {
var actor, result string
harness := newHarness(t, func(options *Options) {
options.Audit = func(_ context.Context, recordedActor, recordedResult string) error {
actor, result = recordedActor, recordedResult
return nil
}
})
cookie, query, _ := harness.begin(t, "/auth/login")
harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
if actor != "user-1" || result != "success" {
t.Fatalf("audit actor = %q, result = %q", actor, result)
}
}
func TestPostLoginRedirectRejectsUnsafeTargets(t *testing.T) {
for _, test := range []struct {
name string
redirect string
want string
}{
{"in-app path", "/incidents", "/incidents"},
{"in-app path with query and fragment", "/dashboards/1?tab=live#top", "/dashboards/1?tab=live#top"},
{"absent", "", "/"},
{"protocol relative", "//evil.example/phish", "/"},
{"triple slash", "///evil.example", "/"},
{"absolute http", "http://evil.example", "/"},
{"absolute https", "https://evil.example/x", "/"},
{"scheme relative backslash", "/\\evil.example", "/"},
{"backslashes", "\\\\evil.example", "/"},
{"relative path", "incidents", "/"},
{"javascript scheme", "javascript:alert(1)", "/"},
{"data scheme", "data:text/html,<script>alert(1)</script>", "/"},
{"newline injection", "/incidents\r\nSet-Cookie: x=1", "/"},
{"userinfo authority", "//user:pass@evil.example/", "/"},
{"overlong", "/" + strings.Repeat("a", maxRedirectLength), "/"},
} {
t.Run(test.name, func(t *testing.T) {
harness := newHarness(t, nil)
cookie, query, _ := harness.begin(t, "/auth/login?redirect="+url.QueryEscape(test.redirect))
response := harness.complete(t, cookie, url.Values{"state": {query.Get("state")}, "code": {"authorization-code"}})
if response.Code != http.StatusFound {
t.Fatalf("status = %d", response.Code)
}
if location := response.Header().Get("Location"); location != test.want {
t.Fatalf("location = %q, want %q", location, test.want)
}
})
}
}
func TestConcurrentFlowCreationIsSafeAndBounded(t *testing.T) {
const workers = 64
harness := newHarness(t, func(options *Options) { options.MaxFlows = 16 })
var wait sync.WaitGroup
cookies := make([]string, workers)
for index := range workers {
wait.Add(1)
go func() {
defer wait.Done()
request := httptest.NewRequest(http.MethodGet, "/auth/login", nil)
response := httptest.NewRecorder()
harness.handler.LoginHandler().ServeHTTP(response, request)
for _, cookie := range response.Result().Cookies() {
if cookie.Name == flowCookieName {
cookies[index] = cookie.Value
}
}
}()
}
wait.Wait()
unique := make(map[string]struct{}, workers)
for _, cookie := range cookies {
if cookie == "" {
t.Fatal("a concurrent login produced no flow cookie")
}
unique[cookie] = struct{}{}
}
if len(unique) != workers {
t.Fatalf("unique flow identifiers = %d, want %d", len(unique), workers)
}
if size := harness.handler.flows.size(); size > 16 {
t.Fatalf("pending flows = %d, want at most 16", size)
}
}
func TestDiscoveryFailureRedirectsSafely(t *testing.T) {
closed := httptest.NewServer(http.NewServeMux())
issuer := closed.URL
closed.Close()
handler, err := New(Options{
OIDC: auth.OIDCConfig{Issuer: issuer, ClientID: testClientID, RedirectURL: "https://pulse.example/auth/callback"},
Sessions: &recordingSessions{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
response := httptest.NewRecorder()
handler.LoginHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if reason := errorReason(t, response); reason != reasonProviderUnavailable {
t.Fatalf("reason = %q, want %q", reason, reasonProviderUnavailable)
}
if body := response.Body.String(); strings.Contains(body, issuer) {
t.Fatalf("response leaked the issuer: %s", body)
}
}
func TestNonGetMethodsAreRejected(t *testing.T) {
harness := newHarness(t, nil)
for _, test := range []struct {
name string
handler http.Handler
target string
}{
{"login", harness.handler.LoginHandler(), "/auth/login"},
{"callback", harness.handler.CallbackHandler(), "/auth/callback"},
} {
t.Run(test.name, func(t *testing.T) {
response := httptest.NewRecorder()
test.handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, test.target, nil))
if response.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", response.Code, http.StatusMethodNotAllowed)
}
if contentType := response.Header().Get("Content-Type"); contentType != "application/problem+json" {
t.Fatalf("content type = %q", contentType)
}
})
}
}
func TestNewValidatesOptions(t *testing.T) {
valid := auth.OIDCConfig{Issuer: "https://idp.example", ClientID: "pulse", RedirectURL: "https://pulse.example/auth/callback"}
for _, test := range []struct {
name string
options Options
wantErr bool
}{
{"complete", Options{OIDC: valid, Sessions: &recordingSessions{}}, false},
{"missing issuer", Options{OIDC: auth.OIDCConfig{ClientID: "pulse", RedirectURL: valid.RedirectURL}, Sessions: &recordingSessions{}}, true},
{"missing client id", Options{OIDC: auth.OIDCConfig{Issuer: valid.Issuer, RedirectURL: valid.RedirectURL}, Sessions: &recordingSessions{}}, true},
{"missing redirect url", Options{OIDC: auth.OIDCConfig{Issuer: valid.Issuer, ClientID: "pulse"}, Sessions: &recordingSessions{}}, true},
{"missing sessions", Options{OIDC: valid}, true},
} {
t.Run(test.name, func(t *testing.T) {
handler, err := New(test.options)
if (err != nil) != test.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, test.wantErr)
}
if err != nil {
if handler != nil {
t.Fatal("handler returned with an error")
}
return
}
if handler.options.ErrorPath != defaultErrorPath || handler.options.DefaultRedirect != defaultRedirect || handler.options.GroupsClaim != "groups" {
t.Fatalf("defaults not applied: %#v", handler.options)
}
})
}
}
func TestNewNormalizesUnsafePaths(t *testing.T) {
handler, err := New(Options{
OIDC: auth.OIDCConfig{Issuer: "https://idp.example", ClientID: "pulse", RedirectURL: "https://pulse.example/auth/callback"},
Sessions: &recordingSessions{},
DefaultRedirect: "//evil.example",
ErrorPath: "/login/error?reason=spoofed",
})
if err != nil {
t.Fatalf("New: %v", err)
}
if handler.options.DefaultRedirect != defaultRedirect || handler.options.ErrorPath != defaultErrorPath {
t.Fatalf("unsafe paths were kept: %#v", handler.options)
}
}
+72
View File
@@ -0,0 +1,72 @@
package authapi_test
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/itworx/pulse/internal/audit"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/authapi"
"github.com/itworx/pulse/internal/config"
"github.com/itworx/pulse/internal/correlation"
"github.com/jackc/pgx/v5/pgxpool"
)
// TestDocumentedWiringCompilesAndRoutes mirrors the registration snippet in the
// package documentation so cmd/api/main.go can copy it verbatim.
func TestDocumentedWiringCompilesAndRoutes(t *testing.T) {
// The issuer points at a closed local server so the test stays offline: both
// endpoints then answer with the safe error redirect instead of a session.
unreachable := httptest.NewServer(http.NewServeMux())
unreachable.Close()
application := config.Config{
Environment: config.Development,
AuthMode: "oidc",
OIDCIssuer: unreachable.URL,
OIDCClientID: "pulse",
OIDCRedirectURL: "https://pulse.example/auth/callback",
}
sessions := auth.NewSessionManager("pulse_session", 8*time.Hour, application.Environment == config.Production)
logger := slog.New(slog.DiscardHandler)
var pool *pgxpool.Pool
oidcAuth, err := authapi.New(authapi.Options{
OIDC: auth.OIDCConfig{
Issuer: application.OIDCIssuer,
ClientID: application.OIDCClientID,
ClientSecret: application.OIDCClientSecret,
RedirectURL: application.OIDCRedirectURL,
},
RoleMapping: map[string]auth.Role{
"pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator,
"pulse-editor": auth.RoleEditor, "pulse-admin": auth.RoleAdministrator,
},
Sessions: sessions,
Secure: application.Environment == config.Production,
Logger: logger,
Audit: func(ctx context.Context, actor, result string) error {
if pool == nil {
return nil
}
return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/auth/login", oidcAuth.LoginHandler())
mux.Handle("/auth/callback", oidcAuth.CallbackHandler())
for _, path := range []string{"/auth/login", "/auth/callback"} {
response := httptest.NewRecorder()
mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusFound {
t.Fatalf("%s status = %d, want %d", path, response.Code, http.StatusFound)
}
}
}