This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user