diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1606243b..f502fd00 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,8 @@ jobs: prepare: name: Prepare Release runs-on: ubuntu-latest + permissions: + contents: write # pushes the dispatched tag and creates the GitHub release outputs: tag: ${{ steps.set-tag.outputs.tag }} version: ${{ steps.set-tag.outputs.version }} diff --git a/account/breach.go b/account/breach.go index ec1fbd1d..25f7d803 100644 --- a/account/breach.go +++ b/account/breach.go @@ -5,6 +5,7 @@ import ( // #nosec G505 -- SHA-1 imported solely for the HIBP k-Anonymity protocol; see account/breach.go. "crypto/sha1" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -15,6 +16,13 @@ import ( // ErrPasswordBreached is returned when a password has been found in a data breach. var ErrPasswordBreached = fmt.Errorf("%w: password found in data breach", ErrWeakPassword) +// ErrBreachCheckUnavailable is what IsBreachedContext returns when the +// lookup could not run: the request failed, HIBP answered something other +// than 200, or the body could not be read. The password is not reported as +// breached in that case. The transport's own error is not wrapped, because +// it quotes the request URL and the URL carries the hash prefix. +var ErrBreachCheckUnavailable = errors.New("account: breach check could not run") + // BreachChecker checks passwords against the HaveIBeenPwned Passwords API // using the k-Anonymity model (only a 5-character SHA-1 prefix is sent). type BreachChecker struct { @@ -22,12 +30,22 @@ type BreachChecker struct { baseURL string } +// BreachCheckTimeout bounds one HIBP range lookup. +const BreachCheckTimeout = 5 * time.Second + // NewBreachChecker creates a new breach checker with sensible defaults. func NewBreachChecker() *BreachChecker { + return NewBreachCheckerWithClient(nil) +} + +// NewBreachCheckerWithClient is NewBreachChecker sending its lookups +// through client. A nil client is a plain one with BreachCheckTimeout. +func NewBreachCheckerWithClient(client *http.Client) *BreachChecker { + if client == nil { + client = &http.Client{Timeout: BreachCheckTimeout} + } return &BreachChecker{ - client: &http.Client{ - Timeout: 5 * time.Second, - }, + client: client, baseURL: "https://api.pwnedpasswords.com/range/", } } @@ -36,6 +54,16 @@ func NewBreachChecker() *BreachChecker { // Returns true if the password hash appears in the HIBP database. // On network errors, returns false (fail-open to avoid blocking auth). func (bc *BreachChecker) IsBreached(password string) (bool, error) { + breached, _ := bc.IsBreachedContext(context.Background(), password) //nolint:errcheck // IsBreached fails open and has always returned a nil error + return breached, nil +} + +// IsBreachedContext is IsBreached with the lookup made under ctx, so it +// carries ctx's values (its trace span among them) and stops when ctx does. +// It fails open too, reporting false, but says so: when the lookup could +// not run the error is ErrBreachCheckUnavailable, so a caller can count a +// control that did not run. +func (bc *BreachChecker) IsBreachedContext(ctx context.Context, password string) (bool, error) { // SHA-1 hash the password // #nosec G401 -- SHA-1 is mandated by the HIBP k-Anonymity range API; this is a breach lookup, not password storage. h := sha1.New() @@ -45,24 +73,24 @@ func (bc *BreachChecker) IsBreached(password string) (bool, error) { prefix := hash[:5] suffix := hash[5:] - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, bc.baseURL+prefix, http.NoBody) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, bc.baseURL+prefix, http.NoBody) if err != nil { - return false, nil //nolint:nilerr // fail-open: don't block auth on request build errors + return false, ErrBreachCheckUnavailable } resp, err := bc.client.Do(req) if err != nil { - // Fail open on network errors — don't block authentication. - return false, nil //nolint:nilerr // fail-open: don't block auth on network errors + // Fail open on network errors: don't block authentication. + return false, ErrBreachCheckUnavailable } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return false, nil + return false, ErrBreachCheckUnavailable } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB limit if err != nil { - return false, nil //nolint:nilerr // fail-open: don't block auth on read errors + return false, ErrBreachCheckUnavailable } // Response format: SUFFIX:COUNT\r\n diff --git a/account/breach_test.go b/account/breach_test.go new file mode 100644 index 00000000..0db7fdc7 --- /dev/null +++ b/account/breach_test.go @@ -0,0 +1,62 @@ +package account_test + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/account" +) + +// roundTrip answers every request without touching the network. +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func answer(status int, body string) roundTrip { + return func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + } +} + +func checker(rt http.RoundTripper) *account.BreachChecker { + return account.NewBreachCheckerWithClient(&http.Client{Transport: rt}) +} + +func TestBreachCheckerFindsABreachedPassword(t *testing.T) { + sum := sha1.Sum([]byte("hunter2")) + hash := strings.ToUpper(hex.EncodeToString(sum[:])) + c := checker(answer(http.StatusOK, "0000000000000000000000000000000000A:1\r\n"+hash[5:]+":42\r\n")) + + breached, err := c.IsBreachedContext(context.Background(), "hunter2") + require.NoError(t, err) + assert.True(t, breached) +} + +// A lookup that can't run reports false either way, but only the context +// variant says it didn't run. IsBreached keeps its old contract. +func TestBreachCheckerSaysWhenItCouldNotRun(t *testing.T) { + for name, rt := range map[string]roundTrip{ + "transport error": func(*http.Request) (*http.Response, error) { return nil, errors.New("dial tcp: refused") }, + "non-200": answer(http.StatusServiceUnavailable, ""), + } { + t.Run(name, func(t *testing.T) { + breached, err := checker(rt).IsBreachedContext(context.Background(), "hunter2") + assert.False(t, breached) + require.ErrorIs(t, err, account.ErrBreachCheckUnavailable) + assert.NotContains(t, err.Error(), "refused", "the transport's error, which quotes the URL, isn't wrapped") + + breached, err = checker(rt).IsBreached("hunter2") + assert.False(t, breached) + assert.NoError(t, err, "IsBreached still fails open with a nil error") + }) + } +} diff --git a/api/auth_handlers.go b/api/auth_handlers.go index 6abbb73d..460105ad 100644 --- a/api/auth_handlers.go +++ b/api/auth_handlers.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/rand" "encoding/hex" "errors" @@ -15,6 +16,7 @@ import ( "github.com/xraph/authsome/account" "github.com/xraph/authsome/bridge" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/session" "github.com/xraph/authsome/user" @@ -44,10 +46,11 @@ func (a *API) captchaOpt(action string) []forge.RouteOption { } return []forge.RouteOption{ forge.WithMiddleware(middleware.CaptchaMiddleware(middleware.CaptchaOptions{ - Settings: mgr, - Action: action, - Chronicle: bridge.NewBusChronicle(a.engine.Hooks()), - Logger: a.engine.Logger(), + Settings: mgr, + Action: action, + Chronicle: bridge.NewBusChronicle(a.engine.Hooks()), + Logger: a.engine.Logger(), + VerifierFor: a.captchaVerifierFor, })), } } @@ -169,7 +172,7 @@ func (a *API) handleSignUp(ctx forge.Context, req *SignUpRequest) (*AuthResponse // emails and use the response time to enumerate registered // addresses (duplicate ~1ms vs fresh ~100ms+). Result is // discarded. - a.consumeDummyHashBudget(req.Password) + a.consumeDummyHashBudget(ctx.Context(), req.Password) return nil, ctx.JSON(http.StatusCreated, a.syntheticSignupResponse(req.Email, appID)) } return nil, mapErrorCtx(ctx, err) @@ -184,10 +187,14 @@ func (a *API) handleSignUp(ctx forge.Context, req *SignUpRequest) (*AuthResponse // the duplicate-email path so the response time matches a real signup. Any // error from the hash function is intentionally ignored — this is purely a // time-budget consumer. -func (a *API) consumeDummyHashBudget(password string) { +// +// It runs under the same hash password span, and lands in the same hash +// histogram, as a fresh sign-up's real hash, so an operator reading traces +// or metrics can't tell the duplicate from the fresh sign-up either. +func (a *API) consumeDummyHashBudget(ctx context.Context, password string) { // The engine owns the hash and its policy; sign-in's unknown-identifier // path spends the same budget through the same method. - policy := a.engine.ConsumeDummyHash(password) + policy := a.engine.ConsumeDummyHashContext(ctx, password) // Test-only observation point (nil in production). Reports the policy // actually used so a test can assert the duplicate path hashes with the @@ -266,6 +273,12 @@ func (a *API) handleSignIn(ctx forge.Context, req *SignInRequest) (*AuthResponse dpopJKT, err := a.engine.DPoPBindingForRequest(ctx, appID) if err != nil { + // Refused before SignIn runs, so it is counted here: a DPoP mandate + // the request can't satisfy is a denied sign-in, as it is on every + // other method. + if errors.Is(err, authsome.ErrDPoPBindingRequired) { + a.engine.Telemetry().SignIn(telemetry.MethodPassword, telemetry.SignInDenied) + } return nil, err } diff --git a/api/captcha_telemetry.go b/api/captcha_telemetry.go new file mode 100644 index 00000000..7659a6e5 --- /dev/null +++ b/api/captcha_telemetry.go @@ -0,0 +1,26 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/xraph/authsome/captcha" + "github.com/xraph/authsome/internal/telemetry" +) + +// captchaVerifierFor is the captcha middleware's default verifier factory +// with each Turnstile call measured in the engine's provider latency +// histogram as well as traced. +func (a *API) captchaVerifierFor(provider, secret string) (captcha.Verifier, error) { + switch provider { + case "", "turnstile": + if secret == "" { + return nil, errors.New("captcha: empty secret for turnstile") + } + client := telemetry.InstrumentClient(&http.Client{Timeout: captcha.DefaultTurnstileTimeout}, + telemetry.Endpoint{Kind: telemetry.ProviderCaptcha}, a.engine.Telemetry()) + return captcha.NewTurnstileVerifier(secret, client), nil + default: + return nil, errors.New("captcha: unknown provider: " + provider) + } +} diff --git a/api/telemetry_privacy_test.go b/api/telemetry_privacy_test.go new file mode 100644 index 00000000..3487e2ac --- /dev/null +++ b/api/telemetry_privacy_test.go @@ -0,0 +1,294 @@ +package api_test + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/api" + "github.com/xraph/authsome/apikey" + "github.com/xraph/authsome/appclientconfig" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/middleware" + apikeyplugin "github.com/xraph/authsome/plugins/apikey" + "github.com/xraph/authsome/plugins/mfa" + "github.com/xraph/authsome/plugins/oauth2provider" + "github.com/xraph/authsome/store/memory" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" +) + +// randomHex returns n random bytes as hex, so the probe values below are +// recognisable in a failure message without being fixed strings a secret +// scanner would flag. +func randomHex(t *testing.T, n int) string { + t.Helper() + b := make([]byte, n) + _, err := rand.Read(b) + require.NoError(t, err) + return hex.EncodeToString(b) +} + +// TestTelemetryCarriesNoCredentialsOrPersonalData drives sign-in (wrong +// password, unknown identifier, the MFA gate and the TOTP challenge), +// refresh, API key authentication and the OAuth2 token endpoint, all with +// values that are easy to spot, and then checks that none of them reached +// a span authsome started or the metric scrape. +// +// forge's own server span is left out on purpose: it records url.path and +// user_agent.original, which is forge's to redact, not authsome's. +func TestTelemetryCarriesNoCredentialsOrPersonalData(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + + probe := randomHex(t, 6) + email := "privacy-" + probe + "@example.com" + unknownEmail := "nobody-" + probe + "@example.com" + password := "Pw-" + probe + "-Aa1!" + wrongPassword := "Wrong-" + probe + "-Aa1!" + clientIP := "203.0.113.77" + remoteIP := "198.51.100.23" + userAgent := "PrivacyProbe/" + probe + clientID := "client-" + probe + clientSecret := "cs-" + randomHex(t, 16) + + mfaStore := mfa.NewMemoryStore() + mfaPlugin := mfa.New() + mfaPlugin.SetStore(mfaStore) + + oauthStore := oauth2provider.NewMemoryStore() + oauthPlugin := oauth2provider.New(oauth2provider.Config{Issuer: "https://auth.example.com"}) + oauthPlugin.SetOAuth2Store(oauthStore) + + s := memory.New() + seedTestPlatformApp(t, s) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := authsome.NewEngine( + authsome.WithConfig(testConfig()), + authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithStore(s), + authsome.WithWarden(w), + authsome.WithDisableMigrate(), + authsome.WithAppID(testAppIDStr), + authsome.WithMetricsSink(reg), + authsome.WithPlugin(mfaPlugin), + authsome.WithPlugin(apikeyplugin.New()), + authsome.WithPlugin(oauthPlugin), + ) + require.NoError(t, err) + ctx := context.Background() + require.NoError(t, eng.Start(ctx)) + t.Cleanup(func() { _ = eng.Stop(ctx) }) + secutil.RelaxAuthDefaults(t, eng) + oauthPlugin.SetStore(s) + + appID, err := id.ParseAppID(testAppIDStr) + require.NoError(t, err) + + rootRouter := forge.NewRouter() + a := api.New(eng, rootRouter) + require.NoError(t, a.RegisterRoutes(rootRouter)) + require.NoError(t, mfaPlugin.RegisterRoutes(rootRouter)) + require.NoError(t, oauthPlugin.RegisterRoutes(rootRouter)) + // A route behind the auth middleware, so an API key goes through the + // strategy the way it does in production. + guarded := forge.NewRouter() + guarded.Use(middleware.AuthMiddlewareWithStrategies(eng.ResolveSessionByToken, eng.ResolveUser, eng.Strategies(), eng.Logger())) + require.NoError(t, guarded.GET("/whoami", func(c forge.Context) error { + if _, ok := middleware.UserIDFrom(c.Context()); !ok { + return c.NoContent(http.StatusUnauthorized) + } + return c.NoContent(http.StatusNoContent) + })) + router := withTestKey(rootRouter.Handler()) + + // send runs one request with the probe's address and user agent. + send := func(h http.Handler, method, path string, body any, hdr map[string]string) *httptest.ResponseRecorder { + t.Helper() + var rdr *bytes.Reader + if body != nil { + b, mErr := json.Marshal(body) + require.NoError(t, mErr) + rdr = bytes.NewReader(b) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequestWithContext(ctx, method, path, rdr) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + req.Header.Set("X-Forwarded-For", clientIP) + req.RemoteAddr = remoteIP + ":4242" + for k, v := range hdr { + req.Header.Set(k, v) + } + out := httptest.NewRecorder() + h.ServeHTTP(out, req) + return out + } + + // Sign up, then put a verified TOTP factor on the account and require + // MFA for the app. + _, _, _ = signUp(t, eng, email, password) + u, err := s.GetUserByEmail(ctx, appID, id.Nil, email) + require.NoError(t, err) + totpKey, err := mfa.GenerateTOTPKey(mfa.TOTPConfig{Issuer: "PrivacyProbe", AccountName: email}) + require.NoError(t, err) + require.NoError(t, mfaStore.CreateEnrollment(ctx, &mfa.Enrollment{ + ID: id.NewMFAID(), UserID: u.ID, Method: "totp", Secret: totpKey.Secret(), Verified: true, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + })) + tru := true + require.NoError(t, s.SetAppClientConfig(ctx, &appclientconfig.Config{ID: id.NewAppClientConfigID(), AppID: appID, MFARequired: &tru})) + + // Sign-in: a wrong password, an unknown identifier, then the right + // password, which the MFA gate answers with a ticket. + assert.Equal(t, http.StatusUnauthorized, send(router, http.MethodPost, "/v1/signin", map[string]string{"email": email, "password": wrongPassword}, nil).Code) + assert.Equal(t, http.StatusUnauthorized, send(router, http.MethodPost, "/v1/signin", map[string]string{"email": unknownEmail, "password": wrongPassword}, nil).Code) + gateRes := send(router, http.MethodPost, "/v1/signin", map[string]string{"email": email, "password": password}, nil) + require.Equal(t, http.StatusForbidden, gateRes.Code, gateRes.Body.String()) + var gate map[string]any + require.NoError(t, json.NewDecoder(gateRes.Body).Decode(&gate)) + ticket, _ := gate["mfa_ticket"].(string) + require.NotEmpty(t, ticket) + + // MFA: a wrong code, then the right one. + code, err := mfa.GenerateTOTPCode(totpKey.Secret()) + require.NoError(t, err) + wrongCode := "000000" + if code == wrongCode { + wrongCode = "111111" + } + assert.Equal(t, http.StatusUnauthorized, send(router, http.MethodPost, "/v1/mfa/challenge", map[string]string{"mfa_ticket": ticket, "code": wrongCode}, nil).Code) + chRes := send(router, http.MethodPost, "/v1/mfa/challenge", map[string]string{"mfa_ticket": ticket, "code": code}, nil) + require.Equal(t, http.StatusOK, chRes.Code, chRes.Body.String()) + var session map[string]any + require.NoError(t, json.NewDecoder(chRes.Body).Decode(&session)) + accessToken, _ := session["session_token"].(string) + refreshToken, _ := session["refresh_token"].(string) + require.NotEmpty(t, accessToken) + require.NotEmpty(t, refreshToken) + + // Refresh with the token the challenge returned. + refRes := send(router, http.MethodPost, "/v1/refresh", map[string]string{"refresh_token": refreshToken}, nil) + require.Equal(t, http.StatusOK, refRes.Code, refRes.Body.String()) + var refreshed map[string]any + require.NoError(t, json.NewDecoder(refRes.Body).Decode(&refreshed)) + newAccess, _ := refreshed["session_token"].(string) + newRefresh, _ := refreshed["refresh_token"].(string) + + // API key: a good key and a guess. + rawKey, keyHash, keyPrefix, err := apikey.GenerateKey() + require.NoError(t, err) + now := time.Now() + require.NoError(t, s.CreateAPIKey(ctx, &apikey.APIKey{ + ID: id.NewAPIKeyID(), AppID: appID, UserID: u.ID, Name: "probe", KeyHash: keyHash, KeyPrefix: keyPrefix, + CreatedAt: now, UpdatedAt: now, + })) + assert.Equal(t, http.StatusNoContent, send(guarded, http.MethodGet, "/whoami", nil, + map[string]string{"Authorization": "Bearer " + rawKey, "X-App-ID": testAppIDStr}).Code) + guess := rawKey[:len(rawKey)-4] + "zzzz" + assert.NotEqual(t, http.StatusNoContent, send(guarded, http.MethodGet, "/whoami", nil, + map[string]string{"Authorization": "Bearer " + guess, "X-App-ID": testAppIDStr}).Code) + + // OAuth2: client credentials with the right secret and a wrong one. + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(clientSecret), bcrypt.MinCost) + require.NoError(t, err) + require.NoError(t, oauthStore.CreateClient(ctx, &oauth2provider.OAuth2Client{ + ID: id.NewOAuth2ClientID(), AppID: appID, ClientID: clientID, FirstParty: true, + ClientSecret: string(hashedSecret), Name: "Probe", RedirectURIs: []string{"https://probe.example.com/cb"}, + Scopes: []string{"openid"}, GrantTypes: []string{"client_credentials"}, + })) + tokRes := send(router, http.MethodPost, "/v1/oauth/token", + map[string]string{"grant_type": "client_credentials", "client_id": clientID, "client_secret": clientSecret}, nil) + require.Equal(t, http.StatusOK, tokRes.Code, tokRes.Body.String()) + var issued map[string]any + require.NoError(t, json.NewDecoder(tokRes.Body).Decode(&issued)) + oauthAccess, _ := issued["access_token"].(string) + assert.Equal(t, http.StatusUnauthorized, send(router, http.MethodPost, "/v1/oauth/token", + map[string]string{"grant_type": "client_credentials", "client_id": clientID, "client_secret": clientSecret + "x"}, nil).Code) + + // The flows above produced the telemetry they should have. + spans := telemetrytest.Authsome(rec) + names := telemetrytest.Names(spans) + for _, want := range []string{"verify password", "issue session", "refresh session", "authenticate api key", "verify client secret", "hash password"} { + assert.Contains(t, names, want) + } + body := scrape() + for _, want := range []string{ + `forge_authsome_signin_attempts_total{method="password",result="invalid"} 2`, + `forge_authsome_signin_attempts_total{method="password",result="mfa_required"} 1`, + `forge_authsome_mfa_verifications_total{flow="signin",method="totp",result="invalid"} 1`, + `forge_authsome_mfa_verifications_total{flow="signin",method="totp",result="ok"} 1`, + `forge_authsome_sessions_refreshed_total{result="ok"} 1`, + `forge_authsome_apikey_authentications_total{result="ok"} 1`, + `forge_authsome_apikey_authentications_total{result="invalid"} 1`, + `forge_authsome_oauth2_tokens_requested_total{grant_type="client_credentials",result="ok"} 1`, + `forge_authsome_oauth2_tokens_requested_total{grant_type="client_credentials",result="invalid_client"} 1`, + } { + assert.Contains(t, body, want) + } + + // And none of the values reached any of it. + secrets := map[string]string{ + "email": email, + "unknown email": unknownEmail, + "email local part": "privacy-" + probe, + "password": password, + "wrong password": wrongPassword, + "client IP": clientIP, + "remote IP": remoteIP, + "user agent": userAgent, + "mfa ticket": ticket, + "totp code": code, + "totp secret": totpKey.Secret(), + "access token": accessToken, + "refresh token": refreshToken, + "refreshed access": newAccess, + "refreshed refresh": newRefresh, + "api key": rawKey, + "api key prefix": keyPrefix, + "api key guess": guess, + "oauth2 client id": clientID, + "oauth2 client secret": clientSecret, + "oauth2 access token": oauthAccess, + "user id": u.ID.String(), + "app id": testAppIDStr, + "probe": probe, + "publishable key": testPublishableKey, + "identifier-hash marker": "identifier_hash", + } + for _, s := range spans { + text := telemetrytest.Text(s) + for what, v := range secrets { + if v == "" { + continue + } + assert.False(t, strings.Contains(text, v), "%s reached span %q:\n%s", what, s.Name(), text) + } + } + for what, v := range secrets { + if v == "" { + continue + } + assert.False(t, strings.Contains(body, v), "%s reached the metric scrape", what) + } +} diff --git a/api/telemetry_signin_test.go b/api/telemetry_signin_test.go new file mode 100644 index 00000000..61a924a9 --- /dev/null +++ b/api/telemetry_signin_test.go @@ -0,0 +1,96 @@ +package api_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/store/memory" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" +) + +// /v1/signin checks the DPoP mandate before SignIn runs, so the handler +// counts that refusal itself, as "denied" like every other method does. +func TestSignIn_DPoPMandateRefusalCountsAsDenied(t *testing.T) { + reg, scrape := telemetrytest.Registry(t) + s := memory.New() + seedTestPlatformApp(t, s) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := authsome.NewEngine(authsome.WithConfig(testConfig()), authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithStore(s), authsome.WithWarden(w), authsome.WithDisableMigrate(), + authsome.WithAppID(testAppIDStr), authsome.WithMetricsSink(reg)) + require.NoError(t, err) + require.NoError(t, eng.Start(context.Background())) + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + secutil.RelaxAuthDefaults(t, eng) + + const email = "dpop-denied-api@example.com" + signUp(t, eng, email, "SecureP@ss123") + setSignInDPoPMode(t, eng, "required") + + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/signin", + bytes.NewReader([]byte(`{"email":"`+email+`","password":"SecureP@ss123"}`))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + newAPIWithRouter(t, eng).ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + + assert.ElementsMatch(t, []string{ + `forge_authsome_signin_attempts_total{method="password",result="denied"}`, + }, telemetrytest.Series(scrape(), "forge_authsome_signin_attempts_total")) +} + +// A duplicate sign-up answers like a fresh one and spends the same hash, so +// it must look the same to an operator too: one hash password span with the +// same attributes, one observation in the hash histogram. +func TestSignUp_DuplicateAddressLooksLikeAFreshSignUp(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + s := memory.New() + seedTestPlatformApp(t, s) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := authsome.NewEngine(authsome.WithConfig(testConfig()), authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithStore(s), authsome.WithWarden(w), authsome.WithDisableMigrate(), + authsome.WithAppID(testAppIDStr), authsome.WithMetricsSink(reg)) + require.NoError(t, err) + require.NoError(t, eng.Start(context.Background())) + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + secutil.RelaxAuthDefaults(t, eng) + router := newAPIWithRouter(t, eng) + + signUpOnce := func() []map[string]string { + t.Helper() + n := len(rec.Ended()) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/signup", + bytes.NewReader([]byte(`{"email":"twice@example.com","password":"SecureP@ss123","first_name":"T"}`))) + req.Header.Set("Content-Type", "application/json") + out := httptest.NewRecorder() + router.ServeHTTP(out, req) + require.Equal(t, http.StatusCreated, out.Code, out.Body.String()) + spans := telemetrytest.Named(telemetrytest.OnlyAuthsome(rec.Ended()[n:]), "hash password") + hashes := make([]map[string]string, 0, len(spans)) + for _, sp := range spans { + hashes = append(hashes, telemetrytest.Attrs(sp)) + } + return hashes + } + + fresh := signUpOnce() + duplicate := signUpOnce() + require.Len(t, fresh, 1) + assert.Equal(t, fresh, duplicate) + assert.Contains(t, scrape(), `forge_authsome_password_hash_duration_seconds_count{algorithm="bcrypt",operation="hash"} 2`) +} diff --git a/bridge/maileradapter/resend.go b/bridge/maileradapter/resend.go index 036a9294..4970b2ff 100644 --- a/bridge/maileradapter/resend.go +++ b/bridge/maileradapter/resend.go @@ -12,6 +12,7 @@ import ( "time" "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/telemetry" ) // ResendMailer delivers email via the Resend HTTP API. @@ -40,9 +41,10 @@ func NewResendMailer(apiKey, fromAddr string, opts ...ResendOption) *ResendMaile m := &ResendMailer{ apiKey: apiKey, fromAddr: fromAddr, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + // Each call is a client span with the host and status on it and + // nothing from the message. + httpClient: telemetry.InstrumentClient(&http.Client{Timeout: 10 * time.Second}, + telemetry.Endpoint{Kind: telemetry.ProviderEmail}, nil), baseURL: "https://api.resend.com", } for _, opt := range opts { diff --git a/bridge/metricsadapter/adapter.go b/bridge/metricsadapter/adapter.go index 81895c12..5d5952bc 100644 --- a/bridge/metricsadapter/adapter.go +++ b/bridge/metricsadapter/adapter.go @@ -1,7 +1,15 @@ -// Package metricsadapter records authsome's metrics in a Forge metrics -// registry, so an operator who already scrapes the app sees sign-ins, -// refusals, lockouts, replay detections and request latency beside -// everything else, with no second exporter. +// Package metricsadapter implements bridge.MetricsCollector over a go-utils +// metrics registry, such as a Forge app's. +// +// You only need it if you embed the engine and want the bridge signals +// (hook-bus events and the operational counters IncrementCounter carries) +// in a registry. In a Forge app the extension passes the app's registry to +// authsome.WithMetricsSink instead, which records authsome's own metric +// families and needs no adapter. +// +// Every family this adapter writes keeps the same label keys on every +// series, and none is labelled by tenant: the tenant the engine passes is +// an app ID, which is unbounded. package metricsadapter import ( @@ -12,9 +20,10 @@ import ( "github.com/xraph/go-utils/metrics" "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/telemetry" ) -// Adapter implements bridge.MetricsCollector over a Forge metrics registry. +// Adapter implements bridge.MetricsCollector over a metrics registry. type Adapter struct { m metrics.Metrics } @@ -28,51 +37,65 @@ func New(m forge.Metrics) *Adapter { return &Adapter{m: m} } -// metricName turns an authsome signal name into a registry name: dots and -// dashes become underscores under the authsome_ prefix. +// metricName turns an authsome signal name into a registry name: dots, +// dashes and spaces become underscores under the authsome_ prefix. func metricName(name string) string { r := strings.NewReplacer(".", "_", "-", "_", " ", "_") return "authsome_" + r.Replace(name) } -func labels(kv ...string) metrics.MetricOption { - m := make(map[string]string, len(kv)/2) - for i := 0; i+1 < len(kv); i += 2 { - if kv[i+1] != "" { - m[kv[i]] = kv[i+1] - } +// counterName is metricName with the _total suffix a counter carries. +func counterName(name string) string { + n := metricName(name) + if strings.HasSuffix(n, "_total") { + return n } - return metrics.WithLabels(m) + return n + "_total" } -// RecordEvent implements bridge.MetricsCollector: one counter per -// (action, resource, outcome, tenant) and, when a duration is given, a -// histogram of it in seconds. -func (a *Adapter) RecordEvent(action, resource, outcome, tenant string, duration time.Duration) { +// eventLabels always sets all three keys, empty or not, so every series +// under one name has the same label set. The values go through +// telemetry.Bounded, so a caller that passes free text can't grow the +// series without bound. +func eventLabels(action, resource, outcome string) metrics.MetricOption { + return metrics.WithLabels(map[string]string{ + "action": telemetry.Bounded(action), + "resource": telemetry.Bounded(resource), + "outcome": telemetry.Bounded(outcome), + }) +} + +// RecordEvent implements bridge.MetricsCollector: authsome_events_total by +// action, resource and outcome and, when a duration is given, a histogram +// of it in seconds under the same labels. The tenant is ignored. +func (a *Adapter) RecordEvent(action, resource, outcome, _ string, duration time.Duration) { if a == nil { return } - l := labels("action", action, "resource", resource, "outcome", outcome, "tenant", tenant) + l := eventLabels(action, resource, outcome) a.m.Counter("authsome_events_total", l).Inc() if duration > 0 { - a.m.Histogram("authsome_event_duration_seconds", l).Observe(duration.Seconds()) + a.m.Histogram("authsome_event_duration_seconds", l, metrics.WithUnit("s")).Observe(duration.Seconds()) } } -// IncrementGauge implements bridge.MetricsCollector. -func (a *Adapter) IncrementGauge(name, tenant string, delta int) { +// IncrementGauge implements bridge.MetricsCollector. The tenant is +// ignored. +func (a *Adapter) IncrementGauge(name, _ string, delta int) { if a == nil { return } - a.m.Gauge(metricName(name), labels("tenant", tenant)).Add(float64(delta)) + a.m.Gauge(metricName(name)).Add(float64(delta)) } -// IncrementCounter implements bridge.MetricsCollector. -func (a *Adapter) IncrementCounter(name, tenant string) { +// IncrementCounter implements bridge.MetricsCollector: +// IncrementCounter("auth.lockout", ...) adds one to +// authsome_auth_lockout_total. The tenant is ignored. +func (a *Adapter) IncrementCounter(name, _ string) { if a == nil { return } - a.m.Counter(metricName(name), labels("tenant", tenant)).Inc() + a.m.Counter(counterName(name)).Inc() } var _ bridge.MetricsCollector = (*Adapter)(nil) diff --git a/bridge/metricsadapter/adapter_test.go b/bridge/metricsadapter/adapter_test.go index 7f4ab8c1..c685460f 100644 --- a/bridge/metricsadapter/adapter_test.go +++ b/bridge/metricsadapter/adapter_test.go @@ -6,22 +6,50 @@ import ( "github.com/stretchr/testify/assert" "github.com/xraph/go-utils/metrics" + + "github.com/xraph/authsome/internal/telemetry/telemetrytest" ) func TestAdapterRecordsIntoRegistry(t *testing.T) { reg := metrics.NewMetricsCollector("test") a := New(reg) - a.RecordEvent("auth.signin", "session", "success", "app-1", 30*time.Millisecond) - a.RecordEvent("auth.signin", "session", "success", "app-1", 0) - a.IncrementCounter("refresh.replay_detected", "app-1") - a.IncrementCounter("refresh.replay_detected", "app-1") - a.IncrementGauge("sessions.active", "app-1", 3) + a.RecordEvent("auth.signin", "session", "success", "aapp_01jf0000000000000000000000", 30*time.Millisecond) + a.RecordEvent("auth.signin", "session", "success", "aapp_01jf0000000000000000000001", 0) + a.IncrementCounter("refresh.replay_detected", "aapp_01jf0000000000000000000000") + a.IncrementCounter("refresh.replay_detected", "") + a.IncrementGauge("sessions.active", "aapp_01jf0000000000000000000000", 3) - l := metrics.WithLabels(map[string]string{"action": "auth.signin", "resource": "session", "outcome": "success", "tenant": "app-1"}) + // Two apps, one series: the tenant is not a label. + l := metrics.WithLabels(map[string]string{"action": "auth.signin", "resource": "session", "outcome": "success"}) assert.EqualValues(t, 2, reg.Counter("authsome_events_total", l).Value()) - assert.EqualValues(t, 2, reg.Counter("authsome_refresh_replay_detected", metrics.WithLabels(map[string]string{"tenant": "app-1"})).Value()) + assert.EqualValues(t, 2, reg.Counter("authsome_refresh_replay_detected_total").Value()) + assert.EqualValues(t, 3, reg.Gauge("authsome_sessions_active").Value()) var nilAdapter *Adapter assert.NotPanics(t, func() { nilAdapter.IncrementCounter("x", "") }, "a nil adapter is a no-op") } + +// An event with an empty field keeps all three label keys, so the family +// never mixes label sets, and free text is folded into "other". Checked on a +// real Forge scrape, since that is where a mixed label set would break. +func TestAdapterKeepsLabelKeysConsistent(t *testing.T) { + reg, scrape := telemetrytest.Registry(t) + a := New(reg) + + a.RecordEvent("auth.signin", "", "", "aapp_01jf0000000000000000000000", 0) + a.RecordEvent("GET /v1/sessions/ases_01jf0000000000000000000000", "session", "success", "", 0) + a.IncrementCounter("control.degraded", "aapp_01jf0000000000000000000000") + + assert.ElementsMatch(t, []string{ + `forge_authsome_events_total{action="auth.signin",outcome="",resource=""}`, + `forge_authsome_events_total{action="other",outcome="success",resource="session"}`, + `forge_authsome_control_degraded_total`, + }, telemetrytest.Series(scrape(), "forge_authsome_")) +} + +func TestCounterNameGetsTotalOnce(t *testing.T) { + assert.Equal(t, "authsome_auth_lockout_total", counterName("auth.lockout")) + assert.Equal(t, "authsome_ratelimit_rejected_total", counterName("ratelimit.rejected")) + assert.Equal(t, "authsome_things_total", counterName("things_total")) +} diff --git a/bridge/smsadapter/twilio.go b/bridge/smsadapter/twilio.go index 03b2d0dc..cb47fd00 100644 --- a/bridge/smsadapter/twilio.go +++ b/bridge/smsadapter/twilio.go @@ -12,6 +12,7 @@ import ( "time" "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/telemetry" ) // TwilioSender implements bridge.SMSSender via the Twilio REST API. @@ -32,8 +33,10 @@ func NewTwilioSender(accountSID, authToken, fromNumber string) *TwilioSender { authToken: authToken, fromNumber: fromNumber, // A deadline on every outbound call: a stalled SMS provider must - // not hold a sign-in open indefinitely. - client: &http.Client{Timeout: 10 * time.Second}, + // not hold a sign-in open indefinitely. Each call is a client span + // with no path on it: Twilio's path carries the account SID. + client: telemetry.InstrumentClient(&http.Client{Timeout: 10 * time.Second}, + telemetry.Endpoint{Kind: telemetry.ProviderSMS}, nil), } } diff --git a/captcha/turnstile.go b/captcha/turnstile.go index ee4e4d5e..89630f98 100644 --- a/captcha/turnstile.go +++ b/captcha/turnstile.go @@ -8,15 +8,17 @@ import ( "net/url" "strings" "time" + + "github.com/xraph/authsome/internal/telemetry" ) // turnstileEndpoint is Cloudflare's siteverify endpoint. const turnstileEndpoint = "https://challenges.cloudflare.com/turnstile/v0/siteverify" -// defaultTurnstileTimeout is the default HTTP timeout for the Turnstile +// DefaultTurnstileTimeout is the default HTTP timeout for the Turnstile // verifier. Captcha verification sits on the auth path; we'd rather fail fast // than hang the request. -const defaultTurnstileTimeout = 5 * time.Second +const DefaultTurnstileTimeout = 5 * time.Second // TurnstileVerifier verifies Cloudflare Turnstile tokens. // @@ -29,10 +31,12 @@ type TurnstileVerifier struct { } // NewTurnstileVerifier returns a verifier configured with the given secret. -// httpClient may be nil — defaults to a client with a 5s timeout. +// httpClient may be nil: it defaults to a client with a 5s timeout, whose +// calls to Cloudflare are client spans under the request's span. func NewTurnstileVerifier(secret string, httpClient *http.Client) *TurnstileVerifier { if httpClient == nil { - httpClient = &http.Client{Timeout: defaultTurnstileTimeout} + httpClient = telemetry.InstrumentClient(&http.Client{Timeout: DefaultTurnstileTimeout}, + telemetry.Endpoint{Kind: telemetry.ProviderCaptcha}, nil) } return &TurnstileVerifier{ secret: secret, diff --git a/docs/content/docs/architecture.mdx b/docs/content/docs/architecture.mdx index 2eec9a71..2273702d 100644 --- a/docs/content/docs/architecture.mdx +++ b/docs/content/docs/architecture.mdx @@ -206,7 +206,7 @@ Bridges are the interfaces Authsome uses to reach other systems. Chronicle and W | `Vault` | `bridge.Vault` | Secrets management and feature flag access | | `Dispatch` | `bridge.Dispatcher` | Background job queue for async tasks | | `Ledger` | `bridge.Ledger` | Billing/metering integration | -| `Metrics` | `bridge.MetricsCollector` | Counter and histogram metrics for observability | +| `Metrics` | `bridge.MetricsCollector` | Hook-bus events and operational counters for a collector of your own. authsome's own metric families go through `WithMetricsSink`; see [Observability](/docs/concepts/observability) | ### Bridge adapters diff --git a/docs/content/docs/authentication/overview.mdx b/docs/content/docs/authentication/overview.mdx index 10649c66..3877acd5 100644 --- a/docs/content/docs/authentication/overview.mdx +++ b/docs/content/docs/authentication/overview.mdx @@ -113,12 +113,12 @@ Every authentication operation emits structured events on the hook bus. You can import "github.com/xraph/authsome/hook" engine.Hooks().Subscribe(hook.ActionSignUp, func(ctx context.Context, evt *hook.Event) { - metrics.Increment("auth.signups", map[string]string{"app": evt.Tenant}) + metrics.Increment("auth.signups", nil) }) engine.Hooks().Subscribe(hook.ActionSignIn, func(ctx context.Context, evt *hook.Event) { if evt.Err != nil { - metrics.Increment("auth.signin_failures", map[string]string{"app": evt.Tenant}) + metrics.Increment("auth.signin_failures", nil) } }) diff --git a/docs/content/docs/bridges/metrics.mdx b/docs/content/docs/bridges/metrics.mdx index cee9d67b..8e7dcc72 100644 --- a/docs/content/docs/bridges/metrics.mdx +++ b/docs/content/docs/bridges/metrics.mdx @@ -1,179 +1,109 @@ --- title: Metrics Bridge -description: "Observability integration: emits authentication metrics to Prometheus, OpenTelemetry, or any collector backend." +description: "Hand authsome's hook-bus events and operational counters to a metrics collector of your own." --- -The Metrics bridge connects Authsome to your observability stack. When configured, Authsome emits metrics for every authentication event: event counts by action and outcome, sign-in latency, session counts, active user gauges, and more. The bridge is a thin interface: you wire it to Prometheus, OpenTelemetry, Datadog, or any other metrics backend. +The Metrics bridge gives you authsome's raw signals: every event on the hook bus, and a handful of operational counters. You wire it to whatever you already run. + +In a Forge app you probably want something else. The extension passes the app's registry to `WithMetricsSink`, which records authsome's own metric families (sign-in attempts, MFA checks, refreshes, provider latency and more) with bounded labels and needs no code from you. [Observability](/docs/concepts/observability) lists them. Use this bridge when you embed the engine and want the signals in a backend of your own. ## Interface -The `bridge.MetricsCollector` interface is defined in `github.com/xraph/authsome/bridge`: +`bridge.MetricsCollector` lives in `github.com/xraph/authsome/bridge`: ```go type MetricsCollector interface { RecordEvent(action, resource, outcome, tenant string, duration time.Duration) IncrementGauge(name, tenant string, delta int) + IncrementCounter(name, tenant string) } ``` -### RecordEvent +Register yours with `authsome.WithMetrics(collector)`. -Called after every authentication operation: +### RecordEvent -| Parameter | Type | Description | -|-----------|------|-------------| -| `action` | `string` | Operation name, e.g. `"auth.signin"`, `"auth.signup"`, `"session.refresh"` | -| `resource` | `string` | Entity type affected, e.g. `"session"`, `"user"`, `"mfa"` | -| `outcome` | `string` | `"success"` or `"failure"` | -| `tenant` | `string` | Organisation ID or empty string for no-org context | -| `duration` | `time.Duration` | Wall-clock time of the operation | +The engine calls it once for every hook-bus event, after the audit trail has seen it. -### IncrementGauge +| Parameter | Description | +|-----------|-------------| +| `action` | The event's action, a `hook.Action*` constant such as `auth.signin` or `session.revoke` | +| `resource` | The entity type, such as `session`, `user` or `mfa` | +| `outcome` | `success` or `failure` | +| `tenant` | The app ID the event belongs to | +| `duration` | Always zero today. The engine doesn't time bus events. | -Called to adjust live gauges (e.g., active session count): +Earlier releases also sent one `http.request` event per HTTP request, from `middleware.Metrics`. That middleware is gone: forge measures every route itself, by route template, and the old event labelled by raw path, which put session IDs and secrets into labels. -| Parameter | Type | Description | -|-----------|------|-------------| -| `name` | `string` | Gauge name, e.g. `"active_sessions"`, `"registered_users"` | -| `tenant` | `string` | Organisation ID for per-tenant gauges | -| `delta` | `int` | `+1` to increment, `-1` to decrement | +### IncrementCounter -## Setup with a Prometheus adapter +The engine calls it for these operational signals: -```go -import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/xraph/authsome" - "github.com/xraph/authsome/bridge" - "time" -) +| Name | When | +|------|------| +| `audit.record.failed` | Chronicle refused an audit record | +| `auth.lockout` | A sign-in was refused because the account is locked | +| `refresh.replay_detected` | A rotated refresh token came back for the first time | +| `control.degraded` | A security control couldn't run and let the request through | +| `identifier_limit.rejected` | The per-identifier limiter refused a request | +| `ratelimit.rejected` | A route limiter refused a request | +| `ratelimit.error` | A limiter couldn't answer | -// Prometheus-backed MetricsCollector. -type PrometheusCollector struct { - eventCounter *prometheus.CounterVec - latencyHist *prometheus.HistogramVec - gauges *prometheus.GaugeVec -} +### IncrementGauge -func NewPrometheusCollector(reg prometheus.Registerer) *PrometheusCollector { - return &PrometheusCollector{ - eventCounter: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ - Name: "authsome_events_total", - Help: "Total auth events by action, resource, outcome, and tenant.", - }, []string{"action", "resource", "outcome", "tenant"}), +Part of the interface, but nothing in authsome calls it. - latencyHist: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ - Name: "authsome_event_duration_seconds", - Help: "Auth event duration distribution.", - Buckets: prometheus.DefBuckets, - }, []string{"action", "resource", "outcome"}), +## Don't label by tenant - gauges: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{ - Name: "authsome_gauge", - Help: "Live auth metrics gauges.", - }, []string{"name", "tenant"}), - } -} +The `tenant` argument is an app ID. A deployment can hold any number of apps, so a label built from it grows without bound, and a metrics backend pays for every series. Ignore it, as the adapter below does, unless you know your app count stays small. -func (c *PrometheusCollector) RecordEvent(action, resource, outcome, tenant string, duration time.Duration) { - c.eventCounter.WithLabelValues(action, resource, outcome, tenant).Inc() - c.latencyHist.WithLabelValues(action, resource, outcome).Observe(duration.Seconds()) -} +## The bundled adapter -func (c *PrometheusCollector) IncrementGauge(name, tenant string, delta int) { - c.gauges.WithLabelValues(name, tenant).Add(float64(delta)) -} +`bridge/metricsadapter` implements the interface over any go-utils metrics registry, Forge's included: -// Register with Authsome. +```go eng, err := authsome.NewEngine( - // plus WithChronicle and WithWarden, which every engine needs (see Getting Started) - authsome.WithStore(pgStore), - authsome.WithMetrics(NewPrometheusCollector(prometheus.DefaultRegisterer)), + // plus WithStore, WithChronicle and WithWarden, which every engine needs + authsome.WithMetrics(metricsadapter.New(registry)), ) ``` -## Setup with an OpenTelemetry adapter - -```go -import ( - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric" -) +It records `authsome_events_total{action,resource,outcome}`, a counter per `IncrementCounter` name with a `_total` suffix (`auth.lockout` becomes `authsome_auth_lockout_total`), and a gauge per `IncrementGauge` name. It ignores the tenant, gives every series in a family the same label keys, and folds a value that isn't shaped like a constant into `other`. -type OTelCollector struct { - meter metric.Meter - counter metric.Int64Counter - latency metric.Float64Histogram - gauges map[string]metric.Int64UpDownCounter - mu sync.Mutex -} +Don't use it alongside `WithMetricsSink` on the same registry: the sink records `authsome_events_total` too, and the two would count every event twice. -func NewOTelCollector() (*OTelCollector, error) { - meter := otel.GetMeterProvider().Meter("authsome") +## A Prometheus collector of your own - counter, err := meter.Int64Counter("authsome.events.total", - metric.WithDescription("Total auth events")) - if err != nil { - return nil, err - } +```go +type promCollector struct { + events *prometheus.CounterVec + counters *prometheus.CounterVec +} - latency, err := meter.Float64Histogram("authsome.event.duration", - metric.WithUnit("s"), - metric.WithDescription("Auth event duration")) - if err != nil { - return nil, err +func newPromCollector(reg prometheus.Registerer) *promCollector { + return &promCollector{ + events: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "authsome_events_total", + Help: "Hook-bus events by action, resource and outcome.", + }, []string{"action", "resource", "outcome"}), + counters: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Name: "authsome_signals_total", + Help: "authsome's operational signals by name.", + }, []string{"name"}), } - - return &OTelCollector{meter: meter, counter: counter, latency: latency, - gauges: make(map[string]metric.Int64UpDownCounter)}, nil } -func (c *OTelCollector) RecordEvent(action, resource, outcome, tenant string, duration time.Duration) { - attrs := metric.WithAttributes( - attribute.String("action", action), - attribute.String("resource", resource), - attribute.String("outcome", outcome), - attribute.String("tenant", tenant), - ) - c.counter.Add(context.Background(), 1, attrs) - c.latency.Record(context.Background(), duration.Seconds(), attrs) +func (c *promCollector) RecordEvent(action, resource, outcome, _ string, _ time.Duration) { + c.events.WithLabelValues(action, resource, outcome).Inc() } -func (c *OTelCollector) IncrementGauge(name, tenant string, delta int) { - // Acquire or create the gauge for this name. - // ... +func (c *promCollector) IncrementCounter(name, _ string) { + c.counters.WithLabelValues(name).Inc() } -``` - -## Metrics emitted by Authsome - -The following events and gauges are emitted when the Metrics bridge is configured: -### Events (RecordEvent) - -| Action | Resource | When | -|--------|----------|------| -| `auth.signup` | `user` | User successfully registered | -| `auth.signin` | `session` | Sign-in completed (success or failure) | -| `auth.signout` | `session` | User signed out | -| `auth.refresh` | `session` | Session token refreshed | -| `auth.mfa.enrolled` | `mfa` | MFA method enrolled | -| `auth.mfa.verified` | `mfa` | MFA challenge completed | -| `auth.passkey.authenticated` | `passkey` | Passkey login completed | -| `auth.social.signin` | `session` | Social OAuth sign-in completed | -| `auth.password.reset` | `account` | Password reset flow completed | -| `apikey.validated` | `apikey` | API key validated | -| `session.revoked` | `session` | Session explicitly revoked | - -### Gauges (IncrementGauge) - -| Name | When incremented | When decremented | -|------|-----------------|-----------------| -| `active_sessions` | Session created | Session expired or revoked | -| `registered_users` | User created | User deleted | -| `mfa_enrolled_users` | MFA enrolled | MFA disabled | +func (c *promCollector) IncrementGauge(string, string, int) {} +``` -## When no metrics bridge is configured +## Without a collector -Without a metrics bridge, Authsome performs all operations normally. No metrics overhead is incurred: the bridge check is a single nil pointer check. Configuring metrics is purely additive. +Nothing changes. The engine checks for a nil collector before each call, so leaving it out costs nothing. diff --git a/docs/content/docs/bridges/overview.mdx b/docs/content/docs/bridges/overview.mdx index 4377f540..9cf620eb 100644 --- a/docs/content/docs/bridges/overview.mdx +++ b/docs/content/docs/bridges/overview.mdx @@ -49,7 +49,7 @@ bridge/ | Keysmith | `WithKeysmith` / `WithKeyManager` | `bridge.KeyManager` | Centralized API key management | | Mailer | `WithMailer` | `bridge.Mailer` | Transactional email delivery | | SMS | `WithSMSSender` | `bridge.SMSSender` | SMS delivery for OTP and MFA codes | -| Metrics | `WithMetrics` | `bridge.MetricsCollector` | Observability metrics export | +| Metrics | `WithMetrics` | `bridge.MetricsCollector` | Hook-bus events and operational counters for your own collector. For authsome's metric families use `WithMetricsSink` | ## Registering bridges diff --git a/docs/content/docs/concepts/configuration.mdx b/docs/content/docs/concepts/configuration.mdx index 0b404cbf..5452e7e7 100644 --- a/docs/content/docs/concepts/configuration.mdx +++ b/docs/content/docs/concepts/configuration.mdx @@ -238,6 +238,7 @@ Options are `func(*Engine)` values passed to `authsome.NewEngine`. | `WithDispatcher(bridge.Dispatcher)` | Set the background job bridge. | | `WithLedger(bridge.Ledger)` | Set the billing bridge. | | `WithMetrics(bridge.MetricsCollector)` | Set the metrics bridge. | +| `WithMetricsSink(metrics.Metrics)` | Record authsome's metric families into a go-utils registry. The Forge extension passes the app's. | | `WithBasePath(string)` | Override the URL prefix. | | `WithDisableRoutes()` | Set `DisableRoutes` | | `WithDriverName(string)` | Set the Grove driver name for plugin migrations | diff --git a/docs/content/docs/concepts/meta.json b/docs/content/docs/concepts/meta.json index 1a18044d..94424dfd 100644 --- a/docs/content/docs/concepts/meta.json +++ b/docs/content/docs/concepts/meta.json @@ -1,4 +1,11 @@ { "title": "Core Concepts", - "pages": ["identity", "entities", "configuration", "errors", "multi-tenancy"] + "pages": [ + "identity", + "entities", + "configuration", + "errors", + "multi-tenancy", + "observability" + ] } diff --git a/docs/content/docs/concepts/observability.mdx b/docs/content/docs/concepts/observability.mdx new file mode 100644 index 00000000..d86b0af8 --- /dev/null +++ b/docs/content/docs/concepts/observability.mdx @@ -0,0 +1,128 @@ +--- +title: Observability +description: "The spans and metrics authsome records, what each one carries, and what never reaches them." +--- + +In a Forge app you don't have to wire anything. The authsome extension hands the engine the app's metrics registry, so authsome's metric families show up on `/_/metrics` next to everything else, and its spans go to the tracer provider forge installs. Turn on forge's tracing and metrics the way you normally would and you'll see them. + +Embedding the engine yourself works too. Pass any `github.com/xraph/go-utils/metrics` registry to `WithMetricsSink`, and install an OpenTelemetry tracer provider with `otel.SetTracerProvider`: + +```go +eng, err := authsome.NewEngine( + authsome.WithStore(store), + authsome.WithChronicle(chronicle), + authsome.WithWarden(warden), + authsome.WithMetricsSink(registry), +) +``` + +Leave the sink out and the families are off. The engine then pays a nil check per event and nothing else. + +## What forge already records + +forge traces every request with a server span and records the semconv `http_server_request_duration_seconds` histogram, labelled by route template and status. authsome doesn't measure HTTP latency itself. grove records a `db.client` span and `db_client_operation_duration_seconds` for every query, and the forgery libraries authsome uses (Chronicle, Relay, Warden, Keysmith, Herald, Dispatch) trace and measure their own work, so authsome doesn't wrap those calls again. + +One thing to know about forge's server span: it records `url.path` and `user_agent.original` as they arrive. A few authsome routes carry something sensitive in the path. The shared-signals push route (`/streams/:push_path/events`) carries the stream's push secret, and session and passkey routes carry session and credential IDs. Until forge redacts those, you can keep a route out of tracing with forge's `exclude_paths` tracing setting. + +## Spans + +Every span below uses the instrumentation scope `github.com/xraph/authsome`. A span that fails gets an error status whose description is a class (the error's Go type, an HTTP status, or a transport class like `timeout`), an `error.type` attribute with the same value, and an `exception` event carrying only `exception.type`. An error's message never goes on a span, because store, driver and provider messages quote the values they were given. + +| Span | Kind | Attributes | +|------|------|------------| +| `issue session` | internal | `authsome.auth.method`, `authsome.mfa.verified`, `authsome.mfa.required`, `authsome.result` (the sign-in results below), `authsome.session.format` (`opaque` or `jwt`) | +| `verify password` | internal | none | +| `hash password` | internal | `authsome.password.algorithm` (`argon2id` or `bcrypt`) | +| `refresh session` | internal | `authsome.refresh.via` (`refresh_token`, `oauth2_client`, `session_token`, `session_id`), `authsome.result` (the refresh results below) | +| `run plugin hook` | internal | `authsome.plugin.name`, `authsome.plugin.hook` (`before_sign_up`, `after_sign_up`, `before_sign_in`, `after_sign_in`, `before_principal_auth`, `after_principal_auth`, `before_session_create`, `after_session_create`), `authsome.result` (`ok`, `refused`, `error`) | +| `authenticate api key` | internal | `authsome.result` (the API key results below) | +| `verify client secret` | internal | none | +| `verify saml assertion` | internal | `authsome.result` (`ok`, `invalid`) | +| `send email`, `send sms` | internal | `authsome.message.channel` (`email`, `sms`) | +| `sweep retention` | internal, a root span when the background sweeper runs it | `authsome.retention.removed`; a `sweep failed` event per kind that failed, with `authsome.retention.kind` and `exception.type` | +| `deliver retention jobs` | internal, root | `authsome.retention.jobs` | +| `deliver retention job` | internal | `authsome.retention.job_kind` (`contact_upsert`, `activity_log`), `authsome.retention.provider` (`hubspot`, `generic`, `other`), `authsome.retention.outcome` (the job outcomes below) | +| `purge retention outbox` | internal, root | `authsome.retention.removed` | +| `refresh stream keys` | internal, root | `authsome.jwks.sets`, `authsome.jwks.failed` | +| `GET`, `POST` and so on | client | `http.request.method`, `authsome.provider.kind`, `server.address` and `server.port` for vendor hosts only, `http.response.status_code`, `error.type` | + +`verify password` carries no algorithm and no outcome on purpose. A sign-in for an identifier nobody holds runs a stand-in hash under the same span name, so a wrong password and an unknown identifier look identical in a trace. Sign-up works the same way the other round: an address that's already taken gets a stand-in hash under the same `hash password` span, and the same histogram observation, a fresh sign-up gets. + +The client spans are every third-party call authsome makes: social sign-in token exchanges and profile fetches, SSO OIDC discovery, token exchange and userinfo, the SAML metadata fetch, shared-signals key sets, Twilio, Resend, Turnstile, the HIBP breach check, CRM deliveries and the webhook URL test. The span is named after the method alone. It never records the URL, its path or its query, which matters for the breach check (the path carries a prefix of the password's SHA-1) and for Twilio (the path carries your account SID). The host is left off when the URL is your customer's: an SSO issuer, a SAML metadata URL, a shared-signals JWKS URI, a generic CRM endpoint, a HubSpot base URL you've overridden, a webhook. None of these requests carries a `traceparent`, `tracestate` or `baggage` header. A call made outside any request (a metadata fetch while a connection loads, say) is measured but starts no trace of its own. + +Background work runs on a context with no inherited span, so nothing it does hangs off forge's boot trace. A unit of work gets a root span only when there is work: a sweep that wins the lease, a CRM tick that claims jobs, a key refresh round with a set due. + +## Metrics + +Names below are as you'll scrape them from a Forge app, with forge's `forge_` namespace in front. Every label is a bounded enum. None is an ID, a tenant, an app, a user, a provider name, an SSO connection, a route or an error string, and every series in a family carries the same label keys. + +| Metric | Type | Labels | +|--------|------|--------| +| `forge_authsome_signin_attempts_total` | counter | `method`, `result` | +| `forge_authsome_mfa_verifications_total` | counter | `flow` (`signin`, `verify`), `method` (`totp`, `sms`, `recovery`), `result` (`ok`, `invalid`, `replayed`, `expired`, `exhausted`, `error`) | +| `forge_authsome_sessions_refreshed_total` | counter | `result` | +| `forge_authsome_password_hash_duration_seconds` | histogram, 5 ms to 2 s | `operation` (`hash`, `verify`), `algorithm` (`argon2id`, `bcrypt`) | +| `forge_authsome_apikey_authentications_total` | counter | `result` | +| `forge_authsome_oauth2_tokens_requested_total` | counter | `grant_type` (`authorization_code`, `refresh_token`, `client_credentials`, `device_code`, `token_exchange`, `other`), `result` (`ok`, `invalid_client`, `rejected`, `pending`, `error`) | +| `forge_authsome_provider_request_duration_seconds` | histogram, 10 ms to 30 s | `kind` (`social`, `oidc`, `saml_metadata`, `jwks`, `sms`, `email`, `captcha`, `breach`, `crm`, `webhook`), `result` (`ok`, `4xx`, `5xx`, `timeout`, `canceled`, `error`) | +| `forge_authsome_retention_sweeps_attempted_total` | counter | `result` (`ok`, `error`, `skipped`) | +| `forge_authsome_retention_rows_deleted_total` | counter | `kind` (`sessions`, `verifications`, `password_resets`, `revoked_refresh_tokens`, `ceremony_state`, `plugins`) | +| `forge_authsome_retention_jobs_processed_total` | counter | `provider` (`hubspot`, `generic`, `other`), `outcome` (`done`, `retry`, `dead`, `deferred`, `suppressed`) | +| `forge_authsome_controls_degraded_total` | counter | `control` (`ratelimit`, `identifier_limit`, `settings`, `password_history`, `breach_check`) | +| `forge_authsome_requests_throttled_total` | counter | `limiter` (`route`, `identifier`) | +| `forge_authsome_ratelimiter_errors_total` | counter | `limiter` (`route`, `identifier`) | +| `forge_authsome_audit_records_failed_total` | counter | none | +| `forge_authsome_events_total` | counter | `action`, `resource`, `outcome`, from the hook bus | + +A few of those need more than a table cell. + +`method` on sign-in attempts is one of `password`, `passkey`, `social`, `sso`, `magic_link`, `phone`, `email_verification` or `other`. A social sign-in through GitHub is `social`, an SSO sign-in through any connection is `sso`, and a custom plugin's own method is `other`. `result` is one of `ok`, `invalid`, `locked`, `banned`, `unverified`, `password_expired`, `mfa_required`, `rate_limited`, `denied` (a plugin hook or a DPoP mandate refused it) or `error`. Each attempt is counted once. `invalid` covers a wrong password and an identifier nobody holds alike. When the MFA gate fires the attempt counts as `mfa_required`, and the second factor is counted in `forge_authsome_mfa_verifications_total`, not as another sign-in. The sessions sign-in produces are roughly `result="ok"` here plus `flow="signin",result="ok"` there. + +Refresh results are `ok`, `invalid` (an unknown token, a binding mismatch, or a concurrent refresh that lost the race), `expired`, `banned`, `replay_detected` (the first time a rotated token comes back), `dpop_rejected` and `error`. + +API key results are `ok`, `invalid` (malformed, unknown prefix, or wrong secret, so the counter can't be used to learn which prefixes exist), `expired` (revoked or expired), `throttled` (the address spent its failure budget), `denied` (a banned user, an inactive service account, or a principal-auth hook) and `error`. + +On the provider histogram, `email` and `sms` are measured around the configured mailer or SMS bridge, so their result is `ok`, `timeout`, `canceled` or `error` and never a status class. Every other kind is measured at the HTTP client, up to the response headers. + +`breach_check` counts a HIBP lookup that couldn't run (a failed request, a non-200 answer) during sign-up, a password reset or a password change. The check fails open, so the password goes through as not breached; this counter is how you see that it was skipped rather than passed. + +`forge_authsome_events_total` counts every hook-bus event. The values are the bus's own constants (`auth.signin`, `session`, `success`); anything that isn't shaped like one folds into `other`. + +## What never reaches telemetry + +None of this ever goes on a span, an event, a status or a label: passwords or their hashes, OTP, recovery and magic-link codes, reset and verification tokens, session, refresh and access tokens, session IDs, MFA tickets, OAuth codes, state, nonces and PKCE verifiers, client secrets, API keys and their prefixes, DPoP thumbprints, push paths, webhook, JWKS and metadata URLs, a Twilio account SID, emails, phone numbers, usernames, identifier hashes, lockout keys, IP addresses, user agents, user, organization and app IDs, SSO connection names, provider response bodies and error messages. A test drives sign-in, MFA, refresh, API key and OAuth2 token flows with recognisable values and fails if any of them shows up on a span or in the scrape. + +## Migrating from the old metrics + +Earlier releases recorded authsome's metrics through `bridge/metricsadapter`. In a Forge app these names change: + +| Before | Now | +|--------|-----| +| `forge_authsome_events_total{action,resource,outcome,tenant}` | `forge_authsome_events_total{action,resource,outcome}` | +| `forge_authsome_events_total{action="http.request"}` and `forge_authsome_event_duration_seconds` | forge's `http_server_request_duration_seconds` | +| `forge_authsome_audit_record_failed` | `forge_authsome_audit_records_failed_total` | +| `forge_authsome_auth_lockout` | `forge_authsome_signin_attempts_total{result="locked"}` | +| `forge_authsome_refresh_replay_detected` | `forge_authsome_sessions_refreshed_total{result="replay_detected"}` | +| `forge_authsome_control_degraded` | `forge_authsome_controls_degraded_total` | +| `forge_authsome_identifier_limit_rejected` | `forge_authsome_requests_throttled_total{limiter="identifier"}` | +| `forge_authsome_ratelimit_rejected` | `forge_authsome_requests_throttled_total{limiter="route"}` | +| `forge_authsome_ratelimit_error` | `forge_authsome_ratelimiter_errors_total` | + +No family is labelled by tenant any more. A collector you pass to `WithMetrics` still gets every call it got before, except the per-request `http.request` events the removed `middleware.Metrics` used to send; see the [Metrics bridge](/docs/bridges/metrics). + +## A few queries + +```text +# Share of password sign-ins refused as invalid, over five minutes +sum(rate(forge_authsome_signin_attempts_total{method="password",result="invalid"}[5m])) + / sum(rate(forge_authsome_signin_attempts_total{method="password"}[5m])) + +# Refresh-token replays, which mean a leaked or double-spent token +increase(forge_authsome_sessions_refreshed_total{result="replay_detected"}[1h]) + +# p95 latency of each kind of third-party call +histogram_quantile(0.95, sum by (kind, le) (rate(forge_authsome_provider_request_duration_seconds_bucket[5m]))) + +# Any control that let a request through because it couldn't run +sum by (control) (increase(forge_authsome_controls_degraded_total[15m])) > 0 +``` diff --git a/docs/content/docs/security/audit-trail.mdx b/docs/content/docs/security/audit-trail.mdx index dca87daa..080807a8 100644 --- a/docs/content/docs/security/audit-trail.mdx +++ b/docs/content/docs/security/audit-trail.mdx @@ -102,4 +102,4 @@ Chronicle owns the query surface. Use its admin API or the `audit.Query` type; ` ## Metrics -The engine's `MetricsCollector` receives `audit.record.failed` (a counter, labelled by tenant) whenever Chronicle refuses a record. Alert on it: a non-zero rate means the trail has gaps. +Whenever Chronicle refuses a record, the engine adds one to `forge_authsome_audit_records_failed_total` (a counter with no labels, recorded through `WithMetricsSink`, which the Forge extension sets up for you) and hands `audit.record.failed` to any `MetricsCollector` you registered. Alert on it: a non-zero rate means the trail has gaps. [Observability](/docs/concepts/observability) lists the rest of authsome's metrics. diff --git a/docs/content/docs/security/rate-limiting.mdx b/docs/content/docs/security/rate-limiting.mdx index 0ae05b01..10d72240 100644 --- a/docs/content/docs/security/rate-limiting.mdx +++ b/docs/content/docs/security/rate-limiting.mdx @@ -11,7 +11,7 @@ Route limits key on the client address, resolved through the trusted-proxy rules Identifier limits key on what is being tried: the email, username or phone number, hashed before it becomes a key so the shared store never holds an address. They apply to sign-in, password reset, verification and the other places an identifier is presented, and a spent budget answers `429` too. Lockout, described on its own page, is the slower control that follows repeated failures from one network. -A limiter that returns an error is a limiter that cannot count. By default the request is refused with `503` and `Retry-After: 5`, because an outage of the shared store must not turn into an unlimited window. `FailOpen` reverses that for deployments that prefer availability, and every fail-open is counted on the `control.degraded` metric. +A limiter that returns an error is a limiter that cannot count. By default the request is refused with `503` and `Retry-After: 5`, because an outage of the shared store must not turn into an unlimited window. `FailOpen` reverses that for deployments that prefer availability, and every fail-open is counted in `forge_authsome_controls_degraded_total`. ## Configuration @@ -98,4 +98,4 @@ g.POST("/v1/things", handler, forge.WithMiddleware(engine.RateLimitMiddleware(30 ## Metrics and the trail -`ratelimit.rejected` counts route refusals, `identifier_limit.rejected` counts identifier refusals, `ratelimit.error` counts limiter errors and `control.degraded` counts the requests a fail-open let through. A refused sign-in is also on the audit trail with reason `rate_limited`. +`forge_authsome_requests_throttled_total` counts refusals by `limiter` (`route` or `identifier`), `forge_authsome_ratelimiter_errors_total` counts limiter errors the same way, and `forge_authsome_controls_degraded_total` counts the requests a fail-open let through, with `control="ratelimit"` or `control="identifier_limit"`. A `MetricsCollector` you registered still receives `ratelimit.rejected`, `identifier_limit.rejected`, `ratelimit.error` and `control.degraded`. A sign-in the identifier limiter refused also counts as `result="rate_limited"` in `forge_authsome_signin_attempts_total`, and is on the audit trail with reason `rate_limited`. diff --git a/engine.go b/engine.go index b3e3d4d3..abae28e1 100644 --- a/engine.go +++ b/engine.go @@ -40,6 +40,7 @@ import ( "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/lockout" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/organization" @@ -116,6 +117,10 @@ type Engine struct { ledger bridge.Ledger metrics bridge.MetricsCollector + // tel holds authsome's own metric families, recorded into the sink + // WithMetricsSink was given. Nil records nothing. + tel *telemetry.Metrics + // tokenEncryptor encrypts at-rest secrets such as third-party OAuth // access/refresh tokens. Defaults to bridge.NoopEncryptor when no // AUTHSOME_TOKEN_ENCRYPTION_KEY is configured (dev mode). @@ -434,6 +439,7 @@ func (e *Engine) buildAuthMiddleware() { bindCfg := e.DPoPBindingConfig() bindCfg.CookieNameResolver = e.resolveSessionCookieName bindCfg.JWTSessionChecker = e.JWTSessionChecker + bindCfg.JWTSessionContextChecker = e.JWTSessionContextChecker bindCfg.ExpectedAudienceResolver = e.resolveExpectedAudience bindCfg.PrincipalResolver = e.ResolvePrincipalByRef @@ -606,7 +612,7 @@ func (e *Engine) resolveExpectedAudience(ctx context.Context, appID string) []st opts := settings.ResolveOpts{AppID: appID} identifier, err := settings.Get(ctx, mgr, SettingResourceIdentifier, opts) if err != nil { - e.count("control.degraded", appID) + e.controlDegraded(telemetry.ControlSettings, appID) return nil } if identifier == "" { @@ -622,7 +628,16 @@ func (e *Engine) resolveExpectedAudience(ctx context.Context, appID string) []st // active; when it is off, nil, nil is returned to skip the store lookup and // the binding checks. The setting defaults to on. func (e *Engine) JWTSessionChecker(appIDStr, sessionIDStr string) (*session.Session, error) { - ctx := context.Background() + return e.JWTSessionContextChecker(context.Background(), appIDStr, sessionIDStr) +} + +// JWTSessionContextChecker is JWTSessionChecker for a caller that has the +// request's context. The lookup runs on context.WithoutCancel(ctx): it +// keeps the request's values, so its queries show up in the request's +// trace, and drops its cancellation and deadline, so it behaves exactly as +// it did on context.Background(). +func (e *Engine) JWTSessionContextChecker(ctx context.Context, appIDStr, sessionIDStr string) (*session.Session, error) { + ctx = context.WithoutCancel(ctx) // Check if the feature is enabled via dynamic settings. mgr := e.Settings() @@ -692,7 +707,10 @@ func (e *Engine) Start(ctx context.Context) error { // waits on the sweep, and a large table must not hold up boot. The // start context's values travel with the sweep but its cancellation // does not, since Start returning is not a reason to stop converting. - sweepCtx := context.WithoutCancel(ctx) + // Its span does not travel either: forge runs Start inside a boot span, + // and every query the sweep makes would otherwise hang off a trace that + // ended at startup. + sweepCtx := telemetry.Detach(context.WithoutCancel(ctx)) e.spawn(func() { e.hashLegacyTokens(sweepCtx) }) // Webhook rows from before webhooks became Relay endpoints are given // one in the background, for the same reasons. @@ -787,6 +805,7 @@ func (e *Engine) Start(ctx context.Context) error { if e.metrics != nil { e.metrics.IncrementCounter("audit.record.failed", event.Tenant) } + e.tel.AuditRecordFailed() return err } return nil @@ -799,11 +818,20 @@ func (e *Engine) Start(ctx context.Context) error { return nil }) } + // Count every bus event in the sink too, by action, resource and + // outcome only: never by tenant, which is an app ID and unbounded. + if e.tel != nil { + e.hooks.On("telemetry", func(_ context.Context, event *hook.Event) error { + e.tel.Event(event.Action, event.Resource, event.Outcome) + return nil + }) + } // Expired rows are swept on a timer for as long as the engine runs; // see engine_retention.go. As with the token sweep above, the start - // context's cancellation is not a reason to stop. - e.startRetentionSweeper(context.WithoutCancel(ctx)) + // context's cancellation is not a reason to stop, and its boot span is + // dropped so each sweep can be its own trace. + e.startRetentionSweeper(telemetry.Detach(context.WithoutCancel(ctx))) e.started.Store(true) return nil @@ -1048,11 +1076,26 @@ func (e *Engine) KeyManager() bridge.KeyManager { return e.keyManager } // Relay returns the event relay bridge (may be nil). func (e *Engine) Relay() bridge.EventRelay { return e.relay } -// Mailer returns the email bridge (may be nil). -func (e *Engine) Mailer() bridge.Mailer { return e.mailer } +// Mailer returns the email bridge (may be nil). Each send through it is a +// send email span and an observation in the provider latency histogram, +// whichever Mailer was configured; the no-op mailer is handed back as is. +func (e *Engine) Mailer() bridge.Mailer { + switch e.mailer.(type) { + case nil, *bridge.NoopMailer: + return e.mailer + } + return tracedMailer{next: e.mailer, tel: e.tel} +} -// SMSSender returns the SMS bridge (may be nil). -func (e *Engine) SMSSender() bridge.SMSSender { return e.sms } +// SMSSender returns the SMS bridge (may be nil), traced and measured the +// same way as Mailer. +func (e *Engine) SMSSender() bridge.SMSSender { + switch e.sms.(type) { + case nil, *bridge.NoopSMSSender: + return e.sms + } + return tracedSMSSender{next: e.sms, tel: e.tel} +} // Herald returns the Herald notification bridge (may be nil). func (e *Engine) Herald() bridge.Herald { return e.heraldBridge } diff --git a/engine_identifier_limit.go b/engine_identifier_limit.go index ce942aaa..e567f72f 100644 --- a/engine_identifier_limit.go +++ b/engine_identifier_limit.go @@ -7,6 +7,7 @@ import ( "github.com/xraph/authsome/account" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" ) // AllowIdentifier counts an attempt against an identifier (an email, a @@ -30,17 +31,15 @@ func (e *Engine) AllowIdentifier(ctx context.Context, scope string, appID id.App log.String("scope", scope), log.String("error", err.Error()), ) - if e.metrics != nil { - e.metrics.IncrementCounter("ratelimit.error", appID.String()) - } + e.limiterError(telemetry.LimiterIdentifier, appID.String()) if e.config.RateLimit.FailOpen { - e.count("control.degraded", appID.String()) + e.controlDegraded(telemetry.ControlIdentifierLimit, appID.String()) return nil } return account.ErrRateLimited } if !allowed { - e.count("identifier_limit.rejected", appID.String()) + e.throttled(telemetry.LimiterIdentifier, appID.String()) return account.ErrRateLimited } return nil diff --git a/engine_issue_session.go b/engine_issue_session.go index 4471b967..17324cc0 100644 --- a/engine_issue_session.go +++ b/engine_issue_session.go @@ -11,12 +11,14 @@ import ( "time" log "github.com/xraph/go-utils/log" + "go.opentelemetry.io/otel/trace" "github.com/xraph/authsome/account" "github.com/xraph/authsome/ceremony" "github.com/xraph/authsome/dpop" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/session" "github.com/xraph/authsome/user" ) @@ -164,7 +166,43 @@ type MFATicketPayload struct { // Returns (*IssueSessionResult, nil) on success. // Returns (nil, *MFARequiredError) when the gate fires. // Returns (nil, err) for any other failure. +// +// Because every sign-in reaches it, this is also where a sign-in attempt is +// counted, under the normalized auth method. A call that completes an MFA +// challenge is not a new attempt (the MFA plugin counts the code check), so +// it gets the span but not the count. func (e *Engine) IssueSession(ctx context.Context, req *IssueSessionRequest) (*IssueSessionResult, error) { + var method string + var mfaVerified bool + if req != nil { + method, mfaVerified = telemetry.AuthMethod(req.AuthMethod), req.MFAJustVerified + } + ctx, span := telemetry.Start(ctx, spanIssueSession, trace.WithAttributes( + telemetry.AttrAuthMethod.String(method), + telemetry.AttrMFAVerified.Bool(mfaVerified), + )) + defer span.End() + + res, err := e.issueSession(ctx, req) + + result := signInResult(err) + span.SetAttributes( + telemetry.AttrResult.String(result), + telemetry.AttrMFARequired.Bool(result == telemetry.SignInMFARequired), + ) + if result == telemetry.SignInError { + telemetry.FailErr(span, err) + } + if res != nil && res.Session != nil { + span.SetAttributes(telemetry.AttrSessionFormat.String(sessionFormat(res.Session.Token))) + } + if !mfaVerified { + e.tel.SignIn(method, result) + } + return res, err +} + +func (e *Engine) issueSession(ctx context.Context, req *IssueSessionRequest) (*IssueSessionResult, error) { if req == nil || req.User == nil { return nil, fmt.Errorf("authsome: IssueSession: nil request or user") } @@ -237,7 +275,7 @@ func (e *Engine) IssueSession(ctx context.Context, req *IssueSessionRequest) (*I } e.bindSessionToDevice(ctx, sess, req.AppID, req.EnvID, req.IPAddress, req.UserAgent) if hookErr := e.plugins.EmitBeforeSessionCreate(ctx, sess); hookErr != nil { - return nil, fmt.Errorf("authsome: before session create: %w", hookErr) + return nil, &hookRefusedError{fmt.Errorf("authsome: before session create: %w", hookErr)} } e.enforceSessionCap(ctx, req.User.ID, sessCfg.MaxActiveSessions) if storeErr := e.store.CreateSession(ctx, sess); storeErr != nil { diff --git a/engine_retention.go b/engine_retention.go index a7530ebb..d2428628 100644 --- a/engine_retention.go +++ b/engine_retention.go @@ -7,11 +7,16 @@ import ( "time" log "github.com/xraph/go-utils/log" + "go.opentelemetry.io/otel/trace" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/store" ) +// spanSweepRetention is the span a sweep that holds the lease runs in. +const spanSweepRetention = "sweep retention" + // retentionLeaseKey is the authsome_kv key one replica takes before a sweep // so that a fleet of replicas runs one sweep per interval, not one each. const retentionLeaseKey = "authsome:retention:lease" @@ -46,6 +51,10 @@ func (e *Engine) retentionTTLs() map[string]time.Duration { // first run waits a full interval so boot is not slowed by a sweep, and the // database's own expiry indexes, where it has them, are brought in line // with the configured windows first. +// +// ctx must carry no span (Start passes it through telemetry.Detach): each +// sweep that wins the lease is its own trace, and the lease check that +// loses is no trace at all. func (e *Engine) startRetentionSweeper(ctx context.Context) { interval := e.retentionInterval() if interval <= 0 { @@ -97,6 +106,10 @@ func (e *Engine) stopRetentionSweeper() { // replica holds it. Each kind is removed in batches of Retention.BatchSize // until a batch comes back short. A failure in one kind does not stop the // others; the errors are joined and returned together. +// +// A sweep that holds the lease runs in a sweep retention span: a root span +// when the background sweeper calls this, a child when you call it under a +// span of your own. Losing the lease starts no span. func (e *Engine) SweepRetention(ctx context.Context) (int64, error) { lease := e.retentionInterval() / 2 if lease <= 0 { @@ -104,13 +117,26 @@ func (e *Engine) SweepRetention(ctx context.Context) (int64, error) { } held, err := e.store.KVSetNX(ctx, retentionLeaseKey, []byte(time.Now().UTC().Format(time.RFC3339)), lease) if err != nil { + e.tel.RetentionSweep(telemetry.SweepError) return 0, fmt.Errorf("authsome: retention lease: %w", err) } if !held { + e.tel.RetentionSweep(telemetry.SweepSkipped) e.logger.Debug("authsome: retention sweep skipped; another replica holds the lease") return 0, nil } + ctx, span := telemetry.Start(ctx, spanSweepRetention) + defer span.End() + // failed notes one kind that did not finish, by kind and error type + // only: a store's message can quote the rows it was working on. + failed := func(kind string, err error) { + span.AddEvent("sweep failed", trace.WithAttributes( + telemetry.AttrRetentionKind.String(kind), + telemetry.AttrExceptionType.String(telemetry.TypeOf(err)), + )) + } + batch := e.config.Retention.BatchSize if batch <= 0 { batch = 1000 @@ -121,13 +147,14 @@ func (e *Engine) SweepRetention(ctx context.Context) (int64, error) { var total int64 var errs []error sweeps := []struct { - kind string - del func(ctx context.Context, before time.Time, batch int) (int64, error) + kind string + label string + del func(ctx context.Context, before time.Time, batch int) (int64, error) }{ - {store.RetentionSessions, e.store.DeleteExpiredSessions}, - {store.RetentionVerifications, e.store.DeleteExpiredVerifications}, - {store.RetentionPasswordResets, e.store.DeleteExpiredPasswordResets}, - {store.RetentionRevokedRefreshTokens, e.store.DeleteExpiredRevokedRefreshTokens}, + {store.RetentionSessions, telemetry.RetentionSessions, e.store.DeleteExpiredSessions}, + {store.RetentionVerifications, telemetry.RetentionVerifications, e.store.DeleteExpiredVerifications}, + {store.RetentionPasswordResets, telemetry.RetentionPasswordResets, e.store.DeleteExpiredPasswordResets}, + {store.RetentionRevokedRefreshTokens, telemetry.RetentionRevokedRefreshTokens, e.store.DeleteExpiredRevokedRefreshTokens}, } for _, sw := range sweeps { before := cutoff(sw.kind) @@ -138,17 +165,23 @@ func (e *Engine) SweepRetention(ctx context.Context) (int64, error) { return sw.del(ctx, before, batch) }) total += n + e.tel.RetentionRowsDeleted(sw.label, n) if sweepErr != nil { + failed(sw.label, sweepErr) errs = append(errs, fmt.Errorf("authsome: sweep %s: %w", sw.kind, sweepErr)) } } n, err := e.store.KVDeleteExpired(ctx, now) total += n + e.tel.RetentionRowsDeleted(telemetry.RetentionCeremonyState, n) if err != nil { + failed(telemetry.RetentionCeremonyState, err) errs = append(errs, fmt.Errorf("authsome: sweep ceremony state: %w", err)) } + // Plugin sweepers share one label: a custom plugin's name is not a + // bounded value. for _, p := range e.plugins.Plugins() { sw, ok := p.(plugin.RetentionSweeper) if !ok { @@ -156,11 +189,21 @@ func (e *Engine) SweepRetention(ctx context.Context) (int64, error) { } n, sweepErr := sw.SweepRetention(ctx, cutoff, batch) total += n + e.tel.RetentionRowsDeleted(telemetry.RetentionPlugins, n) if sweepErr != nil { + failed(telemetry.RetentionPlugins, sweepErr) errs = append(errs, fmt.Errorf("authsome: sweep plugin %s: %w", p.Name(), sweepErr)) } } + span.SetAttributes(telemetry.AttrRetentionRemoved.Int64(total)) + result := telemetry.SweepOK + if len(errs) > 0 { + result = telemetry.SweepError + telemetry.Fail(span, "sweep_incomplete", nil) + } + e.tel.RetentionSweep(result) + if total > 0 { e.logger.Info("authsome: retention sweep removed rows", log.Int64("removed", total)) } diff --git a/engine_telemetry.go b/engine_telemetry.go new file mode 100644 index 00000000..37b2e95d --- /dev/null +++ b/engine_telemetry.go @@ -0,0 +1,36 @@ +package authsome + +import "github.com/xraph/authsome/internal/telemetry" + +// Telemetry returns the engine's metric instruments, for authsome's own +// plugins to record into. It is nil when the engine was built without +// WithMetricsSink, and every method on it is safe to call on nil. +func (e *Engine) Telemetry() *telemetry.Metrics { return e.tel } + +// ────────────────────────────────────────────────── +// Operational counters +// ────────────────────────────────────────────────── + +// controlDegraded counts a security control that could not run and let the +// request through: once on the bridge collector, under its historical name, +// and once in the controls_degraded family. +func (e *Engine) controlDegraded(control, tenant string) { + e.count("control.degraded", tenant) + e.tel.ControlDegraded(control) +} + +// throttled counts a request a rate limiter refused. +func (e *Engine) throttled(limiter, tenant string) { + name := "ratelimit.rejected" + if limiter == telemetry.LimiterIdentifier { + name = "identifier_limit.rejected" + } + e.count(name, tenant) + e.tel.Throttled(limiter) +} + +// limiterError counts a rate limiter that could not answer. +func (e *Engine) limiterError(limiter, tenant string) { + e.count("ratelimit.error", tenant) + e.tel.RateLimiterError(limiter) +} diff --git a/engine_telemetry_auth.go b/engine_telemetry_auth.go new file mode 100644 index 00000000..7bd15f31 --- /dev/null +++ b/engine_telemetry_auth.go @@ -0,0 +1,201 @@ +package authsome + +import ( + "context" + "errors" + "strings" + "time" + + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/internal/telemetry" +) + +// Span names for sign-in, sessions and passwords. Every one of them is +// SpanKindInternal. +const ( + spanIssueSession = "issue session" + spanRefreshSession = "refresh session" + spanHashPassword = "hash password" + spanVerifyPassword = "verify password" +) + +// ────────────────────────────────────────────────── +// Sign-in results +// ────────────────────────────────────────────────── + +// hookRefusedError marks an error a plugin's before hook returned to stop +// an operation, so the sign-in counter can say "denied" rather than +// "error". It changes nothing a caller sees: the message is the wrapped +// error's, and errors.Is and errors.As reach through it. +type hookRefusedError struct{ err error } + +func (r *hookRefusedError) Error() string { return r.err.Error() } +func (r *hookRefusedError) Unwrap() error { return r.err } + +// signInResult sorts the error a sign-in path returned into the bounded +// result label. A wrong password and an unknown identifier both arrive as +// account.ErrInvalidCredentials, so both are "invalid". +func signInResult(err error) string { + var ( + mfa *MFARequiredError + dpopReq *DPoPRequiredError + locked *account.LockedError + refused *hookRefusedError + ) + switch { + case err == nil: + return telemetry.SignInOK + case errors.As(err, &mfa): + return telemetry.SignInMFARequired + case errors.As(err, &locked), errors.Is(err, account.ErrAccountLocked): + return telemetry.SignInLocked + case errors.Is(err, account.ErrUserBanned): + return telemetry.SignInBanned + case errors.Is(err, account.ErrInvalidCredentials): + return telemetry.SignInInvalid + case errors.Is(err, account.ErrEmailNotVerified): + return telemetry.SignInUnverified + case errors.Is(err, account.ErrPasswordExpired): + return telemetry.SignInPasswordExpired + case errors.Is(err, account.ErrRateLimited): + return telemetry.SignInRateLimited + case errors.As(err, &refused), errors.As(err, &dpopReq): + return telemetry.SignInDenied + default: + return telemetry.SignInError + } +} + +// sessionFormat names the shape of a minted access token without reading +// anything in it: a JWT is three dot-separated parts, an opaque token none. +func sessionFormat(token string) string { + if strings.Count(token, ".") == 2 { + return "jwt" + } + return "opaque" +} + +// ────────────────────────────────────────────────── +// Refresh results +// ────────────────────────────────────────────────── + +// refreshRefusalError tags a refusal whose sentinel alone (always +// account.ErrInvalidCredentials, so the caller cannot learn why) would hide +// the reason from the refresh counter. finishRefresh unwraps it, so callers +// receive exactly the sentinel they always did. +type refreshRefusalError struct { + result string + err error +} + +func (r *refreshRefusalError) Error() string { return r.err.Error() } +func (r *refreshRefusalError) Unwrap() error { return r.err } + +// startRefresh starts the refresh session span for one entry point. The +// returned func ends it: it records the result on the span and the counter +// and hands back the error the caller should see. +func (e *Engine) startRefresh(ctx context.Context, via string) (spanCtx context.Context, finish func(error) error) { + ctx, span := telemetry.Start(ctx, spanRefreshSession, + trace.WithAttributes(telemetry.AttrRefreshVia.String(via))) + return ctx, func(err error) error { + result := telemetry.RefreshOK + var rr *refreshRefusalError + switch { + case err == nil: + case errors.As(err, &rr): + result, err = rr.result, rr.err + case errors.Is(err, account.ErrSessionExpired): + result = telemetry.RefreshExpired + case errors.Is(err, account.ErrUserBanned): + result = telemetry.RefreshBanned + case errors.Is(err, account.ErrInvalidCredentials): + result = telemetry.RefreshInvalid + default: + result = telemetry.RefreshError + telemetry.FailErr(span, err) + } + span.SetAttributes(telemetry.AttrResult.String(result)) + span.End() + e.tel.Refresh(result) + return err + } +} + +// ────────────────────────────────────────────────── +// Password hashing +// ────────────────────────────────────────────────── + +// passwordAlgorithm names the algorithm a policy hashes with. +func passwordAlgorithm(policy account.PasswordPolicy) string { + if policy.Algorithm == "argon2id" { + return telemetry.AlgorithmArgon2id + } + return telemetry.AlgorithmBcrypt +} + +// hashAlgorithm names the algorithm a stored hash was made with. +func hashAlgorithm(hash string) string { + if account.IsArgon2Hash(hash) { + return telemetry.AlgorithmArgon2id + } + return telemetry.AlgorithmBcrypt +} + +// hashPassword hashes password under policy inside a hash password span. +func (e *Engine) hashPassword(ctx context.Context, password string, policy account.PasswordPolicy) (string, error) { + algorithm := passwordAlgorithm(policy) + _, span := telemetry.Start(ctx, spanHashPassword, + trace.WithAttributes(telemetry.AttrPasswordAlgorithm.String(algorithm))) + defer span.End() + start := time.Now() + hash, err := account.HashPasswordWithPolicy(password, policy) + e.tel.PasswordHash(telemetry.PasswordHash, algorithm, time.Since(start)) + if err != nil { + telemetry.FailErr(span, err) + } + return hash, err +} + +// verifyPassword checks password against hash inside a verify password +// span. A mismatch is an answer, not a failure, so nothing on the span says +// how the check came out; the span also carries no algorithm, because an +// unknown identifier's stand-in check (consumeDummyVerify) has no stored +// hash to name one from and must look exactly like this. +func (e *Engine) verifyPassword(ctx context.Context, hash, password string) error { + _, span := telemetry.Start(ctx, spanVerifyPassword) + defer span.End() + start := time.Now() + err := account.CheckPassword(hash, password) + e.tel.PasswordHash(telemetry.PasswordVerify, hashAlgorithm(hash), time.Since(start)) + return err +} + +// consumeDummyVerify is ConsumeDummyHash for sign-in's unknown-identifier +// path. It spends the same budget under the same span a real check gets, +// so neither the response time nor the trace says whether the identifier +// exists. +func (e *Engine) consumeDummyVerify(ctx context.Context, password string) { + _, span := telemetry.Start(ctx, spanVerifyPassword) + defer span.End() + start := time.Now() + policy := e.ConsumeDummyHash(password) + e.tel.PasswordHash(telemetry.PasswordVerify, passwordAlgorithm(policy), time.Since(start)) +} + +// ConsumeDummyHashContext is ConsumeDummyHash for sign-up's duplicate-address +// path. It hashes under the same hash password span, with the same +// algorithm attribute, and records into the same histogram as a real +// sign-up's hash, so the duplicate looks like a fresh sign-up in a trace and +// on a metric as well as in the response time. +func (e *Engine) ConsumeDummyHashContext(ctx context.Context, password string) account.PasswordPolicy { + algorithm := passwordAlgorithm(e.passwordPolicy()) + _, span := telemetry.Start(ctx, spanHashPassword, + trace.WithAttributes(telemetry.AttrPasswordAlgorithm.String(algorithm))) + defer span.End() + start := time.Now() + policy := e.ConsumeDummyHash(password) + e.tel.PasswordHash(telemetry.PasswordHash, algorithm, time.Since(start)) + return policy +} diff --git a/engine_telemetry_providers.go b/engine_telemetry_providers.go new file mode 100644 index 00000000..0fae15f3 --- /dev/null +++ b/engine_telemetry_providers.go @@ -0,0 +1,103 @@ +package authsome + +import ( + "context" + "net/http" + "time" + + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/telemetry" +) + +// Span names for outgoing messages. Both are SpanKindInternal: the +// provider's own HTTP call, where there is one, is the client span under +// them. +const ( + spanSendEmail = "send email" + spanSendSMS = "send sms" +) + +// isBreached asks HIBP whether password is in a known breach, through a +// client that measures the call and traces it as a child of the span in +// ctx. The lookup runs on context.WithoutCancel(ctx): the request's values +// travel with it (its span, so the call shows in the request's trace) but +// not its cancellation or deadline, which is exactly how it behaved on +// context.Background(). The client's own timeout is still the only limit. +// The request path carries the password hash prefix, and the client span +// never records a path. +func (e *Engine) isBreached(ctx context.Context, password string) (bool, error) { + client := telemetry.InstrumentClient(&http.Client{Timeout: account.BreachCheckTimeout}, + telemetry.Endpoint{Kind: telemetry.ProviderBreach}, e.tel) + return account.NewBreachCheckerWithClient(client).IsBreachedContext(context.WithoutCancel(ctx), password) +} + +// ────────────────────────────────────────────────── +// Messaging +// ────────────────────────────────────────────────── + +// passwordBreached is isBreached for a path that fails open: a lookup that +// could not run reports false and is counted as a degraded breach_check +// control, so you can see the check was skipped rather than passed. +func (e *Engine) passwordBreached(ctx context.Context, tenant, password string) bool { + breached, err := e.isBreached(ctx, password) + if err != nil { + e.controlDegraded(telemetry.ControlBreachCheck, tenant) + } + return breached +} + +// tracedMailer measures and traces every email the engine's plugins send, +// whichever Mailer is configured. +type tracedMailer struct { + next bridge.Mailer + tel *telemetry.Metrics +} + +func (m tracedMailer) SendEmail(ctx context.Context, msg *bridge.EmailMessage) error { + ctx, span := telemetry.Start(ctx, spanSendEmail, + trace.WithAttributes(telemetry.AttrMessageChannel.String(telemetry.ProviderEmail))) + defer span.End() + start := time.Now() + err := m.next.SendEmail(ctx, msg) + m.tel.ProviderRequest(telemetry.ProviderEmail, messageResult(ctx, span, err), time.Since(start)) + return err +} + +// tracedSMSSender is tracedMailer for SMS. +type tracedSMSSender struct { + next bridge.SMSSender + tel *telemetry.Metrics +} + +func (s tracedSMSSender) SendSMS(ctx context.Context, msg *bridge.SMSMessage) error { + ctx, span := telemetry.Start(ctx, spanSendSMS, + trace.WithAttributes(telemetry.AttrMessageChannel.String(telemetry.ProviderSMS))) + defer span.End() + start := time.Now() + err := s.next.SendSMS(ctx, msg) + s.tel.ProviderRequest(telemetry.ProviderSMS, messageResult(ctx, span, err), time.Since(start)) + return err +} + +// messageResult marks span failed when err is set and returns the provider +// result label for it. A provider's error text quotes addresses and codes, +// so only its type reaches the span. +func messageResult(ctx context.Context, span trace.Span, err error) string { + if err == nil { + return telemetry.ProviderOK + } + switch telemetry.CallClass(ctx, err) { + case telemetry.ClassTimeout: + telemetry.Fail(span, telemetry.ClassTimeout, err) + return telemetry.ProviderTimeout + case telemetry.ClassCanceled: + telemetry.Fail(span, telemetry.ClassCanceled, err) + return telemetry.ProviderCanceled + default: + telemetry.FailErr(span, err) + return telemetry.ProviderError + } +} diff --git a/engine_webhooks.go b/engine_webhooks.go index 93e1e1ec..4c80d387 100644 --- a/engine_webhooks.go +++ b/engine_webhooks.go @@ -19,6 +19,7 @@ import ( "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" "github.com/xraph/authsome/internal/safeurl" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/store" "github.com/xraph/authsome/webhook" ) @@ -114,7 +115,11 @@ func (e *Engine) verifyWebhookURL(ctx context.Context, w *webhook.Webhook, secre req.Header.Set("X-Relay-Event-Type", "webhook.test") req.Header.Set(webhook.RelayTimestampHeader, strconv.FormatInt(ts.Unix(), 10)) req.Header.Set(webhook.RelaySignatureHeader, webhook.SignRelay(secret, ts, body)) - resp, err := safeurl.Client(e.webhookVerifyTimeout(), opts).Do(req) + // The receiver is the customer's, so the client span keeps its host + // off; the URL itself never goes on a span at all. + client := telemetry.InstrumentClient(safeurl.Client(e.webhookVerifyTimeout(), opts), + telemetry.Endpoint{Kind: telemetry.ProviderWebhook, HideHost: true}, e.tel) + resp, err := client.Do(req) if err != nil { return fmt.Errorf("%w: test delivery failed: %w", ErrWebhookURLRejected, err) } diff --git a/extension/extension.go b/extension/extension.go index b89e006f..4b4a82ea 100644 --- a/extension/extension.go +++ b/extension/extension.go @@ -31,7 +31,6 @@ import ( "github.com/xraph/authsome/bridge/dispatchadapter" "github.com/xraph/authsome/bridge/heraldadapter" "github.com/xraph/authsome/bridge/maileradapter" - "github.com/xraph/authsome/bridge/metricsadapter" "github.com/xraph/authsome/bridge/relayadapter" "github.com/xraph/authsome/dpop" "github.com/xraph/authsome/environment" @@ -325,9 +324,12 @@ func (e *Extension) init(fapp forge.App) error { e.Logger().Info("authsome: auto-discovered keysmith engine (first-class)") } - // ── Metrics: record into the app's registry ── + // ── Metrics: record authsome's families into the app's registry ── + // + // HTTP latency and status per route are forge's to record (its semconv + // http.server metrics), so authsome no longer measures them itself. if m := fapp.Metrics(); m != nil { - opts = append(opts, authsome.WithMetrics(metricsadapter.New(m))) + opts = append(opts, authsome.WithMetricsSink(m)) } // ── Auto-discover Relay (optional) ── @@ -489,8 +491,9 @@ func (e *Extension) init(fapp forge.App) error { router := fapp.Router() if router != nil { - // Every authsome route reports its latency and outcome. - groupedRouter := router.Group(basePath, forge.WithGroupMiddleware(middleware.Metrics(eng.MetricsCollector()))) + // Route latency and status are forge's own http.server metrics + // and server spans, so the group needs no middleware of ours. + groupedRouter := router.Group(basePath) // Origin-root routes first. Well-known discovery documents must // answer at the host root, so they cannot go on groupedRouter. diff --git a/extension/telemetry_test.go b/extension/telemetry_test.go new file mode 100644 index 00000000..8083313b --- /dev/null +++ b/extension/telemetry_test.go @@ -0,0 +1,79 @@ +package extension_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/chronicle" + chronstore "github.com/xraph/chronicle/store" + chronmem "github.com/xraph/chronicle/store/memory" + "github.com/xraph/forge" + "github.com/xraph/vessel" + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/extension" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/store/memory" +) + +// The extension hands the engine the app's registry, so authsome's +// families land on /_/metrics under forge's namespace. Route latency and +// status are forge's own http.server metrics now: authsome records no +// per-request event of its own, so no route path (and none of the IDs and +// secrets some paths carry) ever becomes a label. +func TestExtensionRecordsIntoTheAppRegistry(t *testing.T) { + app := forge.New(forge.WithAppName("authsome-extension-telemetry"), forge.WithAppLogger(forge.NewNoopLogger())) + + chron, err := chronicle.New(chronicle.WithStore(chronstore.NewAdapter(chronmem.New()))) + require.NoError(t, err) + require.NoError(t, vessel.Provide(app.Container(), func() (chronicle.Emitter, error) { return chron, nil })) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + require.NoError(t, vessel.Provide(app.Container(), func() (*warden.Engine, error) { return w, nil })) + + ext := extension.New(extension.WithEngineOptions(authsome.WithStore(memory.New()))) + require.NoError(t, ext.Register(app)) + ctx := context.Background() + require.NoError(t, ext.Start(ctx)) + t.Cleanup(func() { _ = ext.Stop(ctx) }) + require.NotNil(t, ext.Engine().Telemetry(), "the extension wires the app's registry into the engine") + + // One request through the mounted routes. + rr := httptest.NewRecorder() + app.Router().ServeHTTP(rr, httptest.NewRequestWithContext(ctx, http.MethodGet, "/authsome/v1/health", http.NoBody)) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + // One sign-in nobody can complete. + platform, err := id.ParseAppID(ext.Engine().PlatformAppID().String()) + require.NoError(t, err) + _, _, err = ext.Engine().SignIn(ctx, &account.SignInRequest{AppID: platform, Email: "nobody@example.com", Password: "not-the-password"}) + require.ErrorIs(t, err, account.ErrInvalidCredentials) + + p, ok := app.Metrics().(interface{ PrometheusHandler() http.Handler }) + require.True(t, ok) + scrape := httptest.NewRecorder() + p.PrometheusHandler().ServeHTTP(scrape, httptest.NewRequestWithContext(ctx, http.MethodGet, "/_/metrics", http.NoBody)) + body := scrape.Body.String() + + assert.Contains(t, body, `forge_authsome_signin_attempts_total{method="password",result="invalid"} 1`) + assert.Contains(t, body, `forge_authsome_password_hash_duration_seconds_count{algorithm="bcrypt",operation="verify"} 1`) + assert.NotContains(t, body, `action="http.request"`, "the old per-request middleware is gone") + assert.Contains(t, body, `http_route="/authsome/v1/health"`, "forge measures the route, by its template") + for line := range strings.SplitSeq(body, "\n") { + if !strings.HasPrefix(line, "forge_authsome_") { + continue + } + assert.NotContains(t, line, "/v1/", "no route path is an authsome label") + assert.NotContains(t, line, "tenant=", "no authsome family is labelled by tenant") + assert.NotContains(t, line, platform.String(), "no app ID in any authsome series") + } +} diff --git a/go.mod b/go.mod index dd3e89c5..b7f7b044 100644 --- a/go.mod +++ b/go.mod @@ -15,24 +15,25 @@ require ( github.com/russellhaering/goxmldsig v1.6.1 github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 - github.com/xraph/chronicle v1.7.0 - github.com/xraph/dispatch v1.7.0 - github.com/xraph/forge v1.12.5 - github.com/xraph/forge/extensions/auth v1.12.5 - github.com/xraph/go-utils v1.3.0 - github.com/xraph/grove v1.7.1 - github.com/xraph/grove/drivers/mongodriver v1.7.1 - github.com/xraph/grove/drivers/pgdriver v1.7.1 - github.com/xraph/grove/drivers/sqlitedriver v1.7.1 - github.com/xraph/herald v1.7.0 - github.com/xraph/keysmith v1.7.0 - github.com/xraph/ledger v1.7.0 - github.com/xraph/relay v1.7.0 - github.com/xraph/vault v1.7.0 + github.com/xraph/chronicle v1.9.0 + github.com/xraph/dispatch v1.9.1 + github.com/xraph/forge v1.12.9 + github.com/xraph/forge/extensions/auth v1.12.9 + github.com/xraph/go-utils v1.4.1 + github.com/xraph/grove v1.8.0 + github.com/xraph/grove/drivers/mongodriver v1.8.0 + github.com/xraph/grove/drivers/pgdriver v1.8.0 + github.com/xraph/grove/drivers/sqlitedriver v1.8.0 + github.com/xraph/herald v1.8.0 + github.com/xraph/keysmith v1.8.0 + github.com/xraph/ledger v1.8.0 + github.com/xraph/relay v1.8.0 + github.com/xraph/vault v1.8.0 github.com/xraph/vessel v1.0.4 - github.com/xraph/warden v1.7.0 + github.com/xraph/warden v1.8.0 go.jetify.com/typeid/v2 v2.0.0-alpha.3 go.mongodb.org/mongo-driver/v2 v2.9.1 + go.opentelemetry.io/otel/sdk v1.46.0 golang.org/x/crypto v0.57.0 golang.org/x/oauth2 v0.36.0 gotest.tools/v3 v3.5.2 @@ -55,6 +56,8 @@ require ( github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/grafana/pyroscope-go v1.4.3 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-metrics v0.6.1 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect @@ -76,11 +79,11 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect - go.opentelemetry.io/otel/sdk v1.46.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect @@ -185,13 +188,13 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xraph/confy v1.0.3 // indirect + github.com/xraph/confy v1.1.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect - go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/metric v1.47.0 // indirect - go.opentelemetry.io/otel/trace v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect diff --git a/go.sum b/go.sum index a81b3859..ea597b28 100644 --- a/go.sum +++ b/go.sum @@ -179,6 +179,10 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/pyroscope-go v1.4.3 h1:XAfYZe5ie8eRTsKAMHQG60ESJjHBV5XUijk8vDvqalw= +github.com/grafana/pyroscope-go v1.4.3/go.mod h1:enNhwzbML7+hMzJHTvKAIqTGqIaOOF1rgF+2au+NOwg= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11 h1:el5LYpXissAiCKZ5/6yjlr6mhYVV6Cp5lahTocxraXM= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11/go.mod h1:jl1V8M4cWsXciROCPIDDG7CtjSjT/ECbp6eLVuMxYRI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/consul/api v1.34.4 h1:0U4YZ1Yp7K9WK9ex0gTJraFim26l02wCvsmf2ukalVE= @@ -420,44 +424,44 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/xraph/chronicle v1.7.0 h1:ykCJedXgI0x2CCDOJO9HpLSbAUSTdL26rmo2bYCp3uI= -github.com/xraph/chronicle v1.7.0/go.mod h1:XIecdcky0D6+0agzVqxCM1RjLjkKpnL8LrDYPstmt7c= -github.com/xraph/confy v1.0.3 h1:mY0kIo9+dU+XcGGN7cm4IfOhvk4SDj5TrnccpGi21fs= -github.com/xraph/confy v1.0.3/go.mod h1:DcYB+N0yKpr3t1yhhkdIIiCfch30Kh5wW6GRntUMOGk= -github.com/xraph/dispatch v1.7.0 h1:T71tJ5y6sBXX+cFnCRJiOjYoC8QjWQcmwXUsTDA8pCE= -github.com/xraph/dispatch v1.7.0/go.mod h1:M/9XeX4swHc5HpgQr6NuZG6cnAx6lwHpdgFch6wy25M= -github.com/xraph/forge v1.12.5 h1:YUw/Kjb7JagDiiytPYMg0bu4K5Ke6hg2iK/XhYmssZk= -github.com/xraph/forge v1.12.5/go.mod h1:O1JOH9fxDhKRyvBbIl2UpZ+aFCjv7vO/IbNfAwnYSEI= -github.com/xraph/forge/extensions/auth v1.12.5 h1:agFdb4VifNz7xQDgM/7w0bTOnWQXeZsmqLWNJ9bhHMA= -github.com/xraph/forge/extensions/auth v1.12.5/go.mod h1:r9LFtMkQF7OV/XWa4C9WGjL33fphALJ9t6d05KXRzQ8= -github.com/xraph/go-utils v1.3.0 h1:bMZxwH9Xi/6UqhHSotKHB3a+/FnqnlUnEKf+aHZaw5c= -github.com/xraph/go-utils v1.3.0/go.mod h1:NfSn00r74ZjcIhVujIDPdKV25Ktghy2yz0DQmIKEARk= -github.com/xraph/grove v1.7.1 h1:dxXQDbnC6KFwPxxVS6OsIXG+rh4Ees7Jlb+bscY4oYo= -github.com/xraph/grove v1.7.1/go.mod h1:7RviFsJh4CgHtp7DKkgUtRgIPrzdGmWhzu2nLlC2eWU= -github.com/xraph/grove/drivers/mongodriver v1.7.1 h1:VjfZnjZBJfLIb/vVFLfmXc11f6fjfDCMKyxTVXwOing= -github.com/xraph/grove/drivers/mongodriver v1.7.1/go.mod h1:E7ikkdRhiJm1lLCJ5mOVYa4Z6CNqhG/w5UN8A8jnnac= -github.com/xraph/grove/drivers/pgdriver v1.7.1 h1:YCZOg2X1501mswJWO9gp7yF+1NhcfXfbolNRzdJu4kI= -github.com/xraph/grove/drivers/pgdriver v1.7.1/go.mod h1:tN3pXpElsaWtSfZxnyHDr4jZAAl8pJPQfp+pCx/3R5c= -github.com/xraph/grove/drivers/sqlitedriver v1.7.1 h1:L515tTwens2qEsE8wj0GiCmpSLBwHBxxs9ErprZgl6U= -github.com/xraph/grove/drivers/sqlitedriver v1.7.1/go.mod h1:qvoSnNilfVwyB7S7RIYkgZ8WCYGL8CHuegilc4PrZMc= -github.com/xraph/grove/kv v1.7.1 h1:6uBFx0kdy1vDOA1z8pOtED3MA3nt3gmylQ5OnLdhQLY= -github.com/xraph/grove/kv v1.7.1/go.mod h1:DWru5OjKvxjeUKixtsgbDGm4BWrMltB6IA3nLQUrsFw= -github.com/xraph/grove/kv/drivers/redisdriver v1.7.1 h1:nobUkiGI4JTUmH2hFiurO7vO0N4ddL08u4jeSJc3Mp8= -github.com/xraph/grove/kv/drivers/redisdriver v1.7.1/go.mod h1:sSoFDCR105ys80xwoZOQphsqAGNd6JClry2QNm7cwp4= -github.com/xraph/herald v1.7.0 h1:974/fbcRXXZ4a9XBWC19K4KmtxxD4pDOux/xmSa9MGM= -github.com/xraph/herald v1.7.0/go.mod h1:eeU1CRBqm+mdnsGlsFwlH5Z+FI2yuLcXA7QitEAtxz0= -github.com/xraph/keysmith v1.7.0 h1:Cl8rLVL+4khddfAI1fe162kSvs8W/66O56nv6IIayl4= -github.com/xraph/keysmith v1.7.0/go.mod h1:xJFqUrB8eQUv4l7nnODlNbKLTcn7iFxxJNdmJTca954= -github.com/xraph/ledger v1.7.0 h1:ewtTggg9WtrNAnowdMIL96XVXnBP+5iR+hwqBePBvRQ= -github.com/xraph/ledger v1.7.0/go.mod h1:BLmXxWBYUUfh/HwRkHDjpgXMbB/2h260P5OsnuSuXYU= -github.com/xraph/relay v1.7.0 h1:qEYuyUfWvO4/M+24kizT0MnMYBefDvD8TmrENO5shAU= -github.com/xraph/relay v1.7.0/go.mod h1:RNOXPFhK1H3xcmiVZYdG8B5Iy3Pw4mGWyeqktg0SXJw= -github.com/xraph/vault v1.7.0 h1:Nl6s2T34QPR8F/wzUT3gsmM0A2eKInrRuTwiZCUzVlY= -github.com/xraph/vault v1.7.0/go.mod h1:HkbDT/p9C++7/pxuG5KGJ+5AsEtA+FK88DmpyOurZd0= +github.com/xraph/chronicle v1.9.0 h1:npLeZDrOb6XOVee5OD7fHkxcYbH1j/atJ0p9FA7botY= +github.com/xraph/chronicle v1.9.0/go.mod h1:x2k4cIlGDyE8ZNBYCVN7EFrOCSQ0LSo45yEnsSrkkSs= +github.com/xraph/confy v1.1.0 h1:xkWaujezyxi+8AcSgmkWarB6MacGlrhozJi+QcOD5P0= +github.com/xraph/confy v1.1.0/go.mod h1:HZwQ/RixbBNtBMT7IcULVYbAkyFjeXcJDNJDYj6m5oI= +github.com/xraph/dispatch v1.9.1 h1:B6jqhyWKMFW9zU7Weg1cUuP3QF+AQuWsNsv/88j+vpc= +github.com/xraph/dispatch v1.9.1/go.mod h1:Buatu3flxbShAqw0w/agwl5etV9DCYyVEqb2vUuxog8= +github.com/xraph/forge v1.12.9 h1:hZ/qqPpXzTQm17aFdMsc7oINDuFrJAwIyidkphQT/yw= +github.com/xraph/forge v1.12.9/go.mod h1:atngBzJc3w2wDz/PYOZroYE7K5Xm5BpCDLIh5wLm9Oc= +github.com/xraph/forge/extensions/auth v1.12.9 h1:ZzvVev+KMAclQYuNg/28hS1bO2Vhaa+7QvKE2SPD8i0= +github.com/xraph/forge/extensions/auth v1.12.9/go.mod h1:+RriGL+jRZ5zO4GiSLm8eHb8Ypmnp8braypoj8s4YjA= +github.com/xraph/go-utils v1.4.1 h1:uP4CD+AaCKThbfR8kdQUh0kKHCFFaGb300fKMEJoSJY= +github.com/xraph/go-utils v1.4.1/go.mod h1:fMahP6kPUjJP5UmzVje2GSL7OGT9+xpYkukK2dLjSMM= +github.com/xraph/grove v1.8.0 h1:j1QsA/sqnc3WlVRSqvkRXrksC1ckv17hPvEEMjinwAc= +github.com/xraph/grove v1.8.0/go.mod h1:pZsulQT7WO3Owv9f2bxjW/qZxaz8y75SwdtVTPWKod8= +github.com/xraph/grove/drivers/mongodriver v1.8.0 h1:DzT2vRThDm02CkP9P37x5ECHXF3koqm9Rlcs9TYYc0k= +github.com/xraph/grove/drivers/mongodriver v1.8.0/go.mod h1:vp9jfR08GBmh3a42CGAdC87Z2vy8JurEO2VT4c0X17k= +github.com/xraph/grove/drivers/pgdriver v1.8.0 h1:UxYJKdObN9Qd0kQD8JrD9AX4AeDRAV8M5WlOKdQBdJE= +github.com/xraph/grove/drivers/pgdriver v1.8.0/go.mod h1:V9vYMFGIWdtkTOrOEB+rR5kXlhkuQeWjjCKRa0btTdg= +github.com/xraph/grove/drivers/sqlitedriver v1.8.0 h1:CKTCDEN7t/DQbTuZEuvcZx5JYe+rvxLw0oRXaBeyAOo= +github.com/xraph/grove/drivers/sqlitedriver v1.8.0/go.mod h1:ZC2rVcW+ZfWDIiTl1BZ2F9jeIlhl5LayBMyENJoXZSk= +github.com/xraph/grove/kv v1.8.0 h1:YZFeZYWK7omUaloWlVpMwFobl1dI2NCBHH2/iTs6yxo= +github.com/xraph/grove/kv v1.8.0/go.mod h1:dRzQTDR3/btTAxaBr/gXLv5KtSgkdQKWxzE7jaQAoDI= +github.com/xraph/grove/kv/drivers/redisdriver v1.8.0 h1:cyIWJffqtQbhPzSDCvTdZ8xHD12Qs0oclKRxd5h3+L0= +github.com/xraph/grove/kv/drivers/redisdriver v1.8.0/go.mod h1:iW0FOUNFeFi6VBw8OQpV8wUr2U/D/Xyv006NKc1ae9Y= +github.com/xraph/herald v1.8.0 h1:DuYhl1LATwKrr4zsUqlOszxv7/uKkosFb8j6LuvLEgs= +github.com/xraph/herald v1.8.0/go.mod h1:rCQEUrOPA4+TbUBtoU+PbAXZ5WbyexNL8j1w2eKZYWo= +github.com/xraph/keysmith v1.8.0 h1:KIGF5XbJDw7133PX1NnnrTyEgUf13g/Jb1IG3JjePzU= +github.com/xraph/keysmith v1.8.0/go.mod h1:SiYAYp+gtEdFHHjrAf7luCd2BrYAORWXxhzwyqaDIp4= +github.com/xraph/ledger v1.8.0 h1:kDjCSjrqf+4o+pIaOHI5lnbxAqXwsWbt5h8ZHmPVQEg= +github.com/xraph/ledger v1.8.0/go.mod h1:Z6TRJvyy2m/KhdZ1lreb7oqeH6z/hl7dsmvkSOXWkMo= +github.com/xraph/relay v1.8.0 h1:Pfa76U4S1p0NUS87ZZLw5mzqzXz50gp/0wmUE/xSRvQ= +github.com/xraph/relay v1.8.0/go.mod h1:ymuGmz2y2PLfHE7Thynf4L4lgRH3ruv5vbazfjMo1X4= +github.com/xraph/vault v1.8.0 h1:C/rmu9v331pIlLIx1kxD6QEQWI8VDAcUIhpxHkY4BmE= +github.com/xraph/vault v1.8.0/go.mod h1:+UzreUI4t131KxMLCCaz9WR4jVu3sKjy6pvejSspolA= github.com/xraph/vessel v1.0.4 h1:YVG80hm7bAKTwogwLXZMlp46tMHHJThUcvCUOWao9Yk= github.com/xraph/vessel v1.0.4/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= -github.com/xraph/warden v1.7.0 h1:+RVekuWfzUjnFwsByon9mKAL1WU5OyHcssf+JfDP4UM= -github.com/xraph/warden v1.7.0/go.mod h1:5z8oZ0eMr/MrCAwpymtQV+cklpJfBYGIxyFT/gI7A44= +github.com/xraph/warden v1.8.0 h1:U4ErO2KRr7XPwa/NZllU9YBc0pLlfabp00LIIH3FBn0= +github.com/xraph/warden v1.8.0/go.mod h1:LfjpZAVXPQFev8rG2Kp5Qj0UQVP8eLLvzr3yfBvf9dM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -481,8 +485,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAy go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0 h1:pnxy6c/kvNBWdNNFzqpjuJLm9Hjhgk/Q0nY221rwuk0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.45.0/go.mod h1:qw6YsFapotRwoDhXRZvljzaOvCQB7UfnafEJagpN2TA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= @@ -491,6 +495,8 @@ go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lq go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= diff --git a/internal/telemetry/http.go b/internal/telemetry/http.go new file mode 100644 index 00000000..9f3ff426 --- /dev/null +++ b/internal/telemetry/http.go @@ -0,0 +1,233 @@ +package telemetry + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "io" + "net" + "net/http" + "strconv" + "syscall" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/baggage" + "go.opentelemetry.io/otel/trace" +) + +// Endpoint describes a third-party call for ProviderTransport. Nothing in +// it comes from the request URL, which is why a span can't leak the +// password hash prefix in a breach-check path, an account SID in an SMS +// path, or a token in a query string. +type Endpoint struct { + // Kind is the provider kind, one of the Provider* constants. It is + // recorded as authsome.provider.kind and as the kind label. + Kind string + + // HideHost keeps server.address and server.port off the span. Set it + // when a customer supplies the URL (an SSO issuer, a SAML metadata URL, + // a webhook or a generic CRM endpoint): the host is then theirs, not a + // vendor's. + HideHost bool +} + +// propagationHeaders are the headers a trace propagator writes. A request +// to a third party goes out without them. +var propagationHeaders = []string{"traceparent", "tracestate", "baggage"} + +// ProviderTransport wraps base (http.DefaultTransport when nil) so every +// request it sends is measured, and traced under the span in the request's +// context. +// +// The client span is named after the HTTP method alone and carries +// http.request.method, authsome.provider.kind, server.address and +// server.port (unless ep.HideHost), and http.response.status_code. It never +// carries the URL, its path or its query, or any header. A 4xx or 5xx +// answer, or a transport failure, marks it failed with error.type set to +// the status code or to timeout, canceled, dns, tls, connection or other. +// The error's text never reaches it. With no span in the request's context +// it records the duration and starts no span, so a call made outside any +// request does not mint a trace of its own. +// +// The request goes out with exactly the headers its caller set: no +// traceparent, tracestate or baggage. Providers are third parties, and an +// inbound request's baggage is not theirs to see. +// +// m may be nil, in which case only the span is recorded. +func ProviderTransport(base http.RoundTripper, ep Endpoint, m *Metrics) http.RoundTripper { + return &providerTransport{base: base, ep: ep, m: m} +} + +// InstrumentClient returns a copy of c whose transport is c's own wrapped +// in ProviderTransport. A nil c stands for http.DefaultClient. The copy +// keeps c's timeout, redirect policy and cookie jar. +func InstrumentClient(c *http.Client, ep Endpoint, m *Metrics) *http.Client { + if c == nil { + c = http.DefaultClient + } + out := *c + out.Transport = ProviderTransport(c.Transport, ep, m) + return &out +} + +type providerTransport struct { + base http.RoundTripper + ep Endpoint + m *Metrics +} + +// RoundTrip implements http.RoundTripper. +func (t *providerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + start := time.Now() + ctx := baggage.ContextWithoutBaggage(req.Context()) + + // With no parent there is no span: SpanFromContext on a ctx without one + // is the no-op span, and every call below is free on it. + span := trace.SpanFromContext(context.Background()) + if trace.SpanContextFromContext(ctx).IsValid() { + method := knownMethod(req.Method) + attrs := []attribute.KeyValue{ + AttrHTTPMethod.String(method), + AttrProviderKind.String(t.ep.Kind), + } + if !t.ep.HideHost && req.URL != nil { + attrs = append(attrs, AttrServerAddress.String(req.URL.Hostname())) + if p, err := strconv.Atoi(req.URL.Port()); err == nil { + attrs = append(attrs, AttrServerPort.Int(p)) + } + } + ctx, span = Start(ctx, method, trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(attrs...)) + } + defer span.End() + + out := req.WithContext(ctx) + if hasPropagation(req.Header) { + out = req.Clone(ctx) + for _, h := range propagationHeaders { + out.Header.Del(h) + } + } + + base := t.base + if base == nil { + base = http.DefaultTransport + } + resp, err := base.RoundTrip(out) + if err != nil { + class := CallClass(ctx, err) + Fail(span, class, err) + t.m.ProviderRequest(t.ep.Kind, providerResult(class), time.Since(start)) + return nil, err + } + span.SetAttributes(AttrHTTPStatusCode.Int(resp.StatusCode)) + result := ProviderOK + if resp.StatusCode >= http.StatusBadRequest { + Fail(span, strconv.Itoa(resp.StatusCode), nil) + result = Provider4xx + if resp.StatusCode >= http.StatusInternalServerError { + result = Provider5xx + } + } + t.m.ProviderRequest(t.ep.Kind, result, time.Since(start)) + return resp, nil +} + +func hasPropagation(h http.Header) bool { + for _, k := range propagationHeaders { + if h.Get(k) != "" { + return true + } + } + return false +} + +// providerResult maps a transport failure class onto the result label. +func providerResult(class string) string { + switch class { + case ClassTimeout: + return ProviderTimeout + case ClassCanceled: + return ProviderCanceled + default: + return ProviderError + } +} + +// knownMethod returns m when it is a standard HTTP method and "_OTHER" +// otherwise, as the HTTP semantic conventions ask. +func knownMethod(m string) string { + switch m { + case "": + return http.MethodGet + case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace: + return m + default: + return "_OTHER" + } +} + +// Transport failure classes. These are the only values CallClass returns. +const ( + ClassTimeout = "timeout" + ClassCanceled = "canceled" + ClassDNS = "dns" + ClassTLS = "tls" + ClassConnection = "connection" + ClassOther = "other" +) + +// CallClass sorts a transport failure under ctx into timeout, canceled, +// dns, tls, connection or other. A deadline that has passed or a canceled +// ctx decides the class whatever err says, since http.Client's own timeout +// can surface as an error that is neither a context error nor a net.Error. +func CallClass(ctx context.Context, err error) string { + if dl, ok := ctx.Deadline(); ok && !time.Now().Before(dl) { + return ClassTimeout + } + if errors.Is(ctx.Err(), context.Canceled) { + return ClassCanceled + } + switch { + case errors.Is(err, context.Canceled): + return ClassCanceled + case errors.Is(err, context.DeadlineExceeded): + return ClassTimeout + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + if dnsErr.IsTimeout { + return ClassTimeout + } + return ClassDNS + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return ClassTimeout + } + if isTLS(err) { + return ClassTLS + } + var opErr *net.OpError + if errors.As(err, &opErr) || + errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return ClassConnection + } + return ClassOther +} + +func isTLS(err error) bool { + var ( + verifyErr *tls.CertificateVerificationError + recordErr tls.RecordHeaderError + alertErr tls.AlertError + unknownAuth x509.UnknownAuthorityError + hostnameErr x509.HostnameError + invalidCert x509.CertificateInvalidError + ) + return errors.As(err, &verifyErr) || errors.As(err, &recordErr) || errors.As(err, &alertErr) || + errors.As(err, &unknownAuth) || errors.As(err, &hostnameErr) || errors.As(err, &invalidCert) +} diff --git a/internal/telemetry/http_test.go b/internal/telemetry/http_test.go new file mode 100644 index 00000000..b5ba3b59 --- /dev/null +++ b/internal/telemetry/http_test.go @@ -0,0 +1,196 @@ +package telemetry_test + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/baggage" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" +) + +// upstream records the headers each request arrived with. +type upstream struct { + mu sync.Mutex + headers []http.Header + status int +} + +func (u *upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { + u.mu.Lock() + u.headers = append(u.headers, r.Header.Clone()) + u.mu.Unlock() + w.WriteHeader(u.status) +} + +// withParent starts a parent span carrying some inbound baggage, the way a +// request ctx does after forge's server middleware extracts it. +func withParent(t *testing.T) (context.Context, trace.Span) { + t.Helper() + member, err := baggage.NewMember("tenant", "acme") + require.NoError(t, err) + bag, err := baggage.New(member) + require.NoError(t, err) + ctx := baggage.ContextWithBaggage(context.Background(), bag) + return telemetry.Start(ctx, "parent") +} + +func TestProviderTransportRecordsAClientSpanWithoutTheURL(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + m := telemetry.NewMetrics(reg) + + up := &upstream{status: http.StatusOK} + srv := httptest.NewServer(up) + defer srv.Close() + + client := telemetry.InstrumentClient(&http.Client{Timeout: 5 * time.Second}, + telemetry.Endpoint{Kind: telemetry.ProviderBreach}, m) + + ctx, parent := withParent(t) + // The path and query carry what a real breach-check or SMS call would: + // a password hash prefix, an account SID, a token. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + srv.URL+"/range/5BAA6?token=sk-secret-query-value", http.NoBody) + require.NoError(t, err) + // A caller (or an instrumented layer) that set propagation headers + // still sends none of them to the third party. + req.Header.Set("traceparent", "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01") + req.Header.Set("baggage", "tenant=acme") + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + parent.End() + + require.Len(t, up.headers, 1) + for _, h := range []string{"Traceparent", "Tracestate", "Baggage"} { + assert.Empty(t, up.headers[0].Get(h), "%s reached the third party", h) + } + assert.Equal(t, "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", req.Header.Get("traceparent"), + "the caller's own request is not modified") + + spans := telemetrytest.Authsome(rec) + require.Len(t, telemetrytest.Named(spans, "GET"), 1) + span := telemetrytest.Named(spans, "GET")[0] + assert.Equal(t, trace.SpanKindClient, span.SpanKind()) + assert.Equal(t, parent.SpanContext().SpanID(), span.Parent().SpanID(), "the client span hangs off the caller's span") + + attrs := telemetrytest.Attrs(span) + host, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://")) + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "http.request.method": "GET", + "authsome.provider.kind": "breach", + "server.address": host, + "server.port": port, + "http.response.status_code": "200", + }, attrs) + text := telemetrytest.Text(span) + for _, leaked := range []string{"5BAA6", "/range", "sk-secret-query-value", "token="} { + assert.NotContains(t, text, leaked) + } + + assert.Contains(t, scrape(), `forge_authsome_provider_request_duration_seconds_count{kind="breach",result="ok"} 1`) +} + +func TestProviderTransportHidesACustomersHostAndMarksFailures(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + m := telemetry.NewMetrics(reg) + + up := &upstream{status: http.StatusServiceUnavailable} + srv := httptest.NewServer(up) + defer srv.Close() + + client := telemetry.InstrumentClient(nil, telemetry.Endpoint{Kind: telemetry.ProviderCRM, HideHost: true}, m) + ctx, parent := withParent(t) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/hooks/contact", http.NoBody) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + parent.End() + + span := telemetrytest.Named(telemetrytest.Authsome(rec), "POST")[0] + attrs := telemetrytest.Attrs(span) + assert.NotContains(t, attrs, "server.address") + assert.NotContains(t, attrs, "server.port") + assert.Equal(t, "503", attrs["error.type"]) + assert.Equal(t, codes.Error, span.Status().Code) + assert.Equal(t, "503", span.Status().Description) + assert.Contains(t, scrape(), `forge_authsome_provider_request_duration_seconds_count{kind="crm",result="5xx"} 1`) +} + +func TestProviderTransportStartsNoTraceWithoutAParent(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + m := telemetry.NewMetrics(reg) + + srv := httptest.NewServer(&upstream{status: http.StatusNotFound}) + defer srv.Close() + + client := telemetry.InstrumentClient(nil, telemetry.Endpoint{Kind: telemetry.ProviderSAMLMetadata, HideHost: true}, m) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + assert.Empty(t, rec.Ended(), "a call made outside any request mints no trace") + assert.Contains(t, scrape(), `forge_authsome_provider_request_duration_seconds_count{kind="saml_metadata",result="4xx"} 1`) +} + +func TestProviderTransportClassifiesTransportFailures(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + m := telemetry.NewMetrics(reg) + + // A listener that is closed at once refuses the connection. + ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + client := telemetry.InstrumentClient(&http.Client{Timeout: 2 * time.Second}, + telemetry.Endpoint{Kind: telemetry.ProviderSocial}, m) + ctx, parent := withParent(t) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/oauth/token?code=abc", http.NoBody) + require.NoError(t, err) + _, err = client.Do(req) //nolint:bodyclose // the request fails, there is no body + require.Error(t, err) + parent.End() + + span := telemetrytest.Named(telemetrytest.Authsome(rec), "GET")[0] + attrs := telemetrytest.Attrs(span) + assert.Equal(t, "connection", attrs["error.type"]) + assert.Equal(t, "connection", span.Status().Description) + require.Len(t, span.Events(), 1) + assert.Equal(t, "exception", span.Events()[0].Name) + assert.NotContains(t, telemetrytest.Text(span), addr, "the transport's error text, which quotes the URL, stays off the span") + assert.NotContains(t, telemetrytest.Text(span), "code=abc") + assert.Contains(t, scrape(), `forge_authsome_provider_request_duration_seconds_count{kind="social",result="error"} 1`) +} + +func TestCallClass(t *testing.T) { + expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + canceled, cancel2 := context.WithCancel(context.Background()) + cancel2() + + assert.Equal(t, telemetry.ClassTimeout, telemetry.CallClass(expired, errors.New("x"))) + assert.Equal(t, telemetry.ClassCanceled, telemetry.CallClass(canceled, errors.New("x"))) + assert.Equal(t, telemetry.ClassDNS, telemetry.CallClass(context.Background(), &net.DNSError{Err: "no such host", Name: "x"})) + assert.Equal(t, telemetry.ClassOther, telemetry.CallClass(context.Background(), errors.New("x"))) +} diff --git a/internal/telemetry/metrics.go b/internal/telemetry/metrics.go new file mode 100644 index 00000000..0d1242ba --- /dev/null +++ b/internal/telemetry/metrics.go @@ -0,0 +1,223 @@ +package telemetry + +import ( + "time" + + "github.com/xraph/forge" + gometrics "github.com/xraph/go-utils/metrics" +) + +// Metric names as authsome records them. Forge's Prometheus export puts its +// namespace in front, so authsome_signin_attempts_total scrapes as +// forge_authsome_signin_attempts_total. +const ( + MetricSignIns = "authsome_signin_attempts_total" + MetricMFAVerifications = "authsome_mfa_verifications_total" + MetricSessionsRefreshed = "authsome_sessions_refreshed_total" + MetricPasswordHashDuration = "authsome_password_hash_duration_seconds" + MetricAPIKeyAuths = "authsome_apikey_authentications_total" // #nosec G101 -- a metric name, not a credential + MetricOAuth2Tokens = "authsome_oauth2_tokens_requested_total" // #nosec G101 -- a metric name, not a credential + MetricProviderDuration = "authsome_provider_request_duration_seconds" + MetricRetentionRows = "authsome_retention_rows_deleted_total" + MetricRetentionSweeps = "authsome_retention_sweeps_attempted_total" + MetricRetentionJobs = "authsome_retention_jobs_processed_total" + MetricControlsDegraded = "authsome_controls_degraded_total" + MetricRequestsThrottled = "authsome_requests_throttled_total" + MetricRateLimiterErrors = "authsome_ratelimiter_errors_total" + MetricAuditRecordsFailed = "authsome_audit_records_failed_total" + MetricEvents = "authsome_events_total" +) + +// passwordHashBuckets span a bcrypt or Argon2id run: a few milliseconds at +// test cost, tens to a few hundred at production cost, and a tail for a +// host under memory pressure. +var passwordHashBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2} + +// providerBuckets span a third-party HTTP call from a warm connection to +// the longest client timeout authsome sets (30 seconds for a CRM). +var providerBuckets = []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30} + +// Metrics holds authsome's instruments. They are declared once, when the +// engine is built. A nil *Metrics records nothing, which is what an engine +// built without WithMetricsSink gets; every method is safe to call on it. +type Metrics struct { + signIns *forge.Vec[forge.Counter] + mfa *forge.Vec[forge.Counter] + refreshes *forge.Vec[forge.Counter] + passwordHash *forge.Vec[forge.Histogram] + apiKeys *forge.Vec[forge.Counter] + oauth2Tokens *forge.Vec[forge.Counter] + provider *forge.Vec[forge.Histogram] + retentionRows *forge.Vec[forge.Counter] + retentionSweeps *forge.Vec[forge.Counter] + retentionJobs *forge.Vec[forge.Counter] + controlsDegraded *forge.Vec[forge.Counter] + throttled *forge.Vec[forge.Counter] + limiterErrors *forge.Vec[forge.Counter] + auditFailed forge.Counter + events *forge.Vec[forge.Counter] +} + +// NewMetrics declares authsome's instruments in m. A nil m returns nil. +func NewMetrics(m gometrics.Metrics) *Metrics { + if m == nil { + return nil + } + return &Metrics{ + signIns: forge.CounterVec(m, MetricSignIns, "method", "result"), + mfa: forge.CounterVec(m, MetricMFAVerifications, "flow", "method", "result"), + refreshes: forge.CounterVec(m, MetricSessionsRefreshed, "result"), + passwordHash: forge.HistogramVec(m, MetricPasswordHashDuration, forge.HistogramOpts{ + Buckets: passwordHashBuckets, + Help: "Time authsome spent hashing or verifying a password, by operation and algorithm.", + Unit: "s", + }, "operation", "algorithm"), + apiKeys: forge.CounterVec(m, MetricAPIKeyAuths, "result"), + oauth2Tokens: forge.CounterVec(m, MetricOAuth2Tokens, "grant_type", "result"), + provider: forge.HistogramVec(m, MetricProviderDuration, forge.HistogramOpts{ + Buckets: providerBuckets, + Help: "Time a third-party call took to answer, by provider kind and result.", + Unit: "s", + }, "kind", "result"), + retentionRows: forge.CounterVec(m, MetricRetentionRows, "kind"), + retentionSweeps: forge.CounterVec(m, MetricRetentionSweeps, "result"), + retentionJobs: forge.CounterVec(m, MetricRetentionJobs, "provider", "outcome"), + controlsDegraded: forge.CounterVec(m, MetricControlsDegraded, "control"), + throttled: forge.CounterVec(m, MetricRequestsThrottled, "limiter"), + limiterErrors: forge.CounterVec(m, MetricRateLimiterErrors, "limiter"), + auditFailed: m.Counter(MetricAuditRecordsFailed), + events: forge.CounterVec(m, MetricEvents, "action", "resource", "outcome"), + } +} + +// SignIn counts one sign-in attempt. method goes through AuthMethod. +func (t *Metrics) SignIn(method, result string) { + if t == nil { + return + } + t.signIns.With(AuthMethod(method), result).Inc() +} + +// MFAVerification counts one second-factor code check. +func (t *Metrics) MFAVerification(flow, method, result string) { + if t == nil { + return + } + t.mfa.With(flow, method, result).Inc() +} + +// Refresh counts one session refresh. +func (t *Metrics) Refresh(result string) { + if t == nil { + return + } + t.refreshes.With(result).Inc() +} + +// PasswordHash records how long one password hash or verify took. +func (t *Metrics) PasswordHash(operation, algorithm string, took time.Duration) { + if t == nil { + return + } + t.passwordHash.With(operation, algorithm).Observe(took.Seconds()) +} + +// APIKeyAuth counts one API key authentication. +func (t *Metrics) APIKeyAuth(result string) { + if t == nil { + return + } + t.apiKeys.With(result).Inc() +} + +// OAuth2Token counts one request at the OAuth2 token endpoint. +func (t *Metrics) OAuth2Token(grantType, result string) { + if t == nil { + return + } + t.oauth2Tokens.With(grantType, result).Inc() +} + +// ProviderRequest records how long one third-party call took. +func (t *Metrics) ProviderRequest(kind, result string, took time.Duration) { + if t == nil { + return + } + t.provider.With(kind, result).Observe(took.Seconds()) +} + +// RetentionRowsDeleted adds n to the rows the retention sweep removed. +func (t *Metrics) RetentionRowsDeleted(kind string, n int64) { + if t == nil || n <= 0 { + return + } + t.retentionRows.With(kind).Add(float64(n)) +} + +// RetentionSweep counts one retention sweep. +func (t *Metrics) RetentionSweep(result string) { + if t == nil { + return + } + t.retentionSweeps.With(result).Inc() +} + +// RetentionJob counts one CRM outbox job reaching an outcome. +func (t *Metrics) RetentionJob(provider, outcome string) { + if t == nil { + return + } + t.retentionJobs.With(provider, outcome).Inc() +} + +// ControlDegraded counts a security control that could not run and let +// the request through. +func (t *Metrics) ControlDegraded(control string) { + if t == nil { + return + } + t.controlsDegraded.With(control).Inc() +} + +// Throttled counts a request a rate limiter refused. +func (t *Metrics) Throttled(limiter string) { + if t == nil { + return + } + t.throttled.With(limiter).Inc() +} + +// RateLimiterError counts a rate limiter that could not answer. +func (t *Metrics) RateLimiterError(limiter string) { + if t == nil { + return + } + t.limiterErrors.With(limiter).Inc() +} + +// AuditRecordFailed counts an audit event the trail refused. +func (t *Metrics) AuditRecordFailed() { + if t == nil { + return + } + t.auditFailed.Inc() +} + +// Event counts one event on the hook bus. The values go through Bounded. +func (t *Metrics) Event(action, resource, outcome string) { + if t == nil { + return + } + t.events.With(Bounded(action), Bounded(resource), Bounded(outcome)).Inc() +} + +// From returns the instruments of an engine handed over as a plugin.Engine, +// or nil when the engine records none (or is a test stub without the +// method). It is how authsome's own plugins reach the sink without the +// plugin.Engine interface growing a method every stub would need. +func From(engine any) *Metrics { + if src, ok := engine.(interface{ Telemetry() *Metrics }); ok { + return src.Telemetry() + } + return nil +} diff --git a/internal/telemetry/signin.go b/internal/telemetry/signin.go new file mode 100644 index 00000000..9082b87f --- /dev/null +++ b/internal/telemetry/signin.go @@ -0,0 +1,78 @@ +package telemetry + +import ( + "errors" + "net/http" +) + +// SignInAttempt counts one sign-in through a plugin's verify or callback +// handler exactly once. Start it at the top of the handler and defer Done +// with the handler's error. Call HandOff right before IssueSession, which +// counts the attempt itself from then on; call Refuse where the handler +// answers a refusal without returning an error (a redirect carrying the +// error, say), so Done doesn't read the nil error as success. +// +// A nil *SignInAttempt (from a nil *Metrics) does nothing. +type SignInAttempt struct { + m *Metrics + method string + result string + handedOff bool +} + +// StartSignIn begins counting an attempt under method, one of the Method* +// constants. +func (t *Metrics) StartSignIn(method string) *SignInAttempt { + if t == nil { + return nil + } + return &SignInAttempt{m: t, method: method} +} + +// HandOff leaves the count to IssueSession. +func (a *SignInAttempt) HandOff() { + if a != nil { + a.handedOff = true + } +} + +// Refuse sets the result Done records, whatever error it is given. +func (a *SignInAttempt) Refuse(result string) { + if a != nil { + a.result = result + } +} + +// Done records the attempt unless it was handed off. Without a Refuse it +// reads the result off err: nil is ok, and an HTTP error's status sorts it +// into invalid (400, 401, 404), denied (403), rate_limited (429) or error. +func (a *SignInAttempt) Done(err error) { + if a == nil || a.handedOff { + return + } + result := a.result + if result == "" { + result = refusalResult(err) + } + a.m.SignIn(a.method, result) +} + +func refusalResult(err error) string { + if err == nil { + return SignInOK + } + var coded interface{ StatusCode() int } + if !errors.As(err, &coded) { + return SignInError + } + switch code := coded.StatusCode(); { + case code == http.StatusForbidden: + return SignInDenied + case code == http.StatusTooManyRequests: + return SignInRateLimited + case code >= 400 && code < 500: + return SignInInvalid + default: + return SignInError + } +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..419b18b6 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,125 @@ +// Package telemetry holds authsome's tracing and metric plumbing: the tracer +// every authsome span is started on, the helpers that mark a span failed +// without ever putting error text on it, the bounded values metric labels +// take, and the instruments recorded through a go-utils metrics sink. +// +// Nothing here reads a request body, a credential or an identifier. Callers +// pass enums and counts; the helpers refuse to take anything else. +package telemetry + +import ( + "context" + "errors" + "fmt" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// ScopeName is the instrumentation scope on every span authsome starts. +const ScopeName = "github.com/xraph/authsome" + +// Span attribute keys. Every value is an enum, a count or a boolean. None of +// them carries an ID, an address, a credential or text a caller supplied. +const ( + AttrResult = attribute.Key("authsome.result") + AttrAuthMethod = attribute.Key("authsome.auth.method") + AttrMFARequired = attribute.Key("authsome.mfa.required") + AttrMFAVerified = attribute.Key("authsome.mfa.verified") + AttrSessionFormat = attribute.Key("authsome.session.format") + AttrRefreshVia = attribute.Key("authsome.refresh.via") + AttrPasswordAlgorithm = attribute.Key("authsome.password.algorithm") + AttrPasswordOperation = attribute.Key("authsome.password.operation") + AttrPluginName = attribute.Key("authsome.plugin.name") + AttrPluginHook = attribute.Key("authsome.plugin.hook") + AttrProviderKind = attribute.Key("authsome.provider.kind") + AttrMessageChannel = attribute.Key("authsome.message.channel") + AttrRetentionRemoved = attribute.Key("authsome.retention.removed") + AttrRetentionKind = attribute.Key("authsome.retention.kind") + AttrRetentionJobs = attribute.Key("authsome.retention.jobs") + AttrRetentionJobKind = attribute.Key("authsome.retention.job_kind") + AttrRetentionProvider = attribute.Key("authsome.retention.provider") + AttrRetentionOutcome = attribute.Key("authsome.retention.outcome") + AttrJWKSSets = attribute.Key("authsome.jwks.sets") + AttrJWKSFailed = attribute.Key("authsome.jwks.failed") + + // The OTel semantic convention keys the client spans use. + AttrErrorType = attribute.Key("error.type") + AttrExceptionType = attribute.Key("exception.type") + AttrHTTPMethod = attribute.Key("http.request.method") + AttrHTTPStatusCode = attribute.Key("http.response.status_code") + AttrServerAddress = attribute.Key("server.address") + AttrServerPort = attribute.Key("server.port") +) + +// Start starts a span on the global tracer provider, which forge.New +// installs. The tracer is looked up on every call, so a provider installed +// after the engine was built still gets the spans. +func Start(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return otel.Tracer(ScopeName).Start(ctx, name, opts...) +} + +// StartRoot starts the root span of one unit of background work. Pass it a +// ctx that went through Detach, so nothing the unit does before or after +// the span lands in a trace that ended long ago. +func StartRoot(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { + return Start(ctx, name, append(opts, trace.WithNewRoot())...) +} + +// Detach returns ctx without the span it carries, keeping its values and +// its cancellation. +// +// A goroutine that outlives the call that started it must not keep that +// call's span: forge runs every extension's Start inside a "start " +// span, and grove opens a db.client span under any valid span context, +// even an ended one. Without this every query an idle loop makes would land +// in a boot trace that finished at startup. +func Detach(ctx context.Context) context.Context { + return trace.ContextWithSpanContext(ctx, trace.SpanContext{}) +} + +// Fail marks span failed with class as its error.type and status +// description, and adds an exception event carrying only the Go type of +// err's innermost error. err's text never reaches the span: store, driver +// and provider errors quote the values they were given. +func Fail(span trace.Span, class string, err error) { + if !span.IsRecording() { + return + } + span.SetAttributes(AttrErrorType.String(class)) + if err != nil { + span.AddEvent("exception", trace.WithAttributes(AttrExceptionType.String(TypeOf(err)))) + } + span.SetStatus(codes.Error, class) +} + +// FailErr is Fail with the error's own type as its class. +func FailErr(span trace.Span, err error) { + if err == nil { + return + } + Fail(span, TypeOf(err), err) +} + +// TypeOf names an error by class: a context error by its package name, +// anything else by the Go type of the innermost error in its chain. A +// joined error reports its own type. +func TypeOf(err error) string { + switch { + case err == nil: + return "" + case errors.Is(err, context.DeadlineExceeded): + return "context.DeadlineExceeded" + case errors.Is(err, context.Canceled): + return "context.Canceled" + } + for { + next := errors.Unwrap(err) + if next == nil { + return fmt.Sprintf("%T", err) + } + err = next + } +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..2efc2e2d --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,134 @@ +package telemetry_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/forge" + + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" +) + +func TestAuthMethodFoldsToTheBoundedSet(t *testing.T) { + for raw, want := range map[string]string{ + "password": "password", + "password+mfa": "password", + "passkey": "passkey", + "magiclink": "magic_link", + "phone": "phone", + "social:github": "social", + "social:github+mfa": "social", + "sso:acme-okta": "sso", + "sso:acme-okta+mfa": "sso", + "email_verification": "email_verification", + "my-custom-plugin": "other", + "": "other", + } { + assert.Equal(t, want, telemetry.AuthMethod(raw), raw) + } +} + +func TestBoundedKeepsConstantsAndFoldsFreeText(t *testing.T) { + assert.Equal(t, "auth.signin", telemetry.Bounded("auth.signin")) + assert.Equal(t, "org.member.add", telemetry.Bounded("org.member.add")) + assert.Equal(t, "", telemetry.Bounded("")) + assert.Equal(t, "other", telemetry.Bounded("GET /v1/users/ausr_01jf0000000000000000000000")) + assert.Equal(t, "other", telemetry.Bounded("alice@example.com")) + assert.Equal(t, "other", telemetry.Bounded("Auth.SignIn")) +} + +// Fail puts the error's type on the span and never its text. +func TestFailKeepsErrorTextOffTheSpan(t *testing.T) { + rec := telemetrytest.Spans(t) + _, span := telemetry.Start(context.Background(), "op") + err := fmt.Errorf("lookup alice@example.com: %w", errors.New("password hunter2 rejected")) + telemetry.FailErr(span, err) + span.End() + + got := telemetrytest.Authsome(rec) + require.Len(t, got, 1) + assert.Equal(t, codes.Error, got[0].Status().Code) + assert.Equal(t, "*errors.errorString", got[0].Status().Description) + text := telemetrytest.Text(got[0]) + assert.NotContains(t, text, "alice@example.com") + assert.NotContains(t, text, "hunter2") +} + +func TestDetachDropsTheSpanButKeepsCancellation(t *testing.T) { + rec := telemetrytest.Spans(t) + ctx, cancel := context.WithCancel(context.Background()) + ctx, span := telemetry.Start(ctx, "boot") + detached := telemetry.Detach(ctx) + span.End() + + assert.False(t, trace.SpanContextFromContext(detached).IsValid()) + cancel() + assert.Error(t, detached.Err(), "cancellation still reaches the detached ctx") + + _, root := telemetry.Start(detached, "work") + root.End() + work := telemetrytest.Named(telemetrytest.Authsome(rec), "work") + require.Len(t, work, 1) + assert.False(t, work[0].Parent().IsValid(), "a span under a detached ctx is a root") +} + +func TestSignInAttemptCountsOnce(t *testing.T) { + reg, scrape := telemetrytest.Registry(t) + m := telemetry.NewMetrics(reg) + + // Handed off: IssueSession counts it, so Done records nothing. + a := m.StartSignIn(telemetry.MethodMagicLink) + a.HandOff() + a.Done(errors.New("ignored")) + + m.StartSignIn(telemetry.MethodMagicLink).Done(forge.Unauthorized("invalid or expired magic link")) + m.StartSignIn(telemetry.MethodPhone).Done(forge.NewHTTPError(http.StatusTooManyRequests, "slow down")) + m.StartSignIn(telemetry.MethodSSO).Done(errors.New("store down")) + + refused := m.StartSignIn(telemetry.MethodSocial) + refused.Refuse(telemetry.SignInDenied) + refused.Done(nil) + + var nilMetrics *telemetry.Metrics + nilMetrics.StartSignIn(telemetry.MethodPasskey).Done(nil) + + assert.ElementsMatch(t, []string{ + `forge_authsome_signin_attempts_total{method="magic_link",result="invalid"}`, + `forge_authsome_signin_attempts_total{method="phone",result="rate_limited"}`, + `forge_authsome_signin_attempts_total{method="sso",result="error"}`, + `forge_authsome_signin_attempts_total{method="social",result="denied"}`, + }, telemetrytest.Series(scrape(), "forge_authsome_signin_")) +} + +// A nil *Metrics is what an engine without a sink holds; nothing on it may +// panic. +func TestNilMetricsRecordNothing(t *testing.T) { + var m *telemetry.Metrics + assert.NotPanics(t, func() { + m.SignIn("password", "ok") + m.MFAVerification("signin", "totp", "ok") + m.Refresh("ok") + m.PasswordHash("hash", "bcrypt", 0) + m.APIKeyAuth("ok") + m.OAuth2Token("client_credentials", "ok") + m.ProviderRequest("email", "ok", 0) + m.RetentionRowsDeleted("sessions", 1) + m.RetentionSweep("ok") + m.RetentionJob("hubspot", "done") + m.ControlDegraded("ratelimit") + m.Throttled("route") + m.RateLimiterError("route") + m.AuditRecordFailed() + m.Event("auth.signin", "session", "success") + }) + assert.Nil(t, telemetry.From(struct{}{})) +} diff --git a/internal/telemetry/telemetrytest/telemetrytest.go b/internal/telemetry/telemetrytest/telemetrytest.go new file mode 100644 index 00000000..522775c0 --- /dev/null +++ b/internal/telemetry/telemetrytest/telemetrytest.go @@ -0,0 +1,159 @@ +// Package telemetrytest holds the helpers authsome's telemetry tests share: +// a real Forge metrics registry with its Prometheus scrape, and a span +// recorder installed as the global tracer provider. Only tests import it. +package telemetrytest + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + + "github.com/xraph/forge" + gometrics "github.com/xraph/go-utils/metrics" + + "github.com/xraph/authsome/internal/telemetry" +) + +// Registry returns a Forge app's metrics registry and a func that scrapes +// it the way /_/metrics does. +// +// forge.New installs its own global tracer provider and propagator. This +// puts back whatever was there before, so a recorder from Spans keeps +// receiving spans whichever of the two a test calls first. +func Registry(t testing.TB) (reg gometrics.Metrics, scrape func() string) { + t.Helper() + prevTP, prevProp := otel.GetTracerProvider(), otel.GetTextMapPropagator() + app := forge.New(forge.WithAppName("authsome-telemetry-test"), forge.WithAppLogger(forge.NewNoopLogger())) + otel.SetTracerProvider(prevTP) + otel.SetTextMapPropagator(prevProp) + m := app.Metrics() + scrape = func() string { + t.Helper() + p, ok := m.(interface{ PrometheusHandler() http.Handler }) + if !ok { + t.Fatalf("app metrics %T have no Prometheus handler", m) + } + rr := httptest.NewRecorder() + p.PrometheusHandler().ServeHTTP(rr, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/_/metrics", http.NoBody)) + body, err := io.ReadAll(rr.Body) + if err != nil { + t.Fatalf("read scrape: %v", err) + } + return string(body) + } + return m, scrape +} + +// Series returns the sample lines in a scrape whose metric name starts with +// prefix, without their values and without histogram bucket lines. +func Series(body, prefix string) []string { + var out []string + for line := range strings.SplitSeq(body, "\n") { + if !strings.HasPrefix(line, prefix) || strings.Contains(line, "_bucket{") { + continue + } + if i := strings.LastIndex(line, " "); i > 0 { + line = line[:i] + } + out = append(out, line) + } + return out +} + +// Spans makes a SpanRecorder the global tracer provider for the rest of +// the test and puts the previous provider back afterwards. Tests that use +// it must not run in parallel. +func Spans(t testing.TB) *tracetest.SpanRecorder { + t.Helper() + rec := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(rec)) + prev := otel.GetTracerProvider() + otel.SetTracerProvider(tp) + t.Cleanup(func() { + otel.SetTracerProvider(prev) + if err := tp.Shutdown(context.Background()); err != nil { + t.Errorf("shut down tracer provider: %v", err) + } + }) + return rec +} + +// Authsome returns the ended spans authsome started, in the order they +// ended. +func Authsome(rec *tracetest.SpanRecorder) []sdktrace.ReadOnlySpan { + return OnlyAuthsome(rec.Ended()) +} + +// OnlyAuthsome keeps the spans authsome started. +func OnlyAuthsome(spans []sdktrace.ReadOnlySpan) []sdktrace.ReadOnlySpan { + var out []sdktrace.ReadOnlySpan + for _, s := range spans { + if s.InstrumentationScope().Name == telemetry.ScopeName { + out = append(out, s) + } + } + return out +} + +// Names returns the names of spans. +func Names(spans []sdktrace.ReadOnlySpan) []string { + out := make([]string, len(spans)) + for i, s := range spans { + out[i] = s.Name() + } + return out +} + +// Named returns the spans called name. +func Named(spans []sdktrace.ReadOnlySpan, name string) []sdktrace.ReadOnlySpan { + var out []sdktrace.ReadOnlySpan + for _, s := range spans { + if s.Name() == name { + out = append(out, s) + } + } + return out +} + +// Attrs returns a span's attributes as strings keyed by attribute name. +func Attrs(s sdktrace.ReadOnlySpan) map[string]string { + out := make(map[string]string, len(s.Attributes())) + for _, kv := range s.Attributes() { + out[string(kv.Key)] = kv.Value.String() + } + return out +} + +// Text flattens everything a span carries that an exporter would ship +// (name, attributes, events and their attributes, status, links) into one +// string, for tests that assert a value appears nowhere on it. +func Text(s sdktrace.ReadOnlySpan) string { + var b strings.Builder + b.WriteString(s.Name()) + for _, kv := range s.Attributes() { + b.WriteString("\n" + string(kv.Key) + "=" + kv.Value.String()) + } + for _, ev := range s.Events() { + b.WriteString("\nevent " + ev.Name) + for _, kv := range ev.Attributes { + b.WriteString(" " + string(kv.Key) + "=" + kv.Value.String()) + } + } + b.WriteString("\nstatus " + s.Status().Code.String() + " " + s.Status().Description) + for _, l := range s.Links() { + for _, kv := range l.Attributes { + b.WriteString("\nlink " + string(kv.Key) + "=" + kv.Value.String()) + } + } + for _, kv := range s.Resource().Attributes() { + b.WriteString("\nresource " + string(kv.Key) + "=" + kv.Value.String()) + } + return b.String() +} diff --git a/internal/telemetry/values.go b/internal/telemetry/values.go new file mode 100644 index 00000000..70df410f --- /dev/null +++ b/internal/telemetry/values.go @@ -0,0 +1,246 @@ +package telemetry + +import "strings" + +// Values of the method label on authsome_signin_attempts_total and of +// authsome.auth.method on the issue session span. AuthMethod folds every +// raw method string into one of these. +const ( + MethodPassword = "password" + MethodPasskey = "passkey" + MethodSocial = "social" + MethodSSO = "sso" + MethodMagicLink = "magic_link" + MethodPhone = "phone" + MethodEmailVerification = "email_verification" + MethodOther = "other" +) + +// AuthMethod folds the raw auth method an IssueSessionRequest carries into +// the bounded set above. "social:github" and "sso:acme-okta" lose the part +// after the colon, which names a provider or an SSO connection, and a +// "+mfa" suffix is dropped because the second factor has its own counter. +// Anything unrecognised, including a custom plugin's own method name, is +// "other". +func AuthMethod(raw string) string { + raw = strings.TrimSuffix(raw, "+mfa") + if i := strings.IndexByte(raw, ':'); i >= 0 { + raw = raw[:i] + } + switch raw { + case "password": + return MethodPassword + case "passkey": + return MethodPasskey + case "social": + return MethodSocial + case "sso": + return MethodSSO + case "magiclink", "magic_link": + return MethodMagicLink + case "phone": + return MethodPhone + case "email_verification": + return MethodEmailVerification + default: + return MethodOther + } +} + +// Values of the result label on authsome_signin_attempts_total. +// +// SignInInvalid covers a wrong password and an identifier nobody holds +// alike. Nothing authsome records tells those two apart. +const ( + SignInOK = "ok" + SignInInvalid = "invalid" + SignInLocked = "locked" + SignInBanned = "banned" + SignInUnverified = "unverified" + SignInPasswordExpired = "password_expired" + SignInMFARequired = "mfa_required" + SignInRateLimited = "rate_limited" + SignInDenied = "denied" + SignInError = "error" +) + +// Values of the flow, method and result labels on +// authsome_mfa_verifications_total. +const ( + MFAFlowSignIn = "signin" + MFAFlowVerify = "verify" + + MFAMethodTOTP = "totp" + MFAMethodSMS = "sms" + MFAMethodRecovery = "recovery" + + MFAOK = "ok" + MFAInvalid = "invalid" + MFAReplayed = "replayed" + MFAExpired = "expired" + MFAExhausted = "exhausted" + MFAError = "error" +) + +// Values of the result label on authsome_sessions_refreshed_total and of +// authsome.result on the refresh session span. +const ( + RefreshOK = "ok" + RefreshInvalid = "invalid" + RefreshExpired = "expired" + RefreshBanned = "banned" + RefreshReplayDetected = "replay_detected" + RefreshDPoPRejected = "dpop_rejected" + RefreshError = "error" +) + +// Values of authsome.refresh.via on the refresh session span: which +// credential the caller presented. +const ( + RefreshViaRefreshToken = "refresh_token" + RefreshViaClient = "oauth2_client" + RefreshViaSessionToken = "session_token" + RefreshViaSessionID = "session_id" +) + +// Values of the operation and algorithm labels on +// authsome_password_hash_duration_seconds. +const ( + PasswordHash = "hash" + PasswordVerify = "verify" + + AlgorithmArgon2id = "argon2id" + AlgorithmBcrypt = "bcrypt" +) + +// Values of the result label on authsome_apikey_authentications_total and +// of authsome.result on the authenticate api key span. +// +// APIKeyInvalid covers a malformed key, an unknown prefix and a wrong +// secret alike, so the counter cannot be used to learn which prefixes +// exist. +const ( + APIKeyOK = "ok" + APIKeyInvalid = "invalid" + APIKeyExpired = "expired" + APIKeyThrottled = "throttled" + APIKeyDenied = "denied" + APIKeyError = "error" +) + +// Values of the grant_type and result labels on +// authsome_oauth2_tokens_requested_total. +const ( + GrantAuthorizationCode = "authorization_code" + GrantRefreshToken = "refresh_token" + GrantClientCredentials = "client_credentials" + GrantDeviceCode = "device_code" + GrantTokenExchange = "token_exchange" + GrantOther = "other" + + // OAuth2Rejected is any other refusal: a bad, used or expired code, a + // refresh token that failed, a malformed request, a bad DPoP proof. + // OAuth2Pending is a device-code poll answered authorization_pending + // or slow_down, which is the flow working as designed. + OAuth2OK = "ok" + OAuth2InvalidClient = "invalid_client" + OAuth2Rejected = "rejected" + OAuth2Pending = "pending" + OAuth2Error = "error" +) + +// Values of the kind label on authsome_provider_request_duration_seconds +// and of authsome.provider.kind on the client spans. +const ( + ProviderSocial = "social" + ProviderOIDC = "oidc" + ProviderSAMLMetadata = "saml_metadata" + ProviderJWKS = "jwks" + ProviderSMS = "sms" + ProviderEmail = "email" + ProviderCaptcha = "captcha" + ProviderBreach = "breach" + ProviderCRM = "crm" + ProviderWebhook = "webhook" +) + +// Values of the result label on authsome_provider_request_duration_seconds. +const ( + ProviderOK = "ok" + Provider4xx = "4xx" + Provider5xx = "5xx" + ProviderTimeout = "timeout" + ProviderCanceled = "canceled" + ProviderError = "error" +) + +// Values of the kind label on authsome_retention_rows_deleted_total and of +// authsome.retention.kind on the sweep failed event. +const ( + RetentionSessions = "sessions" + RetentionVerifications = "verifications" + RetentionPasswordResets = "password_resets" + RetentionRevokedRefreshTokens = "revoked_refresh_tokens" + RetentionCeremonyState = "ceremony_state" + RetentionPlugins = "plugins" +) + +// Values of the result label on authsome_retention_sweeps_attempted_total. +const ( + SweepOK = "ok" + SweepError = "error" + SweepSkipped = "skipped" +) + +// Values of the provider and outcome labels on +// authsome_retention_jobs_processed_total. +const ( + CRMHubSpot = "hubspot" + CRMGeneric = "generic" + CRMOther = "other" + + JobDone = "done" + JobRetry = "retry" + JobDead = "dead" + JobDeferred = "deferred" + JobSuppressed = "suppressed" +) + +// Values of the control label on authsome_controls_degraded_total: a +// security control that could not run and let the request through. +const ( + ControlRateLimit = "ratelimit" + ControlIdentifierLimit = "identifier_limit" + ControlSettings = "settings" + ControlPasswordHistory = "password_history" + ControlBreachCheck = "breach_check" +) + +// Values of the limiter label on authsome_requests_throttled_total and +// authsome_ratelimiter_errors_total. +const ( + LimiterRoute = "route" + LimiterIdentifier = "identifier" +) + +// Bounded folds a hook action, resource or outcome into a label value. The +// built-in values are dotted lower-case constants; anything that is not +// (too long, or carrying characters a constant never would) is "other", so +// a plugin that builds one from input cannot grow the series without bound. +func Bounded(s string) string { + if s == "" { + return "" + } + if len(s) > 64 { + return "other" + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '.', c == '_', c == '-', c == ':': + default: + return "other" + } + } + return s +} diff --git a/middleware/auth.go b/middleware/auth.go index c0ab2c3f..53843c6e 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -134,6 +134,11 @@ type JWTValidator interface { // rather than the global one. type SessionExistsChecker func(appID, sessionID string) (*session.Session, error) +// SessionExistsContextChecker is SessionExistsChecker with the request's +// context, so the store lookup carries the request's values (its trace +// span among them). +type SessionExistsContextChecker func(ctx context.Context, appID, sessionID string) (*session.Session, error) + // CookieNameResolver returns the session cookie name for the current context. // When nil, the default "authsome_session_token" is used. type CookieNameResolver func(ctx context.Context) string @@ -201,6 +206,10 @@ type SessionBindingConfig struct { // DPoPAudit records a DPoP failure. Nil disables recording. DPoPAudit func(ctx context.Context, action string, md map[string]string) + + // JWTSessionContextChecker is JWTSessionChecker taking the request's + // context. When both are set this one is used. + JWTSessionContextChecker SessionExistsContextChecker } // AuthMiddleware extracts the session token from the Authorization header, @@ -554,8 +563,14 @@ func tryJWTAuth( // When a session checker is configured, cross-check the JWT's session ID // against the store. This enables revocation and IP/device binding for JWTs. // The checker returns (nil, nil) when the feature is disabled via settings. - if bindCfg.JWTSessionChecker != nil && claims.SessionID != "" { - sess, sessErr := bindCfg.JWTSessionChecker(claims.AppID, claims.SessionID) + if (bindCfg.JWTSessionContextChecker != nil || bindCfg.JWTSessionChecker != nil) && claims.SessionID != "" { + var sess *session.Session + var sessErr error + if bindCfg.JWTSessionContextChecker != nil { + sess, sessErr = bindCfg.JWTSessionContextChecker(ctx.Context(), claims.AppID, claims.SessionID) + } else { + sess, sessErr = bindCfg.JWTSessionChecker(claims.AppID, claims.SessionID) + } if sessErr != nil { logger.Debug("auth middleware: JWT session not found in store (revoked?)", log.String("session_id", claims.SessionID), diff --git a/middleware/metrics.go b/middleware/metrics.go deleted file mode 100644 index 838e1da0..00000000 --- a/middleware/metrics.go +++ /dev/null @@ -1,90 +0,0 @@ -package middleware - -import ( - "net/http" - "regexp" - "strconv" - "time" - - "github.com/xraph/forge" - - "github.com/xraph/authsome/bridge" -) - -// idSegment matches a TypeID path segment (prefix, underscore, 26 base32 -// characters) so a route label is the route, not one label per user. -var idSegment = regexp.MustCompile(`^[a-z]+_[0-9a-hjkmnp-tv-z]{26}$`) - -// RouteLabel returns the request path with id segments replaced by :id. -func RouteLabel(path string) string { - out := make([]byte, 0, len(path)) - start := 0 - for i := 0; i <= len(path); i++ { - if i < len(path) && path[i] != '/' { - continue - } - seg := path[start:i] - if idSegment.MatchString(seg) { - seg = ":id" - } - out = append(out, seg...) - if i < len(path) { - out = append(out, '/') - } - start = i + 1 - } - return string(out) -} - -// statusWriter remembers the status a handler wrote. Forge's own writer -// does not expose it, and forge middleware never sees a handler's error -// (the router answers it first), so the only place the outcome is visible -// is on the wire. -type statusWriter struct { - http.ResponseWriter - status int -} - -func (w *statusWriter) WriteHeader(code int) { - if w.status == 0 { - w.status = code - } - w.ResponseWriter.WriteHeader(code) -} - -func (w *statusWriter) Write(b []byte) (int, error) { - if w.status == 0 { - w.status = http.StatusOK - } - return w.ResponseWriter.Write(b) -} - -// Unwrap lets http.ResponseController reach the writer underneath. -func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } - -// Metrics records one event per request: action http.request, the method -// and route as the resource, the status code as the outcome, and the -// handler's latency. It runs at the net/http level, converted into a forge -// middleware, so it can see the status the handler wrote. -func Metrics(c bridge.MetricsCollector) forge.Middleware { - if c == nil { - return func(next forge.Handler) forge.Handler { return next } - } - pure := forge.PureMiddleware(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - sw := &statusWriter{ResponseWriter: w} - next.ServeHTTP(sw, r) - status := sw.status - if status == 0 { - status = http.StatusOK - } - tenant := "" - if appID, ok := AppIDFrom(r.Context()); ok { - tenant = appID.String() - } - c.RecordEvent("http.request", r.Method+" "+RouteLabel(r.URL.Path), strconv.Itoa(status), tenant, time.Since(start)) - }) - }) - return pure.ToMiddleware() -} diff --git a/middleware/metrics_test.go b/middleware/metrics_test.go deleted file mode 100644 index 76920104..00000000 --- a/middleware/metrics_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package middleware_test - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/xraph/forge" - - "github.com/xraph/authsome/middleware" -) - -type recordingCollector struct { - mu sync.Mutex - events []string -} - -func (r *recordingCollector) RecordEvent(action, resource, outcome, _ string, d time.Duration) { - r.mu.Lock() - defer r.mu.Unlock() - if d < 0 { - panic("negative duration") - } - r.events = append(r.events, action+"|"+resource+"|"+outcome) -} -func (*recordingCollector) IncrementGauge(string, string, int) {} -func (*recordingCollector) IncrementCounter(string, string) {} - -func TestRouteLabel(t *testing.T) { - assert.Equal(t, "/v1/webhooks/:id/rotate-secret", middleware.RouteLabel("/v1/webhooks/awhk_01jf0000000000000000000000/rotate-secret")) - assert.Equal(t, "/v1/sessions", middleware.RouteLabel("/v1/sessions")) - assert.Equal(t, "/", middleware.RouteLabel("/")) -} - -func TestMetricsMiddlewareRecordsRouteAndStatus(t *testing.T) { - c := &recordingCollector{} - router := forge.NewRouter() - router.Use(middleware.Metrics(c)) - router.GET("/v1/users/:userId", func(ctx forge.Context) error { return ctx.NoContent(http.StatusOK) }) - router.GET("/v1/boom", func(forge.Context) error { return forge.NewHTTPError(http.StatusTeapot, "no") }) - router.GET("/v1/crash", func(forge.Context) error { return errors.New("unexpected") }) - - for _, p := range []string{"/v1/users/ausr_01jf0000000000000000000000", "/v1/boom", "/v1/crash"} { - req := httptest.NewRequestWithContext(context.Background(), "GET", p, nil) - router.ServeHTTP(httptest.NewRecorder(), req) - } - require.Len(t, c.events, 3) - assert.Equal(t, "http.request|GET /v1/users/:id|200", c.events[0]) - assert.Equal(t, "http.request|GET /v1/boom|418", c.events[1]) - assert.Equal(t, "http.request|GET /v1/crash|500", c.events[2]) -} diff --git a/option.go b/option.go index 7e18e583..2e057336 100644 --- a/option.go +++ b/option.go @@ -4,6 +4,7 @@ import ( "time" log "github.com/xraph/go-utils/log" + gometrics "github.com/xraph/go-utils/metrics" "github.com/xraph/authsome/account" "github.com/xraph/authsome/appsessionconfig" @@ -13,6 +14,7 @@ import ( "github.com/xraph/authsome/bridge/wardenadapter" "github.com/xraph/authsome/ceremony" "github.com/xraph/authsome/dpop" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/lockout" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/ratelimit" @@ -264,13 +266,35 @@ func WithLockoutConfig(cfg LockoutConfig) Option { } } -// WithMetrics sets the metrics collector for observability. +// WithMetrics sets a bridge.MetricsCollector. The engine hands it every +// hook-bus event through RecordEvent and its operational signals +// (audit.record.failed, auth.lockout, control.degraded, and so on) through +// IncrementCounter. The tenant argument it receives is the app ID, which is +// unbounded: don't turn it into a metric label. +// +// In a Forge app you don't need this. The extension passes the app's +// registry to WithMetricsSink, which records authsome's own metric +// families; see that option. func WithMetrics(m bridge.MetricsCollector) Option { return func(e *Engine) { e.metrics = m } } +// WithMetricsSink records authsome's metric families (sign-in attempts, +// MFA checks, refreshes, password hashing time, API key and OAuth2 token +// requests, third-party call latency, retention and the degraded-control +// counters) into m. A Forge app's registry, fapp.Metrics(), is one; the +// extension passes it in for you. Nil turns the families off. +// +// Every family carries bounded labels only. None is labelled by app, +// tenant, user, provider name or route. +func WithMetricsSink(m gometrics.Metrics) Option { + return func(e *Engine) { + e.tel = telemetry.NewMetrics(m) + } +} + // WithPrincipalAuthTTL sets how long a machine caller's allow verdict is // cached, keyed on credential and source IP together. Defaults to five // minutes. Denials are never cached regardless of this setting: see diff --git a/plugin/registry.go b/plugin/registry.go index 69ff19e5..f02b798e 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -351,7 +351,9 @@ func (r *Registry) EmitOnShutdown(ctx context.Context) { // EmitBeforeSignUp notifies all plugins that implement BeforeSignUp. func (r *Registry) EmitBeforeSignUp(ctx context.Context, req *account.SignUpRequest) error { for _, e := range r.beforeSignUp { - if err := e.hook.OnBeforeSignUp(ctx, req); err != nil { + if err := runHook(ctx, hookBeforeSignUp, e.name, true, func(ctx context.Context) error { + return e.hook.OnBeforeSignUp(ctx, req) + }); err != nil { return err // Before hooks can abort the operation } } @@ -361,7 +363,9 @@ func (r *Registry) EmitBeforeSignUp(ctx context.Context, req *account.SignUpRequ // EmitAfterSignUp notifies all plugins that implement AfterSignUp. func (r *Registry) EmitAfterSignUp(ctx context.Context, u *user.User, s *session.Session) { for _, e := range r.afterSignUp { - if err := e.hook.OnAfterSignUp(ctx, u, s); err != nil { + if err := runHook(ctx, hookAfterSignUp, e.name, false, func(ctx context.Context) error { + return e.hook.OnAfterSignUp(ctx, u, s) + }); err != nil { r.logHookError("OnAfterSignUp", e.name, err) } } @@ -370,7 +374,9 @@ func (r *Registry) EmitAfterSignUp(ctx context.Context, u *user.User, s *session // EmitBeforeSignIn notifies all plugins that implement BeforeSignIn. func (r *Registry) EmitBeforeSignIn(ctx context.Context, req *account.SignInRequest) error { for _, e := range r.beforeSignIn { - if err := e.hook.OnBeforeSignIn(ctx, req); err != nil { + if err := runHook(ctx, hookBeforeSignIn, e.name, true, func(ctx context.Context) error { + return e.hook.OnBeforeSignIn(ctx, req) + }); err != nil { return err // Before hooks can abort the operation } } @@ -380,7 +386,9 @@ func (r *Registry) EmitBeforeSignIn(ctx context.Context, req *account.SignInRequ // EmitAfterSignIn notifies all plugins that implement AfterSignIn. func (r *Registry) EmitAfterSignIn(ctx context.Context, u *user.User, s *session.Session) { for _, e := range r.afterSignIn { - if err := e.hook.OnAfterSignIn(ctx, u, s); err != nil { + if err := runHook(ctx, hookAfterSignIn, e.name, false, func(ctx context.Context) error { + return e.hook.OnAfterSignIn(ctx, u, s) + }); err != nil { r.logHookError("OnAfterSignIn", e.name, err) } } @@ -409,7 +417,9 @@ func (r *Registry) EmitAfterSignOut(ctx context.Context, sessionID id.SessionID) // BeforePrincipalAuth, stopping at the first error. func (r *Registry) EmitBeforePrincipalAuth(ctx context.Context, a *principal.AuthAttempt) error { for _, e := range r.beforePrincipalAuth { - if err := e.hook.OnBeforePrincipalAuth(ctx, a); err != nil { + if err := runHook(ctx, hookBeforePrincipalAuth, e.name, true, func(ctx context.Context) error { + return e.hook.OnBeforePrincipalAuth(ctx, a) + }); err != nil { return err // Before hooks can abort the operation } } @@ -420,7 +430,9 @@ func (r *Registry) EmitBeforePrincipalAuth(ctx context.Context, a *principal.Aut // AfterPrincipalAuth. Errors are logged, never propagated. func (r *Registry) EmitAfterPrincipalAuth(ctx context.Context, a *principal.AuthAttempt, s *session.Session) { for _, e := range r.afterPrincipalAuth { - if err := e.hook.OnAfterPrincipalAuth(ctx, a, s); err != nil { + if err := runHook(ctx, hookAfterPrincipalAuth, e.name, false, func(ctx context.Context) error { + return e.hook.OnAfterPrincipalAuth(ctx, a, s) + }); err != nil { r.logHookError("OnAfterPrincipalAuth", e.name, err) } } @@ -494,7 +506,9 @@ func (r *Registry) EmitAfterUserDelete(ctx context.Context, userID id.UserID) { // EmitBeforeSessionCreate notifies all plugins that implement BeforeSessionCreate. func (r *Registry) EmitBeforeSessionCreate(ctx context.Context, s *session.Session) error { for _, e := range r.beforeSessionCreate { - if err := e.hook.OnBeforeSessionCreate(ctx, s); err != nil { + if err := runHook(ctx, hookBeforeSessionCreate, e.name, true, func(ctx context.Context) error { + return e.hook.OnBeforeSessionCreate(ctx, s) + }); err != nil { return err } } @@ -504,7 +518,9 @@ func (r *Registry) EmitBeforeSessionCreate(ctx context.Context, s *session.Sessi // EmitAfterSessionCreate notifies all plugins that implement AfterSessionCreate. func (r *Registry) EmitAfterSessionCreate(ctx context.Context, s *session.Session) { for _, e := range r.afterSessionCreate { - if err := e.hook.OnAfterSessionCreate(ctx, s); err != nil { + if err := runHook(ctx, hookAfterSessionCreate, e.name, false, func(ctx context.Context) error { + return e.hook.OnAfterSessionCreate(ctx, s) + }); err != nil { r.logHookError("OnAfterSessionCreate", e.name, err) } } diff --git a/plugin/telemetry.go b/plugin/telemetry.go new file mode 100644 index 00000000..dfcb08ce --- /dev/null +++ b/plugin/telemetry.go @@ -0,0 +1,56 @@ +package plugin + +import ( + "context" + + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/internal/telemetry" +) + +// spanRunPluginHook is the span each plugin's auth hook runs in. +const spanRunPluginHook = "run plugin hook" + +// Values of authsome.plugin.hook on the run plugin hook span. +const ( + hookBeforeSignUp = "before_sign_up" + hookAfterSignUp = "after_sign_up" + hookBeforeSignIn = "before_sign_in" + hookAfterSignIn = "after_sign_in" + hookBeforePrincipalAuth = "before_principal_auth" + hookAfterPrincipalAuth = "after_principal_auth" + hookBeforeSessionCreate = "before_session_create" + hookAfterSessionCreate = "after_session_create" +) + +// Values of authsome.result on the run plugin hook span. +const ( + hookResultOK = "ok" + hookResultRefused = "refused" + hookResultError = "error" +) + +// runHook calls one plugin's hook inside a run plugin hook span named for +// the plugin and the hook, so a slow risk check or geo lookup shows up as +// itself in the sign-in trace. A before hook that returns an error refuses +// the operation, which is an answer, so the span says "refused" and is not +// marked failed. An after hook's error is a fault: the span is marked +// failed with the error's type and never its text. +func runHook(ctx context.Context, hook, pluginName string, before bool, fn func(context.Context) error) error { + ctx, span := telemetry.Start(ctx, spanRunPluginHook, trace.WithAttributes( + telemetry.AttrPluginName.String(pluginName), + telemetry.AttrPluginHook.String(hook), + )) + defer span.End() + err := fn(ctx) + switch { + case err == nil: + span.SetAttributes(telemetry.AttrResult.String(hookResultOK)) + case before: + span.SetAttributes(telemetry.AttrResult.String(hookResultRefused)) + default: + span.SetAttributes(telemetry.AttrResult.String(hookResultError)) + telemetry.FailErr(span, err) + } + return err +} diff --git a/plugin_ratelimit.go b/plugin_ratelimit.go index 774b5151..e5fc2169 100644 --- a/plugin_ratelimit.go +++ b/plugin_ratelimit.go @@ -5,6 +5,7 @@ import ( "github.com/xraph/forge" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/ratelimit" ) @@ -58,11 +59,11 @@ func (e *Engine) RateLimitMiddlewareConfig(limit int) middleware.RateLimitConfig Window: cfg.Window(), FailOpen: cfg.FailOpen, OnError: func(err error) { - e.count("ratelimit.error", "") + e.limiterError(telemetry.LimiterRoute, "") e.logger.Warn("authsome: rate limiter error", log.String("error", err.Error())) }, - OnReject: func() { e.count("ratelimit.rejected", "") }, - OnFailOpen: func() { e.count("control.degraded", "") }, + OnReject: func() { e.throttled(telemetry.LimiterRoute, "") }, + OnFailOpen: func() { e.controlDegraded(telemetry.ControlRateLimit, "") }, } } diff --git a/plugins/apikey/plugin.go b/plugins/apikey/plugin.go index 1b0b9e78..d6c4f65a 100644 --- a/plugins/apikey/plugin.go +++ b/plugins/apikey/plugin.go @@ -20,6 +20,7 @@ import ( "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/principal" @@ -215,6 +216,7 @@ func (p *Plugin) Strategy() strategy.Strategy { failLimiter: p.failLimiter, failLimit: p.failLimit, failWindow: p.failWindow, + tel: telemetry.From(p.engine), } } @@ -615,8 +617,16 @@ type apikeyStrategy struct { failLimiter ratelimit.Limiter failLimit int failWindow time.Duration + + // tel counts every authentication. Nil records nothing. + tel *telemetry.Metrics } +// spanAuthenticateAPIKey is the span an API key authentication runs in. +// authsome reads keysmith's store directly here, so keysmith's own validate +// key span never fires for these requests; this one stands in for it. +const spanAuthenticateAPIKey = "authenticate api key" + func failureKey(r *http.Request) string { return "apikey-fail:" + middleware.ClientIP(r) } // exhausted reports whether the client address has already spent its @@ -635,12 +645,12 @@ func (s *apikeyStrategy) exhausted(ctx context.Context, r *http.Request) bool { } // refuse counts a failed authentication against the client address and -// returns err unchanged. -func (s *apikeyStrategy) refuse(ctx context.Context, r *http.Request, err error) (*strategy.Result, error) { +// returns err unchanged, with the result the counter records. +func (s *apikeyStrategy) refuse(ctx context.Context, r *http.Request, result string, err error) (*strategy.Result, string, error) { if s.failLimiter != nil && s.failLimit > 0 { _, _ = s.failLimiter.Allow(ctx, failureKey(r), s.failLimit, s.failWindow) //nolint:errcheck // counting only } - return nil, err + return nil, result, err } // serviceAccountState returns the service account's registered kind and @@ -672,22 +682,38 @@ func (s *apikeyStrategy) Name() string { return "apikey" } // Authenticate checks for an API key in the Authorization or X-API-Key header. // API keys are identified by their marker prefix (ask_, sk_*, pk_*). // Public keys (pk_*) are rejected — only secret keys can authenticate. +// +// Each authentication of a key-bearing request runs in an authenticate api +// key span and is counted by result. Neither carries the key, its prefix, +// the app or the caller; an unknown prefix and a wrong secret are both +// "invalid", so the counter can't be used to learn which prefixes exist. func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*strategy.Result, error) { rawKey := extractAPIKey(r) if rawKey == "" { return nil, strategy.NotApplicableError{} } + ctx, span := telemetry.Start(ctx, spanAuthenticateAPIKey) + defer span.End() + res, result, err := s.authenticate(ctx, r, rawKey) + span.SetAttributes(telemetry.AttrResult.String(result)) + if result == telemetry.APIKeyError { + telemetry.FailErr(span, err) + } + s.tel.APIKeyAuth(result) + return res, err +} +func (s *apikeyStrategy) authenticate(ctx context.Context, r *http.Request, rawKey string) (*strategy.Result, string, error) { // An address that has spent its failed-attempt budget is refused before // any lookup, so guessing keys costs the guesser the window, not the // store a query per guess. if s.exhausted(ctx, r) { - return nil, fmt.Errorf("apikey: too many failed attempts from this address") + return nil, telemetry.APIKeyThrottled, fmt.Errorf("apikey: too many failed attempts from this address") } // Reject public keys — they are not for authentication. if apikey.IsPublicKey(rawKey) { - return nil, fmt.Errorf("apikey: public keys (pk_*) cannot be used for authentication; use the secret key (sk_*) instead") + return nil, telemetry.APIKeyInvalid, fmt.Errorf("apikey: public keys (pk_*) cannot be used for authentication; use the secret key (sk_*) instead") } // Extract the app_id from query or X-App-ID header @@ -696,32 +722,32 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st appIDStr = r.URL.Query().Get("app_id") } if appIDStr == "" { - return nil, fmt.Errorf("apikey: X-App-ID header or app_id query parameter required") + return nil, telemetry.APIKeyInvalid, fmt.Errorf("apikey: X-App-ID header or app_id query parameter required") } appID, err := id.ParseAppID(appIDStr) if err != nil { - return nil, fmt.Errorf("apikey: invalid app_id: %w", err) + return nil, telemetry.APIKeyInvalid, fmt.Errorf("apikey: invalid app_id: %w", err) } // Extract prefix from the raw key for lookup prefix := extractPrefix(rawKey) if prefix == "" { - return nil, fmt.Errorf("apikey: invalid key format") + return nil, telemetry.APIKeyInvalid, fmt.Errorf("apikey: invalid key format") } key, err := s.store.GetAPIKeyByPrefix(ctx, appID, prefix) if err != nil { - return s.refuse(ctx, r, fmt.Errorf("apikey: key not found")) + return s.refuse(ctx, r, telemetry.APIKeyInvalid, fmt.Errorf("apikey: key not found")) } // Verify the raw key against stored hash if !apikey.VerifyKey(rawKey, key.KeyHash) { - return s.refuse(ctx, r, fmt.Errorf("apikey: invalid key")) + return s.refuse(ctx, r, telemetry.APIKeyInvalid, fmt.Errorf("apikey: invalid key")) } if !key.IsValid() { - return s.refuse(ctx, r, fmt.Errorf("apikey: key is revoked or expired")) + return s.refuse(ctx, r, telemetry.APIKeyExpired, fmt.Errorf("apikey: key is revoked or expired")) } // Record the use (best-effort, never failing auth): a single-column @@ -760,7 +786,7 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st subjectKind, active = s.serviceAccountState(ctx, key.ServiceAccountID, now) if !active { // Only a successfully resolved, active service account can use keys. - return s.refuse(ctx, r, fmt.Errorf("apikey: service account is not active")) + return s.refuse(ctx, r, telemetry.APIKeyDenied, fmt.Errorf("apikey: service account is not active")) } subject = principal.Ref{Kind: subjectKind, ID: key.ServiceAccountID.String()} } @@ -776,7 +802,7 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st } if s.gate != nil { if authErr := s.gate.Authorize(ctx, att); authErr != nil { - return nil, fmt.Errorf("apikey: %w", authErr) + return nil, telemetry.APIKeyDenied, fmt.Errorf("apikey: %w", authErr) } } @@ -794,12 +820,12 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st if s.gate != nil { s.gate.Observe(ctx, att, syntheticSession) } - return &strategy.Result{Session: syntheticSession}, nil + return &strategy.Result{Session: syntheticSession}, telemetry.APIKeyOK, nil } // Resolve the user associated with this API key. if s.resolveUser == nil { - return nil, fmt.Errorf("apikey: user resolver not configured") + return nil, telemetry.APIKeyError, fmt.Errorf("apikey: user resolver not configured") } // Reject keys that were created without a user binding (a bug // in an older dashboard handler used to mint these). Without an @@ -808,14 +834,14 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st // "authentication required" 401 — we'd rather state the // specific cause so operators can re-mint the key correctly. if key.UserID.IsNil() { - return nil, fmt.Errorf("apikey: key %s has no user binding (re-mint via /v1/keys or the dashboard)", key.KeyPrefix) + return nil, telemetry.APIKeyError, fmt.Errorf("apikey: key %s has no user binding (re-mint via /v1/keys or the dashboard)", key.KeyPrefix) } u, err := s.resolveUser(ctx, key.UserID.String()) if err != nil { - return nil, fmt.Errorf("apikey: resolve user: %w", err) + return nil, telemetry.APIKeyError, fmt.Errorf("apikey: resolve user: %w", err) } if u.IsBanned(now) { - return s.refuse(ctx, r, fmt.Errorf("apikey: user is banned")) + return s.refuse(ctx, r, telemetry.APIKeyDenied, fmt.Errorf("apikey: user is banned")) } // Create a synthetic (non-persisted) session for context propagation. @@ -831,7 +857,7 @@ func (s *apikeyStrategy) Authenticate(ctx context.Context, r *http.Request) (*st s.gate.Observe(ctx, att, syntheticSession) } - return &strategy.Result{User: u, Session: syntheticSession}, nil + return &strategy.Result{User: u, Session: syntheticSession}, telemetry.APIKeyOK, nil } // extractAPIKey extracts the API key from the request. diff --git a/plugins/magiclink/plugin.go b/plugins/magiclink/plugin.go index 713e69d1..702302e2 100644 --- a/plugins/magiclink/plugin.go +++ b/plugins/magiclink/plugin.go @@ -13,6 +13,7 @@ import ( "github.com/xraph/authsome/account" "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/session" @@ -308,7 +309,10 @@ func (p *Plugin) handleSend(ctx forge.Context, req *SendRequest) (*SendResponse, return &SendResponse{Status: "magic link sent"}, nil } -func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (*VerifyResponse, error) { +func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (_ *VerifyResponse, err error) { + attempt := telemetry.From(p.engine).StartSignIn(telemetry.MethodMagicLink) + defer func() { attempt.Done(err) }() + if req.Token == "" { return nil, forge.BadRequest("token required") } @@ -375,8 +379,12 @@ func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (*VerifyRes // magic-link route would be refused rather than bound. dpopJKT, bindErr := eng.DPoPBindingForRequest(ctx, v.AppID) if bindErr != nil { + if errors.Is(bindErr, authsome.ErrDPoPBindingRequired) { + attempt.Refuse(telemetry.SignInDenied) + } return nil, bindErr } + attempt.HandOff() result, issueErr := eng.IssueSession(ctx.Context(), &authsome.IssueSessionRequest{ User: u, AppID: v.AppID, diff --git a/plugins/mfa/plugin.go b/plugins/mfa/plugin.go index 97ad7dc6..dfe2dda3 100644 --- a/plugins/mfa/plugin.go +++ b/plugins/mfa/plugin.go @@ -19,6 +19,7 @@ import ( "github.com/xraph/authsome/ceremony" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/settings" @@ -80,6 +81,8 @@ type Plugin struct { // revoker ends the user's other sessions after a factor changes. Nil in // minimal test wiring, in which case nothing is revoked. revoker sessionRevoker + // tel counts every code check. Nil when the engine records no metrics. + tel *telemetry.Metrics } // DeclareSettings implements plugin.SettingsProvider. @@ -119,6 +122,7 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { } p.engine = engine p.revoker = engine + p.tel = telemetry.From(engine) // Build a persistent, at-rest-encrypted enrollment store when one hasn't // been injected (tests inject via SetStore). TOTP/SMS secrets are wrapped @@ -495,11 +499,14 @@ func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyMFARequest) (*Verify ok2, step := ValidateTOTPStep(req.Code, enrollment.Secret) if !ok2 { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodTOTP, telemetry.MFAInvalid) return nil, forge.Unauthorized("invalid TOTP code") } if p.totpStepUsed(ctx.Context(), enrollment.ID, step) { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodTOTP, telemetry.MFAReplayed) return nil, forge.Unauthorized("TOTP code already used") } + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodTOTP, telemetry.MFAOK) // Mark as verified if this is the first successful verification if !enrollment.Verified { @@ -574,6 +581,7 @@ func (p *Plugin) handleChallenge(ctx forge.Context, req *ChallengeRequest) (*Cha payload, err := eng.LoadMFATicket(ctx.Context(), req.Ticket) if err != nil { + p.tel.MFAVerification(telemetry.MFAFlowSignIn, telemetry.MFAMethodTOTP, telemetry.MFAExpired) return nil, forge.Unauthorized("invalid or expired ticket") } userID := payload.UserID @@ -591,16 +599,21 @@ func (p *Plugin) handleChallenge(ctx forge.Context, req *ChallengeRequest) (*Cha // unavailable, which is this endpoint — before the fix they could only be // redeemed from an already-authenticated session, i.e. never when actually // needed. + // + // A code that matches neither counts as method=totp, which is what the + // endpoint reports back. method := "totp" ok2, step := ValidateTOTPStep(req.Code, enrollment.Secret) switch { case ok2: if p.totpStepUsed(ctx.Context(), enrollment.ID, step) { + p.tel.MFAVerification(telemetry.MFAFlowSignIn, telemetry.MFAMethodTOTP, telemetry.MFAReplayed) return nil, forge.Unauthorized("TOTP code already used") } default: used, recErr := p.consumeRecoveryCode(ctx.Context(), userID, req.Code) if recErr != nil { + p.tel.MFAVerification(telemetry.MFAFlowSignIn, telemetry.MFAMethodRecovery, telemetry.MFAError) return nil, recErr } if !used { @@ -615,12 +628,15 @@ func (p *Plugin) handleChallenge(ctx forge.Context, req *ChallengeRequest) (*Cha ) } if exhausted { + p.tel.MFAVerification(telemetry.MFAFlowSignIn, telemetry.MFAMethodTOTP, telemetry.MFAExhausted) return nil, forge.Unauthorized("too many invalid codes; sign in again") } + p.tel.MFAVerification(telemetry.MFAFlowSignIn, telemetry.MFAMethodTOTP, telemetry.MFAInvalid) return nil, forge.Unauthorized("invalid TOTP code") } method = "recovery" } + p.tel.MFAVerification(telemetry.MFAFlowSignIn, method, telemetry.MFAOK) // Code is good. Consume the ticket (single-use) before minting the // real session so a race that re-uses the ticket can't double-spend. @@ -854,6 +870,7 @@ func (p *Plugin) handleRecoveryVerify(ctx forge.Context, req *RecoveryVerifyRequ // Fetch all unused recovery codes codes, err := p.store.GetRecoveryCodes(ctx.Context(), userID) if err != nil { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodRecovery, telemetry.MFAError) return nil, middleware.InternalError(ctx, fmt.Errorf("failed to get recovery codes: %w", err)) } @@ -871,13 +888,16 @@ func (p *Plugin) handleRecoveryVerify(ctx forge.Context, req *RecoveryVerifyRequ } if matched == nil { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodRecovery, telemetry.MFAInvalid) return nil, forge.Unauthorized("invalid recovery code") } // Consume the matched code (one-time use) if err := p.store.ConsumeRecoveryCode(ctx.Context(), matched.ID); err != nil { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodRecovery, telemetry.MFAError) return nil, middleware.InternalError(ctx, fmt.Errorf("failed to consume recovery code: %w", err)) } + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodRecovery, telemetry.MFAOK) p.audit(ctx.Context(), hook.ActionMFARecoveryUsed, "mfa", matched.ID.String(), userID.String(), "", bridge.OutcomeSuccess) p.emitHook(ctx.Context(), hook.ActionMFARecoveryUsed, "mfa", matched.ID.String(), userID.String(), "") @@ -1051,17 +1071,21 @@ func (p *Plugin) handleSMSVerify(ctx forge.Context, req *SMSVerifyRequest) (*SMS challengeData, err := p.ceremonies.Get(ctx.Context(), "mfa:sms:"+userID.String()) if err != nil { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodSMS, telemetry.MFAExpired) return nil, forge.BadRequest("no pending SMS challenge; send a code first") } var challenge SMSChallenge if unmarshalErr := json.Unmarshal(challengeData, &challenge); unmarshalErr != nil { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodSMS, telemetry.MFAError) return nil, middleware.InternalError(ctx, fmt.Errorf("failed to parse challenge: %w", unmarshalErr)) } if !ValidateSMSCode(req.Code, &challenge) { + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodSMS, telemetry.MFAInvalid) return nil, forge.Unauthorized("invalid or expired SMS code") } + p.tel.MFAVerification(telemetry.MFAFlowVerify, telemetry.MFAMethodSMS, telemetry.MFAOK) // Remove the used challenge _ = p.ceremonies.Delete(ctx.Context(), "mfa:sms:"+userID.String()) //nolint:errcheck // best-effort cleanup diff --git a/plugins/oauth2provider/plugin.go b/plugins/oauth2provider/plugin.go index 8da9f32f..58828f05 100644 --- a/plugins/oauth2provider/plugin.go +++ b/plugins/oauth2provider/plugin.go @@ -27,6 +27,7 @@ import ( "github.com/xraph/authsome/ceremony" "github.com/xraph/authsome/dpop" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/ratelimit" @@ -1084,7 +1085,7 @@ func (p *Plugin) authenticateClient(ctx context.Context, clientID, clientSecret if clientSecret == "" { return nil, newOAuth2Error(http.StatusUnauthorized, "invalid_client", "client_secret required for confidential clients") } - if cmpErr := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(clientSecret)); cmpErr != nil { + if cmpErr := compareClientSecret(ctx, client.ClientSecret, clientSecret); cmpErr != nil { return nil, newOAuth2Error(http.StatusUnauthorized, "invalid_client", "invalid client_secret") } } @@ -1123,7 +1124,9 @@ func callerOwnsSession(caller, target *session.Session) bool { // returned error into a written response before any enclosing middleware's // next() call sees it, the same reason handleUserInfo below sets its own // header instead of relying on that middleware. -func (p *Plugin) handleToken(ctx forge.Context, req *TokenRequest) (*TokenResponse, error) { +func (p *Plugin) handleToken(ctx forge.Context, req *TokenRequest) (_ *TokenResponse, err error) { + defer func() { telemetry.From(p.engine).OAuth2Token(grantLabel(req.GrantType), tokenResult(err)) }() + // Token responses carry credentials and must never be cached by a // browser or an intermediary (RFC 6749 section 5.1). ctx.SetHeader("Cache-Control", "no-store") @@ -1249,7 +1252,7 @@ func (p *Plugin) handleAuthorizationCodeGrant(ctx forge.Context, req *TokenReque if req.ClientSecret == "" { return nil, forge.Unauthorized("client_secret required for confidential clients") } - if cmpErr := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(req.ClientSecret)); cmpErr != nil { + if cmpErr := compareClientSecret(ctx.Context(), client.ClientSecret, req.ClientSecret); cmpErr != nil { return nil, forge.Unauthorized("invalid client_secret") } } @@ -1329,7 +1332,7 @@ func (p *Plugin) handleClientCredentialsGrant(ctx forge.Context, req *TokenReque return nil, forge.BadRequest("client is not registered for the client_credentials grant") } - if cmpErr := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(req.ClientSecret)); cmpErr != nil { + if cmpErr := compareClientSecret(ctx.Context(), client.ClientSecret, req.ClientSecret); cmpErr != nil { return nil, forge.Unauthorized("invalid client_secret") } diff --git a/plugins/oauth2provider/telemetry.go b/plugins/oauth2provider/telemetry.go new file mode 100644 index 00000000..537dfbed --- /dev/null +++ b/plugins/oauth2provider/telemetry.go @@ -0,0 +1,73 @@ +package oauth2provider + +import ( + "context" + "errors" + "net/http" + + "golang.org/x/crypto/bcrypt" + + "github.com/xraph/authsome/internal/telemetry" +) + +// spanVerifyClientSecret is the span a confidential client's bcrypt secret +// check runs in. It is the expensive part of the token endpoint. +const spanVerifyClientSecret = "verify client secret" + +// grantLabel folds a grant_type into the bounded grant_type label. The +// value arrives unchecked from the request, so anything unknown is "other". +func grantLabel(grantType string) string { + switch grantType { + case "authorization_code": + return telemetry.GrantAuthorizationCode + case "refresh_token": + return telemetry.GrantRefreshToken + case "client_credentials": + return telemetry.GrantClientCredentials + case "urn:ietf:params:oauth:grant-type:device_code": + return telemetry.GrantDeviceCode + case tokenExchangeGrantType: + return telemetry.GrantTokenExchange + default: + return telemetry.GrantOther + } +} + +// tokenResult sorts the token endpoint's error into the bounded result +// label, by its OAuth2 error code where it carries one and by its HTTP +// status otherwise. +func tokenResult(err error) string { + if err == nil { + return telemetry.OAuth2OK + } + var oauthErr *OAuth2HTTPError + if errors.As(err, &oauthErr) { + switch oauthErr.oauthError { + case "invalid_client": + return telemetry.OAuth2InvalidClient + case "authorization_pending", "slow_down": + return telemetry.OAuth2Pending + } + } + var coded interface{ StatusCode() int } + if !errors.As(err, &coded) { + return telemetry.OAuth2Error + } + switch code := coded.StatusCode(); { + case code == http.StatusUnauthorized: + return telemetry.OAuth2InvalidClient + case code >= 400 && code < 500: + return telemetry.OAuth2Rejected + default: + return telemetry.OAuth2Error + } +} + +// compareClientSecret is bcrypt.CompareHashAndPassword inside a verify +// client secret span. The secret, the client ID and the outcome stay off +// the span: a mismatch is answered by the token endpoint's own status. +func compareClientSecret(ctx context.Context, hash, secret string) error { + _, span := telemetry.Start(ctx, spanVerifyClientSecret) + defer span.End() + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)) +} diff --git a/plugins/oauth2provider/token_exchange.go b/plugins/oauth2provider/token_exchange.go index 210a7d31..2d1d7f46 100644 --- a/plugins/oauth2provider/token_exchange.go +++ b/plugins/oauth2provider/token_exchange.go @@ -8,7 +8,6 @@ import ( "time" "github.com/xraph/forge" - "golang.org/x/crypto/bcrypt" "github.com/xraph/authsome" "github.com/xraph/authsome/hook" @@ -160,7 +159,7 @@ func (p *Plugin) handleTokenExchangeGrant(ctx forge.Context, req *TokenRequest) if client.Public { return nil, forge.BadRequest("token exchange not allowed for public clients") } - if cmpErr := bcrypt.CompareHashAndPassword([]byte(client.ClientSecret), []byte(req.ClientSecret)); cmpErr != nil { + if cmpErr := compareClientSecret(ctx.Context(), client.ClientSecret, req.ClientSecret); cmpErr != nil { return nil, forge.Unauthorized("invalid client_secret") } if !p.clientSupportsGrant(client, tokenExchangeGrantType) { diff --git a/plugins/passkey/discoverable.go b/plugins/passkey/discoverable.go index d568c241..0edec418 100644 --- a/plugins/passkey/discoverable.go +++ b/plugins/passkey/discoverable.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "time" @@ -16,6 +17,7 @@ import ( "github.com/xraph/authsome/bridge" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/session" "github.com/xraph/authsome/user" @@ -105,7 +107,10 @@ func (p *Plugin) resolveDiscoverableUser(ctx context.Context, userHandle []byte) // the ceremony session correlated by cookie, verifies the assertion against the // resolved user (looked up by user handle — no prior authentication), rejects // cloned authenticators, and issues a real session. -func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) (*LoginFinishResponse, error) { +func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) (_ *LoginFinishResponse, err error) { + attempt := telemetry.From(p.engine).StartSignIn(telemetry.MethodPasskey) + defer func() { attempt.Done(err) }() + key := discoverableKey(ceremonyID) sessionJSON, err := p.ceremonies.Get(ctx.Context(), key) if err != nil { @@ -147,7 +152,7 @@ func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) ( _ = p.store.UpdateSignCount(ctx.Context(), cred.ID, cred.Authenticator.SignCount) //nolint:errcheck // best-effort update } - sess, err := p.issueSession(ctx, loggedIn) + sess, err := p.issueSession(ctx, loggedIn, attempt) if err != nil { return nil, err } @@ -167,7 +172,7 @@ func (p *Plugin) finishDiscoverableLogin(ctx forge.Context, ceremonyID string) ( // issueSession mints a real session for a passwordless login via the engine's // centralized IssueSession (so the MFA gate applies), returning it. -func (p *Plugin) issueSession(ctx forge.Context, u *user.User) (*session.Session, error) { +func (p *Plugin) issueSession(ctx forge.Context, u *user.User, attempt *telemetry.SignInAttempt) (*session.Session, error) { eng, ok := p.engine.(*authsome.Engine) if !ok || eng == nil { return nil, middleware.InternalError(ctx, fmt.Errorf("passkey: session issuance unavailable")) @@ -178,8 +183,12 @@ func (p *Plugin) issueSession(ctx forge.Context, u *user.User) (*session.Session // working on a mandating app rather than what tightens it. dpopJKT, err := eng.DPoPBindingForRequest(ctx, u.AppID) if err != nil { + if errors.Is(err, authsome.ErrDPoPBindingRequired) { + attempt.Refuse(telemetry.SignInDenied) + } return nil, err } + attempt.HandOff() result, err := eng.IssueSession(ctx.Context(), &authsome.IssueSessionRequest{ User: u, AppID: u.AppID, diff --git a/plugins/passkey/discoverable_dpop_test.go b/plugins/passkey/discoverable_dpop_test.go index 2b2f0b3b..7f84d691 100644 --- a/plugins/passkey/discoverable_dpop_test.go +++ b/plugins/passkey/discoverable_dpop_test.go @@ -62,7 +62,7 @@ func TestPasskeyIssueSession_UnderRequiredMode_BindsSession(t *testing.T) { router := forge.NewRouter() require.NoError(t, router.POST("/test/passkey-issue", func(ctx forge.Context, _ *issueSessionProbeRequest) (*issueSessionProbeResponse, error) { - sess, issueErr := p.issueSession(ctx, u) + sess, issueErr := p.issueSession(ctx, u, nil) if issueErr != nil { return nil, issueErr } diff --git a/plugins/phone/plugin.go b/plugins/phone/plugin.go index c404f1b0..1d034dd5 100644 --- a/plugins/phone/plugin.go +++ b/plugins/phone/plugin.go @@ -5,6 +5,7 @@ package phone import ( "context" "encoding/json" + "errors" "fmt" "net/http" "regexp" @@ -22,6 +23,7 @@ import ( "github.com/xraph/authsome/ceremony" "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/plugins/mfa" @@ -346,7 +348,10 @@ func (p *Plugin) recordFailedAttempt(ctx context.Context, key string, challenge } } -func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (*VerifyResponse, error) { +func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (_ *VerifyResponse, err error) { + attempt := telemetry.From(p.engine).StartSignIn(telemetry.MethodPhone) + defer func() { attempt.Done(err) }() + if req.Phone == "" { return nil, forge.BadRequest("phone number required") } @@ -418,8 +423,12 @@ func (p *Plugin) handleVerify(ctx forge.Context, req *VerifyRequest) (*VerifyRes // phone route working rather than what makes it stricter. dpopJKT, bindErr := eng.DPoPBindingForRequest(ctx, appID) if bindErr != nil { + if errors.Is(bindErr, authsome.ErrDPoPBindingRequired) { + attempt.Refuse(telemetry.SignInDenied) + } return nil, bindErr } + attempt.HandOff() result, issueErr := eng.IssueSession(ctx.Context(), &authsome.IssueSessionRequest{ User: u, AppID: appID, diff --git a/plugins/retention/plugin.go b/plugins/retention/plugin.go index 2eb60199..d1e629c5 100644 --- a/plugins/retention/plugin.go +++ b/plugins/retention/plugin.go @@ -12,6 +12,7 @@ import ( "github.com/xraph/grove/migrate" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/settings" ) @@ -362,6 +363,8 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { if err != nil { return fmt.Errorf("retention: %w", err) } + tel := telemetry.From(engine) + instrumentProviders(built, tel) if p.providers == nil { p.providers = newProviderRegistry(nil) } @@ -391,6 +394,7 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { Store: p.store, Providers: p.providers, Logger: p.logger, + Tel: tel, Interval: p.config.TickInterval, Lease: p.config.Lease, BatchSize: p.config.BatchSize, diff --git a/plugins/retention/provider.go b/plugins/retention/provider.go index c832eca2..5045e021 100644 --- a/plugins/retention/provider.go +++ b/plugins/retention/provider.go @@ -111,6 +111,11 @@ type ProviderError struct { // the local contact ref and let the retry recreate the contact rather // than keep updating something that no longer exists. DropRef bool + + // Status is the HTTP status the CRM answered with, or zero when the + // request never landed. The worker logs its class (4xx, 5xx) in place + // of the error text. + Status int } func (e *ProviderError) Error() string { diff --git a/plugins/retention/provider_generic.go b/plugins/retention/provider_generic.go index 547fa2ef..2046a90e 100644 --- a/plugins/retention/provider_generic.go +++ b/plugins/retention/provider_generic.go @@ -320,39 +320,40 @@ func classifyHTTPError(resp *http.Response, body []byte, err error) *ProviderErr } detail := fmt.Errorf("%s: %s", resp.Status, truncate(body, 512)) + status := resp.StatusCode - switch resp.StatusCode { + switch status { case http.StatusTooManyRequests: - return &ProviderError{Err: detail, Retryable: true, RetryAfter: retryAfter(resp)} + return &ProviderError{Err: detail, Retryable: true, RetryAfter: retryAfter(resp), Status: status} case http.StatusUnauthorized, http.StatusForbidden: // Whole-integration failure: a bad or revoked token fails every job, // and a dead row is never re-enqueued. Retry flat and slow so a broken // credential does not burn its budget inside the first minute. - return &ProviderError{Err: detail, Retryable: true, RetryAfter: 2 * time.Minute} + return &ProviderError{Err: detail, Retryable: true, RetryAfter: 2 * time.Minute, Status: status} case http.StatusNotFound: // Deleted upstream. Drop the ref so the retry recreates the contact // instead of updating a record that is gone. - return &ProviderError{Err: detail, Retryable: true, DropRef: true} + return &ProviderError{Err: detail, Retryable: true, DropRef: true, Status: status} case http.StatusRequestTimeout, http.StatusConflict: - return &ProviderError{Err: detail, Retryable: true} + return &ProviderError{Err: detail, Retryable: true, Status: status} case http.StatusNotImplemented: // The endpoint does not implement this. Configuration, not weather. - return &ProviderError{Err: detail} + return &ProviderError{Err: detail, Status: status} } - if resp.StatusCode >= 500 { - return &ProviderError{Err: detail, Retryable: true} + if status >= 500 { + return &ProviderError{Err: detail, Retryable: true, Status: status} } // Every other 4xx is this job's own payload: 400, 422, 413. It will not // become valid on the ninth attempt. Anything unrecognised is terminal // too, matching the package rule that an unclassified failure does not // retry forever. - return &ProviderError{Err: detail} + return &ProviderError{Err: detail, Status: status} } // retryAfter parses the response's RFC 7231 Retry-After header, in both its diff --git a/plugins/retention/telemetry.go b/plugins/retention/telemetry.go new file mode 100644 index 00000000..84c4746c --- /dev/null +++ b/plugins/retention/telemetry.go @@ -0,0 +1,91 @@ +package retention + +import ( + "context" + "errors" + "net/http" + "strconv" + + log "github.com/xraph/go-utils/log" + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/internal/telemetry" +) + +// Span names the outbox worker starts. "deliver retention jobs" and "purge +// retention outbox" are root spans, one per tick that found work; "deliver +// retention job" is a child per claimed job. +const ( + spanDeliverJobs = "deliver retention jobs" + spanDeliverJob = "deliver retention job" + spanPurgeOutbox = "purge retention outbox" +) + +// jobKindLabel clamps a job's kind to the two the plugin enqueues. +func jobKindLabel(kind string) string { + switch kind { + case KindContactUpsert, KindActivityLog: + return kind + default: + return "other" + } +} + +// providerLabel names the kind of CRM a job is bound for: the provider's +// type, never the name an operator gave it. +func (w *worker) providerLabel(j *Job) string { + if w.deps.Providers == nil { + return telemetry.CRMOther + } + switch w.deps.Providers.Load()[j.Provider].(type) { + case *HubSpotProvider: + return telemetry.CRMHubSpot + case *GenericProvider: + return telemetry.CRMGeneric + default: + return telemetry.CRMOther + } +} + +// outcome records where a job ended up on its span and its counter. +func (w *worker) outcome(ctx context.Context, j *Job, outcome string) { + trace.SpanFromContext(ctx).SetAttributes(telemetry.AttrRetentionOutcome.String(outcome)) + w.deps.Tel.RetentionJob(w.providerLabel(j), outcome) +} + +// failureFields describes a failed delivery for the log without the error's +// text. A CRM's error quotes up to 512 bytes of its response body, which can +// echo the contact's email back, so that text goes only into the job row +// (MarkDead, MarkRetry), where whoever investigates the job can read it. +func failureFields(j *Job, cause error) []log.Field { + fields := []log.Field{ + log.String("job_id", j.ID.String()), + log.String("provider", j.Provider), + log.String("error_type", telemetry.TypeOf(cause)), + } + var pe *ProviderError + if errors.As(cause, &pe) && pe.Status > 0 { + fields = append(fields, log.String("status_class", strconv.Itoa(pe.Status/100)+"xx")) + } + return fields +} + +// instrumentProviders wraps each built-in provider's HTTP client so every +// CRM call is a client span under its job and an observation in the +// provider latency histogram. HubSpot's own API host is a vendor's and is +// recorded. A HubSpot base URL someone overrode can point anywhere, and a +// generic provider's URL is the customer's, so neither host is. +func instrumentProviders(providers map[string]Provider, tel *telemetry.Metrics) { + for _, p := range providers { + switch prov := p.(type) { + case *HubSpotProvider: + prov.client = instrumentCRMClient(prov.client, prov.baseURL != hubspotDefaultBaseURL, tel) + case *GenericProvider: + prov.client = instrumentCRMClient(prov.client, true, tel) + } + } +} + +func instrumentCRMClient(c *http.Client, hideHost bool, tel *telemetry.Metrics) *http.Client { + return telemetry.InstrumentClient(c, telemetry.Endpoint{Kind: telemetry.ProviderCRM, HideHost: hideHost}, tel) +} diff --git a/plugins/retention/worker.go b/plugins/retention/worker.go index 65d890ce..86c13352 100644 --- a/plugins/retention/worker.go +++ b/plugins/retention/worker.go @@ -10,8 +10,10 @@ import ( "time" log "github.com/xraph/go-utils/log" + "go.opentelemetry.io/otel/trace" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" ) // workerDeps is everything the delivery loop needs. It takes function values @@ -26,6 +28,8 @@ type workerDeps struct { // plain map. Providers *providerRegistry Logger log.Logger + // Tel counts job outcomes and CRM call latency. Nil records nothing. + Tel *telemetry.Metrics Interval time.Duration Lease time.Duration @@ -152,6 +156,10 @@ func (w *worker) start() { // startWith begins the loop on a caller-supplied tick channel, so tests drive // it deterministically instead of sleeping. +// +// The loop's context carries no span. A tick that finds nothing to do (the +// usual case) makes its claim query outside any trace; a tick that claims +// jobs, or that purges, starts a root span of its own. func (w *worker) startWith(tick <-chan time.Time) { ctx, cancel := context.WithCancel(context.Background()) w.cancel = cancel @@ -195,6 +203,12 @@ func (w *worker) runOnce(ctx context.Context) { w.deps.Logger.Warn("retention: claim failed", log.String("error", err.Error())) return } + if len(jobs) == 0 { + return + } + ctx, span := telemetry.StartRoot(ctx, spanDeliverJobs, + trace.WithAttributes(telemetry.AttrRetentionJobs.Int(len(jobs)))) + defer span.End() for _, j := range jobs { w.deliver(ctx, j) } @@ -235,12 +249,16 @@ func (w *worker) maybePurge(ctx context.Context, now time.Time) { return } + ctx, span := telemetry.StartRoot(ctx, spanPurgeOutbox) + defer span.End() removed, err := w.deps.Store.PurgeTerminal(ctx, doneBefore, auditBefore) if err != nil { + telemetry.FailErr(span, err) w.deps.Logger.Warn("retention: outbox purge failed", log.String("error", err.Error())) return } + span.SetAttributes(telemetry.AttrRetentionRemoved.Int(removed)) if removed > 0 { w.deps.Logger.Info("retention: purged expired outbox rows", log.Int("removed", removed)) @@ -256,11 +274,18 @@ func (w *worker) maybePurge(ctx context.Context, now time.Time) { // transient, and running the same bug again on the same row panics again. // The rest of the batch carries on. func (w *worker) deliver(ctx context.Context, j *Job) { + ctx, span := telemetry.Start(ctx, spanDeliverJob, trace.WithAttributes( + telemetry.AttrRetentionJobKind.String(jobKindLabel(j.Kind)), + telemetry.AttrRetentionProvider.String(w.providerLabel(j)), + )) + defer span.End() defer func() { r := recover() if r == nil { return } + telemetry.Fail(span, "panic", nil) + w.outcome(ctx, j, telemetry.JobDead) w.deps.Logger.Error("retention: delivery panicked, job dead-lettered", log.String("job_id", j.ID.String()), log.String("provider", j.Provider), @@ -376,6 +401,7 @@ func (w *worker) deliverOne(ctx context.Context, j *Job) { if err := w.deps.Store.MarkDone(ctx, j.ID, time.Now()); err != nil { w.deps.Logger.Warn("retention: mark done failed", log.String("error", err.Error())) } + w.outcome(ctx, j, telemetry.JobDone) } // ensureRef returns the contact's ref, creating the contact when we have not @@ -455,16 +481,18 @@ func isRetryable(err error) bool { // fail either defers the job or parks it. isTerminal short-circuits the retry // budget for errors that will never succeed. func (w *worker) fail(ctx context.Context, j *Job, cause error, isTerminal bool) { + // The CRM's answer goes on the span as a type, never as text: provider + // errors quote response bodies, and those carry contact details. + telemetry.FailErr(trace.SpanFromContext(ctx), cause) if isTerminal || j.Attempts+1 >= w.deps.MaxAttempts { + w.outcome(ctx, j, telemetry.JobDead) if err := w.deps.Store.MarkDead(ctx, j.ID, cause.Error()); err != nil { w.deps.Logger.Warn("retention: mark dead failed", log.String("error", err.Error())) } - w.deps.Logger.Warn("retention: job dead-lettered", - log.String("job_id", j.ID.String()), - log.String("provider", j.Provider), - log.String("error", cause.Error())) + w.deps.Logger.Warn("retention: job dead-lettered", failureFields(j, cause)...) return } + w.outcome(ctx, j, telemetry.JobRetry) _, after := Retryable(cause) if after <= 0 { after = w.backoff(j.Attempts) @@ -478,12 +506,14 @@ func (w *worker) fail(ctx context.Context, j *Job, cause error, isTerminal bool) // where nothing failed and nothing was decided: we are simply not delivering // right now. It comes back up roughly one tick later. func (w *worker) deferJob(ctx context.Context, j *Job, reason string) { + w.outcome(ctx, j, telemetry.JobDeferred) if err := w.deps.Store.MarkDeferred(ctx, j.ID, time.Now().Add(w.deps.Interval), reason); err != nil { w.deps.Logger.Warn("retention: mark deferred failed", log.String("error", err.Error())) } } func (w *worker) suppress(ctx context.Context, j *Job, reason string) { + w.outcome(ctx, j, telemetry.JobSuppressed) if err := w.deps.Store.MarkSuppressed(ctx, j.ID, reason); err != nil { w.deps.Logger.Warn("retention: mark suppressed failed", log.String("error", err.Error())) } diff --git a/plugins/retention/worker_telemetry_test.go b/plugins/retention/worker_telemetry_test.go new file mode 100644 index 00000000..1c2bcd2f --- /dev/null +++ b/plugins/retention/worker_telemetry_test.go @@ -0,0 +1,206 @@ +package retention + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + log "github.com/xraph/go-utils/log" + + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" +) + +// claimSpy records whether each ClaimDue ran under a span. +type claimSpy struct { + Store + mu sync.Mutex + valid []bool + claimed chan struct{} +} + +func (c *claimSpy) ClaimDue(ctx context.Context, limit int, lease time.Duration, now time.Time) ([]*Job, error) { + c.mu.Lock() + c.valid = append(c.valid, trace.SpanContextFromContext(ctx).IsValid()) + c.mu.Unlock() + defer func() { c.claimed <- struct{}{} }() + return c.Store.ClaimDue(ctx, limit, lease, now) +} + +// An idle tick claims nothing: its query runs outside any trace, and it +// starts no span, so a quiet outbox mints no traces every 30 seconds. +func TestWorkerIdleTickStartsNoTrace(t *testing.T) { + rec := telemetrytest.Spans(t) + spy := &claimSpy{Store: NewMemoryStore(), claimed: make(chan struct{}, 1)} + w := newTestWorker(t, spy, &fakeProvider{caps: CapContacts}) + tick := make(chan time.Time) + w.startWith(tick) + t.Cleanup(w.stop) + + tick <- time.Now() + select { + case <-spy.claimed: + case <-time.After(5 * time.Second): + t.Fatal("the tick never claimed") + } + w.stop() + + spy.mu.Lock() + defer spy.mu.Unlock() + assert.Equal(t, []bool{false}, spy.valid) + assert.Empty(t, telemetrytest.Authsome(rec)) +} + +// A tick that claims jobs is one root span with a child per job, each +// carrying its outcome, and each outcome is counted by provider kind. +func TestWorkerTracesAClaimedBatchAndCountsOutcomes(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + ctx := context.Background() + s := NewMemoryStore() + p := &fakeProvider{ + caps: CapContacts | CapActivities, + upsertErr: []error{nil, errors.New("400 invalid email for alice@example.com")}, + } + enqueued(t, s, KindContactUpsert, "t1") + enqueued(t, s, KindContactUpsert, "t2") + + w := newTestWorker(t, s, p) + w.deps.Tel = telemetry.NewMetrics(reg) + w.runOnce(ctx) + + spans := telemetrytest.Authsome(rec) + roots := telemetrytest.Named(spans, spanDeliverJobs) + require.Len(t, roots, 1) + assert.False(t, roots[0].Parent().IsValid()) + assert.Equal(t, "2", telemetrytest.Attrs(roots[0])["authsome.retention.jobs"]) + + jobs := telemetrytest.Named(spans, spanDeliverJob) + require.Len(t, jobs, 2) + outcomes := map[string]int{} + for _, j := range jobs { + assert.Equal(t, roots[0].SpanContext().SpanID(), j.Parent().SpanID()) + attrs := telemetrytest.Attrs(j) + assert.Equal(t, KindContactUpsert, attrs["authsome.retention.job_kind"]) + assert.Equal(t, "other", attrs["authsome.retention.provider"], "the operator's name for the provider is never recorded") + outcomes[attrs["authsome.retention.outcome"]]++ + assert.NotContains(t, telemetrytest.Text(j), "alice@example.com") + assert.NotContains(t, telemetrytest.Text(j), "invalid email") + if attrs["authsome.retention.outcome"] == telemetry.JobDead { + assert.Equal(t, codes.Error, j.Status().Code) + } + } + assert.Equal(t, map[string]int{telemetry.JobDone: 1, telemetry.JobDead: 1}, outcomes) + + body := scrape() + assert.Contains(t, body, `forge_authsome_retention_jobs_processed_total{outcome="done",provider="other"} 1`) + assert.Contains(t, body, `forge_authsome_retention_jobs_processed_total{outcome="dead",provider="other"} 1`) +} + +func TestProviderLabelNamesTheTypeNotTheInstance(t *testing.T) { + hub, err := NewHubSpotProvider(ProviderConfig{Name: "acme-prod-hubspot", Token: "x"}) + require.NoError(t, err) + gen, err := NewGenericProvider(ProviderConfig{Name: "acme-crm", ContactURL: "https://crm.acme.example/contacts"}) + require.NoError(t, err) + w := newWorker(workerDeps{Providers: newProviderRegistry(map[string]Provider{ + "acme-prod-hubspot": hub, "acme-crm": gen, "fake": &fakeProvider{}, + })}) + + assert.Equal(t, telemetry.CRMHubSpot, w.providerLabel(&Job{Provider: "acme-prod-hubspot"})) + assert.Equal(t, telemetry.CRMGeneric, w.providerLabel(&Job{Provider: "acme-crm"})) + assert.Equal(t, telemetry.CRMOther, w.providerLabel(&Job{Provider: "fake"})) + assert.Equal(t, telemetry.CRMOther, w.providerLabel(&Job{Provider: "gone"})) +} + +// HubSpot's own API host is a vendor's and goes on the client span. An +// overridden base URL can point anywhere, so its host doesn't. +func TestHubSpotHostIsHiddenWhenTheBaseURLIsOverridden(t *testing.T) { + rec := telemetrytest.Spans(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })) + defer srv.Close() + + call := func(p *HubSpotProvider) map[string]string { + t.Helper() + instrumentProviders(map[string]Provider{"h": p}, nil) + ctx, parent := telemetry.Start(context.Background(), "job") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, http.NoBody) + require.NoError(t, err) + resp, err := p.client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + parent.End() + got := telemetrytest.Named(telemetrytest.Authsome(rec), "GET") + return telemetrytest.Attrs(got[len(got)-1]) + } + + vendor, err := NewHubSpotProvider(ProviderConfig{Name: "hs", Token: "x"}) + require.NoError(t, err) + assert.Contains(t, call(vendor), "server.address", "the default HubSpot host is recorded") + + custom, err := NewHubSpotProvider(ProviderConfig{Name: "hs", Token: "x", BaseURL: "https://crm-proxy.acme.example/"}) + require.NoError(t, err) + assert.NotContains(t, call(custom), "server.address", "an overridden base URL's host is not") +} + +// fieldLogger keeps every Warn with its fields. +type fieldLogger struct { + log.Logger + mu sync.Mutex + warns []string +} + +func (l *fieldLogger) Warn(msg string, fields ...log.Field) { + l.mu.Lock() + defer l.mu.Unlock() + line := msg + for _, f := range fields { + line += " " + f.Key() + "=" + fmt.Sprint(f.Value()) + } + l.warns = append(l.warns, line) +} + +// A dead-lettered job is logged by ID, error type and status class. The +// CRM's own error text, which can quote the contact's email back, stays in +// the job row only. +func TestDeadLetterLogLeavesTheCRMResponseInTheJobRow(t *testing.T) { + ctx := context.Background() + s := NewMemoryStore() + crmBody := `{"message":"Contact alice@example.com already exists","status":"error"}` + p := &fakeProvider{ + caps: CapContacts | CapActivities, + upsertErr: []error{&ProviderError{ + Err: fmt.Errorf("422 Unprocessable Entity: %s", crmBody), + Status: http.StatusUnprocessableEntity, + }}, + } + j := enqueued(t, s, KindContactUpsert, "dl1") + + logger := &fieldLogger{Logger: log.NewNoopLogger()} + w := newTestWorker(t, s, p) + w.deps.Logger = logger + w.runOnce(ctx) + + stored, err := s.GetJob(ctx, j.ID) + require.NoError(t, err) + require.Equal(t, StateDead, stored.State) + assert.Contains(t, stored.LastError, "alice@example.com", "the full text is kept where an operator investigates the job") + + require.Len(t, logger.warns, 1) + line := logger.warns[0] + assert.Contains(t, line, "retention: job dead-lettered") + assert.Contains(t, line, "job_id="+j.ID.String()) + assert.Contains(t, line, "status_class=4xx") + assert.Contains(t, line, "error_type=") + assert.NotContains(t, line, "alice@example.com") + assert.NotContains(t, line, "already exists") +} diff --git a/plugins/sharedsignals/jwksclient/client.go b/plugins/sharedsignals/jwksclient/client.go index da2d3b1b..d1eccae5 100644 --- a/plugins/sharedsignals/jwksclient/client.go +++ b/plugins/sharedsignals/jwksclient/client.go @@ -412,6 +412,26 @@ func (c *Client) refresh(ctx context.Context, jwksURI string) (err error) { return err } +// Due reports how many cached key sets RefreshAll would fetch right now: +// those whose last fetch is older than MinRefetchInterval and that no +// other goroutine is already fetching. A background round that finds none +// has no work to do. +func (c *Client) Due() int { + now := c.opts.Now() + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for uri, e := range c.cache { + if _, busy := c.inflight[uri]; busy { + continue + } + if now.Sub(e.lastAttempt) >= c.opts.MinRefetchInterval { + n++ + } + } + return n +} + // RefreshFailure names one URI whose background refresh did not succeed. type RefreshFailure struct { URI string diff --git a/plugins/sharedsignals/jwksclient/client_test.go b/plugins/sharedsignals/jwksclient/client_test.go index 81dee0d9..a2c048c3 100644 --- a/plugins/sharedsignals/jwksclient/client_test.go +++ b/plugins/sharedsignals/jwksclient/client_test.go @@ -775,3 +775,24 @@ func TestRefreshAll_IsANoOpOnAColdCache(t *testing.T) { c := New(hardLimitOptions(srv, &now)) assert.Empty(t, c.RefreshAll(context.Background())) } + +// Due counts the key sets a background round would fetch: none on a cold +// cache, none inside MinRefetchInterval, one once that has passed. The +// plugin uses it to start a trace only for a round that has work. +func TestDue_CountsWhatRefreshAllWouldFetch(t *testing.T) { + srv, _ := flakyJWKS(t, &newKey(t).PublicKey) + defer srv.Close() + + now := time.Now() + c := New(hardLimitOptions(srv, &now)) + assert.Equal(t, 0, c.Due(), "a cold cache has nothing to refresh") + + _, err := c.Key(context.Background(), srv.URL, "kid-1") + require.NoError(t, err) + assert.Equal(t, 0, c.Due(), "a set fetched inside MinRefetchInterval is not due") + + now = now.Add(10 * time.Minute) + assert.Equal(t, 1, c.Due()) + require.Empty(t, c.RefreshAll(context.Background())) + assert.Equal(t, 0, c.Due(), "the round that fetched it resets the clock") +} diff --git a/plugins/sharedsignals/plugin.go b/plugins/sharedsignals/plugin.go index 7144127b..3dd7a58a 100644 --- a/plugins/sharedsignals/plugin.go +++ b/plugins/sharedsignals/plugin.go @@ -5,6 +5,7 @@ import ( "time" log "github.com/xraph/go-utils/log" + "go.opentelemetry.io/otel/trace" "github.com/xraph/grove/migrate" @@ -13,6 +14,7 @@ import ( "github.com/xraph/authsome/bridge" "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/hook" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/plugins/sharedsignals/jwksclient" "github.com/xraph/authsome/settings" @@ -264,7 +266,13 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { p.store = NewMemoryStore() } - p.jwks = jwksclient.New(jwksclient.Options{}) + // Key-set fetches are client spans with no host on them (the JWKS URI + // is the transmitter's, not a vendor's) and observations under + // kind=jwks in the provider latency histogram. + p.jwks = jwksclient.New(jwksclient.Options{ + HTTPClient: telemetry.InstrumentClient(jwksclient.NewHTTPClient(5*time.Second), + telemetry.Endpoint{Kind: telemetry.ProviderJWKS, HideHost: true}, telemetry.From(engine)), + }) if p.config.KeyRefreshInterval > 0 { p.refresher = newRefresher(p.config.KeyRefreshInterval, p.refreshKeys) @@ -282,8 +290,24 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { // visible in the receiver's own error path, but an endpoint that has quietly // stopped answering the ticker produces no traffic at all -- and the first // symptom, twelve hours later, is a stream that starts refusing SETs. +// +// A round with a key set due for a fetch runs in a refresh stream keys root +// span; a round with nothing due starts none, so an idle ticker mints no +// traces. func (p *Plugin) refreshKeys(ctx context.Context) { - for _, failure := range p.jwks.RefreshAll(ctx) { + due := p.jwks.Due() + if due == 0 { + return + } + ctx, span := telemetry.StartRoot(ctx, spanRefreshStreamKeys, + trace.WithAttributes(telemetry.AttrJWKSSets.Int(due))) + defer span.End() + failures := p.jwks.RefreshAll(ctx) + span.SetAttributes(telemetry.AttrJWKSFailed.Int(len(failures))) + if len(failures) > 0 { + telemetry.Fail(span, "refresh_failed", nil) + } + for _, failure := range failures { p.logger.Warn("sharedsignals: background JWKS refresh failed", logString("jwks_uri", failure.URI), logString("error", failure.Err.Error()), @@ -291,6 +315,9 @@ func (p *Plugin) refreshKeys(ctx context.Context) { } } +// spanRefreshStreamKeys is the root span of one background JWKS round. +const spanRefreshStreamKeys = "refresh stream keys" + // OnShutdown stops the background JWKS refresh and waits for any round // already in flight, so the process does not exit with an outbound fetch // still open against an IdP. diff --git a/plugins/sharedsignals/refresh_telemetry_test.go b/plugins/sharedsignals/refresh_telemetry_test.go new file mode 100644 index 00000000..d115cf6c --- /dev/null +++ b/plugins/sharedsignals/refresh_telemetry_test.go @@ -0,0 +1,79 @@ +package sharedsignals + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + + log "github.com/xraph/go-utils/log" + + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/plugins/sharedsignals/jwksclient" +) + +// A background round with nothing due starts no span. A round with a key +// set due is one root span, and the fetch under it is a client span with +// no host and no URL on it: the JWKS URI is the transmitter's. +func TestRefreshKeysTracesOnlyARoundWithWork(t *testing.T) { + rec := telemetrytest.Spans(t) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + body, err := json.Marshal(map[string]any{"keys": []map[string]string{{ + "kty": "RSA", "kid": "kid-1", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}}) + require.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) })) + defer srv.Close() + + now := time.Now() + p := &Plugin{ + logger: log.NewNoopLogger(), + jwks: jwksclient.New(jwksclient.Options{ + HTTPClient: telemetry.InstrumentClient(srv.Client(), + telemetry.Endpoint{Kind: telemetry.ProviderJWKS, HideHost: true}, nil), + MinRefetchInterval: 5 * time.Minute, + Now: func() time.Time { return now }, + ValidateURI: func(string) error { return nil }, + }), + } + jwksURI := srv.URL + "/stream/acme/jwks.json" + + // Cold cache, then a set fetched just now: neither round has work. + p.refreshKeys(context.Background()) + _, err = p.jwks.Key(context.Background(), jwksURI, "kid-1") + require.NoError(t, err) + p.refreshKeys(context.Background()) + assert.Empty(t, telemetrytest.Authsome(rec), "an idle round mints no trace") + + now = now.Add(10 * time.Minute) + p.refreshKeys(context.Background()) + + spans := telemetrytest.Authsome(rec) + rounds := telemetrytest.Named(spans, spanRefreshStreamKeys) + require.Len(t, rounds, 1) + assert.False(t, rounds[0].Parent().IsValid()) + assert.Equal(t, map[string]string{"authsome.jwks.sets": "1", "authsome.jwks.failed": "0"}, telemetrytest.Attrs(rounds[0])) + + fetches := telemetrytest.Named(spans, "GET") + require.Len(t, fetches, 1) + assert.Equal(t, trace.SpanKindClient, fetches[0].SpanKind()) + assert.Equal(t, rounds[0].SpanContext().SpanID(), fetches[0].Parent().SpanID()) + text := telemetrytest.Text(fetches[0]) + assert.NotContains(t, text, "jwks.json") + assert.NotContains(t, text, strings.TrimPrefix(srv.URL, "http://")) +} diff --git a/plugins/social/plugin.go b/plugins/social/plugin.go index a766b609..9c4b7fdb 100644 --- a/plugins/social/plugin.go +++ b/plugins/social/plugin.go @@ -28,6 +28,7 @@ import ( "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" "github.com/xraph/authsome/internal/browserbind" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/session" @@ -832,6 +833,15 @@ func verifyOIDCNonce(token *oauth2.Token, expectedNonce string) bool { // handleCallback processes the OAuth callback, exchanges the code for a token, // fetches the user profile, and either links to an existing user or creates a new one. func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*CallbackResponse, error) { + attempt := telemetry.From(p.engine).StartSignIn(telemetry.MethodSocial) + resp, err := p.callback(ctx, req, attempt) + attempt.Done(err) + return resp, err +} + +// callback is handleCallback's body. attempt is told where IssueSession +// takes over the count and which refusals answer with a redirect. +func (p *Plugin) callback(ctx forge.Context, req *CallbackRequest, attempt *telemetry.SignInAttempt) (*CallbackResponse, error) { provider, ok := p.resolveProvider(ctx.Context(), req.Provider) if !ok { return nil, forge.BadRequest(fmt.Sprintf("unsupported provider: %s", req.Provider)) @@ -885,20 +895,29 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb // burned for nothing. if eng, ok := p.engine.(*authsome.Engine); ok && eng != nil { if bindErr := eng.DPoPBindingUnavailable(ctx.Context(), appID); bindErr != nil { + attempt.Refuse(telemetry.SignInDenied) return nil, bindErr } } // Check for error from provider if req.Error != "" { + attempt.Refuse(telemetry.SignInDenied) return p.callbackError(ctx, stateInfo, fmt.Sprintf("provider error: %s", req.Error)) } // Exchange code for token if req.Code == "" { + attempt.Refuse(telemetry.SignInInvalid) return p.callbackError(ctx, stateInfo, "missing code parameter") } + // The token exchange and the profile fetch go through a client that + // measures each call and traces it under this request, sending the + // provider no trace headers and none of the caller's baggage. + providerCtx := context.WithValue(ctx.Context(), oauth2.HTTPClient, telemetry.InstrumentClient( + contextHTTPClient(ctx.Context()), telemetry.Endpoint{Kind: telemetry.ProviderSocial}, telemetry.From(p.engine))) + cfg := provider.OAuth2Config() // Use the callback URL stored during handleStart so the token exchange @@ -914,7 +933,7 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb // providers that did require it will reject mismatches. exchangeOpts = append(exchangeOpts, oauth2.SetAuthURLParam("code_verifier", verifier)) } - token, err := exchangeCfg.Exchange(ctx.Context(), req.Code, exchangeOpts...) + token, err := exchangeCfg.Exchange(providerCtx, req.Code, exchangeOpts...) if err != nil { return nil, forge.BadRequest("failed to exchange code") } @@ -928,7 +947,7 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb } // Fetch user profile from provider - providerUser, err := provider.FetchUser(ctx.Context(), token) + providerUser, err := provider.FetchUser(providerCtx, token) if err != nil { return nil, middleware.InternalError(ctx, fmt.Errorf("failed to fetch user from provider: %w", err)) } @@ -962,6 +981,7 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb // (e.g. test wiring without a full engine). var sess *session.Session if eng, ok := p.engine.(*authsome.Engine); ok && eng != nil { + attempt.HandOff() result, issueErr := eng.IssueSession(goCtx, &authsome.IssueSessionRequest{ User: u, AppID: appID, @@ -1218,6 +1238,15 @@ func (p *Plugin) callbackError(ctx forge.Context, stateInfo map[string]string, m return nil, nil } +// contextHTTPClient returns the client golang.org/x/oauth2 would use for +// ctx: the one stored under oauth2.HTTPClient, or nil for its default. +func contextHTTPClient(ctx context.Context) *http.Client { + if c, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok { + return c + } + return nil +} + // relayEvent sends a webhook event to EventRelay (nil-safe). func (p *Plugin) relayEvent(ctx context.Context, eventType, tenantID string, data map[string]string) { if p.relay == nil { diff --git a/plugins/sso/oidc.go b/plugins/sso/oidc.go index 6321a886..1a6c8b82 100644 --- a/plugins/sso/oidc.go +++ b/plugins/sso/oidc.go @@ -15,6 +15,7 @@ import ( "golang.org/x/oauth2" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/plugins/sharedsignals/jwksclient" ) @@ -36,6 +37,10 @@ type OIDCConfig struct { // and userinfo. Nil selects the hardened client, which refuses private // addresses and plain http; tests point it at a local server. HTTPClient *http.Client + + // tel is where the plugin's provider calls are measured. Unset for a + // provider built outside the plugin, which then records spans only. + tel *telemetry.Metrics } // discoveryTTL is how long a fetched discovery document is reused. The @@ -90,6 +95,9 @@ func NewOIDCProvider(cfg OIDCConfig) Provider { if client == nil { client = jwksclient.NewHTTPClient(10 * time.Second) } + // Discovery, the token exchange and userinfo are client spans with no + // host on them: the issuer is the customer's IdP, not a vendor. + client = telemetry.InstrumentClient(client, telemetry.Endpoint{Kind: telemetry.ProviderOIDC, HideHost: true}, cfg.tel) return &oidcProvider{name: cfg.Name, issuer: issuer, config: oauthCfg, httpClient: client, customClient: cfg.HTTPClient != nil} } @@ -99,9 +107,9 @@ func (p *oidcProvider) Protocol() string { return "oidc" } // requestConfig returns a copy of the OAuth2 configuration with discovered // endpoints applied. The copy is what keeps concurrent logins from writing // endpoints into one another's configuration. -func (p *oidcProvider) requestConfig() (oauth2.Config, *oidcDiscovery) { +func (p *oidcProvider) requestConfig(ctx context.Context) (oauth2.Config, *oidcDiscovery) { cfg := *p.config - discovered, err := p.discover() + discovered, err := p.discover(ctx) if err == nil && discovered != nil { if discovered.AuthorizationEndpoint != "" { cfg.Endpoint.AuthURL = discovered.AuthorizationEndpoint @@ -117,7 +125,7 @@ func (p *oidcProvider) requestConfig() (oauth2.Config, *oidcDiscovery) { // prefers LoginURLWithPKCE; this remains for callers of the Provider // interface alone. func (p *oidcProvider) LoginURL(state string) (string, error) { - cfg, _ := p.requestConfig() + cfg, _ := p.requestConfig(context.Background()) return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil } @@ -130,7 +138,7 @@ func (p *oidcProvider) LoginURLWithPKCE(state, nonce, verifier string) (string, if nonce == "" || verifier == "" { return "", errors.New("sso/oidc: nonce and verifier are required") } - cfg, _ := p.requestConfig() + cfg, _ := p.requestConfig(context.Background()) return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("nonce", nonce), @@ -147,7 +155,7 @@ func (p *oidcProvider) HandleCallback(ctx context.Context, params map[string]str return nil, errors.New("sso/oidc: missing authorization code") } ctx = context.WithValue(ctx, oauth2.HTTPClient, p.httpClient) - cfg, discovered := p.requestConfig() + cfg, discovered := p.requestConfig(ctx) var exchangeOpts []oauth2.AuthCodeOption if v := params["code_verifier"]; v != "" { @@ -254,7 +262,13 @@ type oidcDiscovery struct { // hardened client and cached for discoveryTTL. The issuer URL is validated // first unless a custom client was supplied, which is how tests reach a // local server. -func (p *oidcProvider) discover() (*oidcDiscovery, error) { +// +// The fetch runs on context.WithoutCancel(ctx) with its own ten-second +// timeout: the caller's values travel with it (its span, so the fetch is +// traced under the callback that needed it) but its cancellation and +// deadline do not, which is exactly how it behaved on context.Background(). +// The login-URL paths have no context to give and pass Background. +func (p *oidcProvider) discover(ctx context.Context) (*oidcDiscovery, error) { if c, ok := discoveryCache.Load(p.issuer); ok { if cached, ok := c.(cachedDiscovery); ok && time.Now().Before(cached.expires) { return cached.doc, nil @@ -266,7 +280,7 @@ func (p *oidcProvider) discover() (*oidcDiscovery, error) { return nil, fmt.Errorf("sso/oidc: discovery: %w", err) } } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { diff --git a/plugins/sso/oidc_telemetry_test.go b/plugins/sso/oidc_telemetry_test.go new file mode 100644 index 00000000..015eef29 --- /dev/null +++ b/plugins/sso/oidc_telemetry_test.go @@ -0,0 +1,52 @@ +package sso + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/baggage" + "go.opentelemetry.io/otel/trace" + + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" +) + +// The callback's discovery, token exchange and userinfo calls are client +// spans under the request, with no host on them (the issuer is the +// customer's IdP) and nothing from the URL, the code or the verifier. The +// discovery fetch used to run on context.Background() and was invisible. +func TestOIDCCallbackCallsAreClientSpansUnderTheRequest(t *testing.T) { + rec := telemetrytest.Spans(t) + op := newFakeOP(t) + op.nonceToUse = "nonce-ok" + p := NewOIDCProvider(OIDCConfig{Name: "acme-okta", Issuer: op.srv.URL + "/" + t.Name(), ClientID: "client-acme", RedirectURL: "https://rp.example/cb", HTTPClient: op.srv.Client()}) + + member, err := baggage.NewMember("tenant", "acme") + require.NoError(t, err) + bag, err := baggage.New(member) + require.NoError(t, err) + ctx, parent := telemetry.Start(baggage.ContextWithBaggage(context.Background(), bag), "request") + _, err = p.HandleCallback(ctx, map[string]string{"code": "code-value-123", "nonce": "nonce-ok", "code_verifier": "verifier-value-456"}) + require.NoError(t, err) + parent.End() + + var calls []string + for _, s := range telemetrytest.Authsome(rec) { + if s.SpanKind() != trace.SpanKindClient { + continue + } + calls = append(calls, s.Name()) + assert.Equal(t, parent.SpanContext().SpanID(), s.Parent().SpanID()) + attrs := telemetrytest.Attrs(s) + assert.Equal(t, "oidc", attrs["authsome.provider.kind"]) + assert.NotContains(t, attrs, "server.address") + text := telemetrytest.Text(s) + for _, leaked := range []string{"code-value-123", "verifier-value-456", "client-acme", "acme-okta", strings.TrimPrefix(op.srv.URL, "http://"), "/token", "/me", "well-known"} { + assert.NotContains(t, text, leaked) + } + } + assert.Equal(t, []string{"GET", "POST", "GET"}, calls, "discovery, token exchange, userinfo") +} diff --git a/plugins/sso/oidc_test.go b/plugins/sso/oidc_test.go index fdca4b1a..016410d2 100644 --- a/plugins/sso/oidc_test.go +++ b/plugins/sso/oidc_test.go @@ -120,9 +120,9 @@ func TestOIDC_DiscoveryIsCachedPerIssuer(t *testing.T) { func TestOIDC_HardenedClientRefusesLocalAndPlainIssuers(t *testing.T) { p := NewOIDCProvider(OIDCConfig{Name: "prod", Issuer: "http://127.0.0.1:9/" + t.Name(), ClientID: "c"}) - _, err := p.(*oidcProvider).discover() + _, err := p.(*oidcProvider).discover(context.Background()) require.Error(t, err, "plain http discovery is refused before any dial") p = NewOIDCProvider(OIDCConfig{Name: "prod", Issuer: "https://127.0.0.1:9/" + t.Name(), ClientID: "c"}) - _, err = p.(*oidcProvider).discover() + _, err = p.(*oidcProvider).discover(context.Background()) require.Error(t, err, "a loopback issuer is refused") } diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 40209a79..2406d76a 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -25,6 +25,7 @@ import ( "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" "github.com/xraph/authsome/internal/browserbind" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/organization" "github.com/xraph/authsome/plugin" @@ -334,6 +335,7 @@ func (p *Plugin) connectionToProvider(conn *Connection) (Provider, error) { // must be set (empty redirect_uri is rejected by real IdPs) and must // exactly match what's registered with the IdP — see oidcRedirectURLFor. RedirectURL: p.oidcRedirectURLFor(conn), + tel: telemetry.From(p.engine), }), nil case "saml": return NewSAMLProvider(SAMLConfig{ @@ -348,6 +350,7 @@ func (p *Plugin) connectionToProvider(conn *Connection) (Provider, error) { SPPrivateKeyPEM: conn.SPPrivateKey, SignRequests: conn.SignRequests, AttributeMap: conn.AttributeMappings, + tel: telemetry.From(p.engine), }) default: return nil, fmt.Errorf("sso: unsupported protocol %q", conn.Protocol) @@ -1219,7 +1222,10 @@ func (p *Plugin) isOrgOwnerOrAdmin(ctx context.Context, orgID id.OrgID, userID i return false } -func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Provider, conn *Connection, params map[string]string) (*CallbackResponse, error) { +func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Provider, conn *Connection, params map[string]string) (_ *CallbackResponse, err error) { + attempt := telemetry.From(p.engine).StartSignIn(telemetry.MethodSSO) + defer func() { attempt.Done(err) }() + // Every SSO entry point funnels through here: the JSON callback, the OIDC // browser landing and the SAML ACS. All three are the identity // provider delivering the user agent. The client holding the DPoP key is @@ -1232,12 +1238,17 @@ func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Pr // regardless, but only after the single-use artifact had been spent. if eng, ok := p.engine.(*authsome.Engine); ok && eng != nil { if bindErr := eng.DPoPBindingUnavailable(ctx.Context(), appID); bindErr != nil { + attempt.Refuse(telemetry.SignInDenied) return nil, bindErr } } ssoUser, err := provider.HandleCallback(ctx.Context(), params) if err != nil { + // The IdP's answer did not yield a user: a bad assertion, a code + // the token endpoint refused, or a userinfo call that failed. The + // response is a 500, but for the counter it is a refused sign-in. + attempt.Refuse(telemetry.SignInInvalid) return nil, middleware.InternalError(ctx, fmt.Errorf("sso: callback failed: %w", err)) } if ssoUser == nil { @@ -1279,6 +1290,7 @@ func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Pr // MFARequired gate fires for SAML/OIDC callbacks too. var sess *session.Session if eng, ok := p.engine.(*authsome.Engine); ok && eng != nil { + attempt.HandOff() result, issueErr := eng.IssueSession(goCtx, &authsome.IssueSessionRequest{ User: u, AppID: appID, diff --git a/plugins/sso/saml.go b/plugins/sso/saml.go index a7ddae77..14232d31 100644 --- a/plugins/sso/saml.go +++ b/plugins/sso/saml.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/plugins/sharedsignals/jwksclient" "github.com/crewjam/saml" @@ -51,8 +52,16 @@ type SAMLConfig struct { // AttributeMap maps SAML attribute names to user fields // ("email" | "first_name" | "last_name" | "groups"). Empty uses defaults. AttributeMap map[string]string + + // tel is where the metadata fetch is measured. Unset for a provider + // built outside the plugin. + tel *telemetry.Metrics } +// spanVerifySAMLAssertion is the span the assertion's signature and +// conditions are checked in. +const spanVerifySAMLAssertion = "verify saml assertion" + // samlProvider implements Provider (and SAMLMetadataProvider) for SAML 2.0 // backed by a configured crewjam ServiceProvider. type samlProvider struct { @@ -149,7 +158,7 @@ func (p *samlProvider) LoginURLWithRequestID(state string) (loginURL, requestID // HandleCallback validates the SAMLResponse (signature against the pinned IdP // cert, conditions, audience, recipient, timestamps) and maps it to a User. -func (p *samlProvider) HandleCallback(_ context.Context, params map[string]string) (*User, error) { +func (p *samlProvider) HandleCallback(ctx context.Context, params map[string]string) (*User, error) { raw := params["SAMLResponse"] if raw == "" { return nil, fmt.Errorf("sso/saml: missing SAMLResponse") @@ -167,7 +176,18 @@ func (p *samlProvider) HandleCallback(_ context.Context, params map[string]strin if reqID := params["request_id"]; reqID != "" { possibleRequestIDs = []string{reqID} } + // The XML signature check is the expensive part of a SAML sign-in, so + // it gets a span of its own. An assertion that fails it is a refusal, + // not a fault, and crewjam's error text can quote the assertion, so + // only the outcome goes on the span. + _, span := telemetry.Start(ctx, spanVerifySAMLAssertion) assertion, err := p.sp.ParseXMLResponse(decoded, possibleRequestIDs, p.sp.AcsURL) + result := "ok" + if err != nil { + result = "invalid" + } + span.SetAttributes(telemetry.AttrResult.String(result)) + span.End() if err != nil { return nil, fmt.Errorf("sso/saml: invalid assertion: %w", err) } @@ -200,7 +220,12 @@ func resolveIDPMetadata(cfg SAMLConfig) (*saml.EntityDescriptor, error) { defer cancel() // The hardened client: a deadline, no redirects into private space, // and a bounded body, the same as OIDC discovery. - md, err := samlsp.FetchMetadata(ctx, jwksclient.NewHTTPClient(metadataFetchTimeout), *u) + // The metadata URL is the customer's, so its host stays off the + // span. Built at connection load with no request around it, the + // fetch is measured but starts no trace of its own. + client := telemetry.InstrumentClient(jwksclient.NewHTTPClient(metadataFetchTimeout), + telemetry.Endpoint{Kind: telemetry.ProviderSAMLMetadata, HideHost: true}, cfg.tel) + md, err := samlsp.FetchMetadata(ctx, client, *u) if err != nil { return nil, fmt.Errorf("sso/saml: fetch metadata: %w", err) } diff --git a/sdkgen/openapi/spec.go b/sdkgen/openapi/spec.go index aecc29d7..9ec6775a 100644 --- a/sdkgen/openapi/spec.go +++ b/sdkgen/openapi/spec.go @@ -109,6 +109,51 @@ type Schema struct { Enum []string `json:"enum,omitempty"` } +// UnmarshalJSON reads a schema and folds forge's nullable reference wrapper +// back into a plain $ref. +// +// Since forge v1.12.9 a pointer element ([]*Connection) or a field tagged +// nullable is described as oneOf a $ref and a null-only object. The +// generators know nothing about oneOf, so without this every such list +// would come out as []any, unknown[] or List. The SDKs already +// treat a referenced element as possibly absent (a pointer in Go), so the +// null branch carries nothing they need. +func (s *Schema) UnmarshalJSON(data []byte) error { + type plain Schema + var raw struct { + plain + OneOf []*nullableBranch `json:"oneOf,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *s = Schema(raw.plain) + if s.Ref == "" && s.Type == "" && len(raw.OneOf) == 2 { + a, b := raw.OneOf[0], raw.OneOf[1] + switch { + case a.isRef() && b.isNullOnly(): + s.Ref = a.Ref + case b.isRef() && a.isNullOnly(): + s.Ref = b.Ref + } + } + return nil +} + +// nullableBranch is one member of a oneOf, kept only long enough to +// recognise forge's {$ref} + {enum: [null], nullable: true} pair. +type nullableBranch struct { + Ref string `json:"$ref,omitempty"` + Nullable bool `json:"nullable,omitempty"` + Enum []any `json:"enum,omitempty"` +} + +func (b *nullableBranch) isRef() bool { return b != nil && b.Ref != "" } + +func (b *nullableBranch) isNullOnly() bool { + return b != nil && b.Ref == "" && b.Nullable && len(b.Enum) == 1 && b.Enum[0] == nil +} + // Components holds a set of reusable objects. type Components struct { Schemas map[string]*Schema `json:"schemas,omitempty"` diff --git a/sdkgen/openapi/spec_nullable_test.go b/sdkgen/openapi/spec_nullable_test.go new file mode 100644 index 00000000..142aed95 --- /dev/null +++ b/sdkgen/openapi/spec_nullable_test.go @@ -0,0 +1,70 @@ +package openapi_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/authsome/sdkgen/openapi" +) + +// forge v1.12.9 wraps a pointer element in oneOf [$ref, null]. The loader +// must hand the generators the plain $ref, or every such list loses its +// element type. +func TestSchema_FoldsNullableRefWrapper(t *testing.T) { + const doc = `{ + "type": "object", + "properties": { + "connections": { + "type": "array", + "items": { + "oneOf": [ + {"$ref": "#/components/schemas/Connection"}, + {"enum": [null], "nullable": true, "type": "object"} + ] + } + } + } + }` + var s openapi.Schema + require.NoError(t, json.Unmarshal([]byte(doc), &s)) + items := s.Properties["connections"].Items + require.NotNil(t, items) + assert.Equal(t, "#/components/schemas/Connection", items.Ref) + + // The order of the two branches carries no meaning. + var flipped openapi.Schema + require.NoError(t, json.Unmarshal([]byte(`{"oneOf": [ + {"enum": [null], "nullable": true, "type": "object"}, + {"$ref": "#/components/schemas/Connection"} + ]}`), &flipped)) + assert.Equal(t, "#/components/schemas/Connection", flipped.Ref) +} + +// A oneOf that is not exactly that pair is left alone, so a real union is +// never silently narrowed to its first member. +func TestSchema_LeavesOtherOneOfAlone(t *testing.T) { + cases := map[string]string{ + "two refs": `{"oneOf": [ + {"$ref": "#/components/schemas/A"}, + {"$ref": "#/components/schemas/B"} + ]}`, + "ref and object": `{"oneOf": [ + {"$ref": "#/components/schemas/A"}, + {"type": "object"} + ]}`, + "non-null enum": `{"oneOf": [ + {"$ref": "#/components/schemas/A"}, + {"enum": ["x"], "nullable": true, "type": "string"} + ]}`, + } + for name, doc := range cases { + t.Run(name, func(t *testing.T) { + var s openapi.Schema + require.NoError(t, json.Unmarshal([]byte(doc), &s)) + assert.Empty(t, s.Ref) + }) + } +} diff --git a/sdkgen/spec.json b/sdkgen/spec.json index 8f17358f..89792475 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -398,7 +398,18 @@ "properties": { "connections": { "items": { - "$ref": "#/components/schemas/Connection" + "oneOf": [ + { + "$ref": "#/components/schemas/Connection" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" } @@ -537,7 +548,18 @@ }, "service_accounts": { "items": { - "$ref": "#/components/schemas/AdminServiceAccountResponse" + "oneOf": [ + { + "$ref": "#/components/schemas/AdminServiceAccountResponse" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" }, @@ -1755,7 +1777,18 @@ "consents": { "description": "List of consent records", "items": { - "$ref": "#/components/schemas/Consent" + "oneOf": [ + { + "$ref": "#/components/schemas/Consent" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" }, @@ -2435,7 +2468,18 @@ "definitions": { "description": "Setting definitions in this group", "items": { - "$ref": "#/components/schemas/Definition" + "oneOf": [ + { + "$ref": "#/components/schemas/Definition" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" }, @@ -3402,7 +3446,18 @@ "properties": { "methods": { "items": { - "$ref": "#/components/schemas/AuthMethod" + "oneOf": [ + { + "$ref": "#/components/schemas/AuthMethod" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" } @@ -3416,7 +3471,18 @@ "properties": { "clients": { "items": { - "$ref": "#/components/schemas/OAuth2Client" + "oneOf": [ + { + "$ref": "#/components/schemas/OAuth2Client" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" } @@ -4165,7 +4231,18 @@ "properties": { "credentials": { "items": { - "$ref": "#/components/schemas/CredentialInfo" + "oneOf": [ + { + "$ref": "#/components/schemas/CredentialInfo" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" } @@ -4638,7 +4715,18 @@ "settings": { "description": "Resolved settings with scope cascade details", "items": { - "$ref": "#/components/schemas/ResolvedSetting" + "oneOf": [ + { + "$ref": "#/components/schemas/ResolvedSetting" + }, + { + "enum": [ + null + ], + "nullable": true, + "type": "object" + } + ] }, "type": "array" } @@ -30685,7 +30773,7 @@ }, "/v1/orgs/invitations/accept": { "post": { - "description": "Accepts a pending organization invitation using the invitation token.", + "description": "Accepts a pending organization invitation using the invitation token. The signed-in user must own the email the invitation was sent to.", "operationId": "acceptInvitation", "requestBody": { "content": { diff --git a/service.go b/service.go index 9100c5ed..a1ca3e2b 100644 --- a/service.go +++ b/service.go @@ -29,6 +29,7 @@ import ( "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry" "github.com/xraph/authsome/middleware" "github.com/xraph/authsome/page" "github.com/xraph/authsome/principal" @@ -79,17 +80,10 @@ func (e *Engine) SignUp(ctx context.Context, req *account.SignUpRequest) (*user. return nil, nil, err } - // Breach check (fail-open on network errors) - if policy.CheckBreached { - breached, breachErr := account.NewBreachChecker().IsBreached(req.Password) - if breachErr != nil { - // Fail open, and say so: a breach check that could not run is a - // control that did not run. - e.count("control.degraded", req.AppID.String()) - } - if breached { - return nil, nil, account.ErrPasswordBreached - } + // Breach check. It fails open, and says so: a lookup that could not run + // is counted as a control that did not run. + if policy.CheckBreached && e.passwordBreached(ctx, req.AppID.String(), req.Password) { + return nil, nil, account.ErrPasswordBreached } // Plugin: before signup @@ -141,7 +135,7 @@ func (e *Engine) SignUp(ctx context.Context, req *account.SignUpRequest) (*user. } // Hash password using configured algorithm - hash, err := account.HashPasswordWithPolicy(req.Password, policy) + hash, err := e.hashPassword(ctx, req.Password, policy) if err != nil { return nil, nil, err } @@ -220,11 +214,27 @@ func (e *Engine) SignUp(ctx context.Context, req *account.SignUpRequest) (*user. } // SignIn authenticates a user and returns the user + session. -func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user.User, *session.Session, error) { +func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (_ *user.User, _ *session.Session, signInErr error) { if err := e.requireStarted(); err != nil { return nil, nil, err } + // The attempt is counted once. IssueSession counts it from the moment + // it is reached; a refusal before that is counted here on the way out. + // lookupFailed separates a store that could not answer from an + // identifier nobody holds, which is only ever "invalid". + issuing, lookupFailed := false, false + defer func() { + if issuing { + return + } + result := signInResult(signInErr) + if lookupFailed { + result = telemetry.SignInError + } + e.tel.SignIn(telemetry.MethodPassword, result) + }() + // Build lockout key from identifier + appID lockoutKey := e.lockoutKey(req) @@ -261,7 +271,7 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. // Plugin: before signin if err := e.plugins.EmitBeforeSignIn(ctx, req); err != nil { - return nil, nil, fmt.Errorf("authsome: before signin: %w", err) + return nil, nil, &hookRefusedError{fmt.Errorf("authsome: before signin: %w", err)} } // Normalize email for case-insensitive lookup (emails are stored lowercase). @@ -293,8 +303,10 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. if err != nil { // An unknown identifier pays the same hash a known one would, so - // the response time does not say which identifiers exist. - e.ConsumeDummyHash(req.Password) + // the response time does not say which identifiers exist. It gets + // the same verify password span too, so a trace doesn't either. + lookupFailed = !errors.Is(err, store.ErrNotFound) + e.consumeDummyVerify(ctx, req.Password) e.recordFailedSignin(ctx, req, lockoutKey) return nil, nil, account.ErrInvalidCredentials } @@ -302,7 +314,7 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. // Verify the password before the ban: a wrong password on a banned // account gets the generic answer, so probing a ban costs the right // password. - if checkErr := account.CheckPassword(u.PasswordHash, req.Password); checkErr != nil { + if checkErr := e.verifyPassword(ctx, u.PasswordHash, req.Password); checkErr != nil { e.recordFailedSignin(ctx, req, lockoutKey) return nil, nil, account.ErrInvalidCredentials } @@ -380,7 +392,7 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. // Transparent rehash if the password algorithm has changed (e.g. bcrypt→argon2id). policy := e.passwordPolicy() if account.NeedsRehash(u.PasswordHash, policy) { - if newHash, hashErr := account.HashPasswordWithPolicy(req.Password, policy); hashErr == nil { + if newHash, hashErr := e.hashPassword(ctx, req.Password, policy); hashErr == nil { u.PasswordHash = newHash u.UpdatedAt = time.Now() if updateErr := e.store.UpdateUser(ctx, u); updateErr != nil { @@ -394,6 +406,7 @@ func (e *Engine) SignIn(ctx context.Context, req *account.SignInRequest) (*user. // hooks/audit. SignIn keeps the signin-specific telemetry below // (after-signin plugin hook, signin audit) since those describe // the auth event rather than the session itself. + issuing = true result, err := e.IssueSession(ctx, &IssueSessionRequest{ User: u, AppID: req.AppID, @@ -501,7 +514,7 @@ type RefreshOpts struct { // a presented hash that is already in the revoked set is either a leaked // token being replayed or a double spend, so the whole family is revoked // and the caller refused. -func (e *Engine) refuseReplayedRefresh(ctx context.Context, presentedHash string, opts ...RefreshOpts) error { +func (e *Engine) refuseReplayedRefresh(ctx context.Context, presentedHash string, opts ...RefreshOpts) (replayErr error) { // Replay check: if the presented token's hash is already in the // revoked set, this is either a leaked token being replayed or a // double-spend. Either way: cascade-revoke the family and refuse. @@ -523,6 +536,9 @@ func (e *Engine) refuseReplayedRefresh(ctx context.Context, presentedHash string return account.ErrInvalidCredentials } e.count("refresh.replay_detected", "") + // The caller still gets the bare sentinel (finishRefresh unwraps + // this); only the refresh counter learns it was a replay. + defer func() { replayErr = &refreshRefusalError{telemetry.RefreshReplayDetected, replayErr} }() var ipAddr, userAgent string if len(opts) > 0 { @@ -558,6 +574,12 @@ func (e *Engine) refuseReplayedRefresh(ctx context.Context, presentedHash string } func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...RefreshOpts) (*session.Session, error) { + ctx, finish := e.startRefresh(ctx, telemetry.RefreshViaRefreshToken) + sess, err := e.refresh(ctx, refreshToken, opts...) + return sess, finish(err) +} + +func (e *Engine) refresh(ctx context.Context, refreshToken string, opts ...RefreshOpts) (*session.Session, error) { if err := e.refuseReplayedRefresh(ctx, store.HashToken(refreshToken), opts...); err != nil { return nil, err } @@ -581,6 +603,12 @@ func (e *Engine) Refresh(ctx context.Context, refreshToken string, opts ...Refre // for replay, matched to that client, and rotated with the same refusals as // any refresh, including the DPoP proof check for a bound session. func (e *Engine) RefreshForClient(ctx context.Context, refreshToken, clientID string, opts ...RefreshOpts) (*session.Session, error) { + ctx, finish := e.startRefresh(ctx, telemetry.RefreshViaClient) + sess, err := e.refreshForClient(ctx, refreshToken, clientID, opts...) + return sess, finish(err) +} + +func (e *Engine) refreshForClient(ctx context.Context, refreshToken, clientID string, opts ...RefreshOpts) (*session.Session, error) { if clientID == "" { return nil, account.ErrInvalidCredentials } @@ -607,6 +635,12 @@ func (e *Engine) RefreshForClient(ctx context.Context, refreshToken, clientID st // here too: bans, agent sessions, expiry, the absolute lifetime, binding and // DPoP. A session issued without a refresh token cannot be rotated this way. func (e *Engine) RefreshBySessionToken(ctx context.Context, sessionToken string, opts ...RefreshOpts) (*session.Session, error) { + ctx, finish := e.startRefresh(ctx, telemetry.RefreshViaSessionToken) + sess, err := e.refreshBySessionToken(ctx, sessionToken, opts...) + return sess, finish(err) +} + +func (e *Engine) refreshBySessionToken(ctx context.Context, sessionToken string, opts ...RefreshOpts) (*session.Session, error) { sess, err := e.store.GetSessionByToken(ctx, sessionToken) if err != nil { return nil, account.ErrInvalidCredentials @@ -628,6 +662,12 @@ func (e *Engine) RefreshBySessionToken(ctx context.Context, sessionToken string, // retires the ones nobody ever received. Everything Refresh enforces after // the lookup applies here too. func (e *Engine) RefreshBySessionID(ctx context.Context, sessionID id.SessionID, opts ...RefreshOpts) (*session.Session, error) { + ctx, finish := e.startRefresh(ctx, telemetry.RefreshViaSessionID) + sess, err := e.refreshBySessionID(ctx, sessionID, opts...) + return sess, finish(err) +} + +func (e *Engine) refreshBySessionID(ctx context.Context, sessionID id.SessionID, opts ...RefreshOpts) (*session.Session, error) { sess, err := e.store.GetSession(ctx, sessionID) if err != nil { return nil, account.ErrInvalidCredentials @@ -735,7 +775,7 @@ func (e *Engine) rotateSession(ctx context.Context, sess *session.Session, opts o = opts[0] } if dpopErr := e.verifyRefreshDPoP(ctx, sess, o); dpopErr != nil { - return nil, account.ErrInvalidCredentials + return nil, &refreshRefusalError{telemetry.RefreshDPoPRejected, account.ErrInvalidCredentials} } } @@ -1271,11 +1311,11 @@ func (e *Engine) checkPasswordHistory(ctx context.Context, userID id.UserID, new entries, err := e.passwordHistory.GetPasswordHistory(ctx, userID, e.config.Password.HistoryCount) if err != nil { e.logger.Warn("authsome: password history lookup failed", log.String("error", err.Error())) - e.count("control.degraded", "") + e.controlDegraded(telemetry.ControlPasswordHistory, "") return nil // fail open — don't block the user } for _, entry := range entries { - if account.CheckPassword(entry.Hash, newPassword) == nil { + if e.verifyPassword(ctx, entry.Hash, newPassword) == nil { return account.ErrPasswordReused } } @@ -1549,7 +1589,7 @@ func (e *Engine) ResetPassword(ctx context.Context, token, newPassword string) e // Breach check if policy.CheckBreached { - if breached, _ := account.NewBreachChecker().IsBreached(newPassword); breached { //nolint:errcheck // best-effort check + if e.passwordBreached(ctx, pr.AppID.String(), newPassword) { return account.ErrPasswordBreached } } @@ -1564,7 +1604,7 @@ func (e *Engine) ResetPassword(ctx context.Context, token, newPassword string) e return historyErr } - hash, err := account.HashPasswordWithPolicy(newPassword, policy) + hash, err := e.hashPassword(ctx, newPassword, policy) if err != nil { return err } @@ -1619,7 +1659,7 @@ func (e *Engine) ChangePassword(ctx context.Context, userID id.UserID, currentPa return fmt.Errorf("authsome: get user: %w", err) } - if verifyErr := account.CheckPassword(u.PasswordHash, currentPassword); verifyErr != nil { + if verifyErr := e.verifyPassword(ctx, u.PasswordHash, currentPassword); verifyErr != nil { return account.ErrInvalidCredentials } @@ -1630,7 +1670,7 @@ func (e *Engine) ChangePassword(ctx context.Context, userID id.UserID, currentPa // Breach check if policy.CheckBreached { - if breached, _ := account.NewBreachChecker().IsBreached(newPassword); breached { //nolint:errcheck // best-effort check + if e.passwordBreached(ctx, u.AppID.String(), newPassword) { return account.ErrPasswordBreached } } @@ -1640,7 +1680,7 @@ func (e *Engine) ChangePassword(ctx context.Context, userID id.UserID, currentPa return historyErr } - hash, err := account.HashPasswordWithPolicy(newPassword, policy) + hash, err := e.hashPassword(ctx, newPassword, policy) if err != nil { return err } @@ -2787,7 +2827,7 @@ func (e *Engine) AdminCreateUser(ctx context.Context, adminID id.UserID, appID i } // Hash password - hash, err := account.HashPasswordWithPolicy(password, policy) + hash, err := e.hashPassword(ctx, password, policy) if err != nil { return nil, fmt.Errorf("authsome: admin create user: hash password: %w", err) } diff --git a/telemetry_apikey_test.go b/telemetry_apikey_test.go new file mode 100644 index 00000000..2ef26e89 --- /dev/null +++ b/telemetry_apikey_test.go @@ -0,0 +1,72 @@ +package authsome_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + apikeyPlugin "github.com/xraph/authsome/plugins/apikey" +) + +// An API key authentication is a span and a count by result. An unknown +// prefix and a wrong secret are both "invalid", so neither the trace nor +// the counter says which prefixes exist. +func TestAPIKeyAuthenticationIsTracedAndCounted(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, s := newTestEngine(t, authsome.WithMetricsSink(reg), authsome.WithPlugin(apikeyPlugin.New())) + ctx := context.Background() + appID := signInApp(t) + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "apikey-trace@example.com", Password: "SecureP@ss123", FirstName: "K"}) + require.NoError(t, err) + rawKey, key := e2eCreateAPIKey(t, s, appID, u.ID) + + router := e2eRouter(eng) + require.NoError(t, router.GET("/test", func(c forge.Context) error { return c.NoContent(http.StatusOK) })) + call := func(key string) { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/test", http.NoBody) + req.Header.Set("X-API-Key", key) + req.Header.Set("X-App-ID", appID.String()) + router.ServeHTTP(httptest.NewRecorder(), req) + } + // Built from the real key at runtime: one with the right prefix and a + // wrong secret, one with a prefix nobody holds. + wrongSecret := rawKey[:len(rawKey)-4] + "zzzz" + marker := key.KeyPrefix[:len(key.KeyPrefix)-8] + other := strings.Repeat("0", 8) + if strings.HasSuffix(key.KeyPrefix, other) { + other = strings.Repeat("1", 8) + } + unknownPrefix := marker + other + rawKey[len(key.KeyPrefix):] + + n := len(rec.Ended()) + call(rawKey) + call(wrongSecret) + call(unknownPrefix) + + auths := telemetrytest.Named(spansSince(rec, n), "authenticate api key") + require.Len(t, auths, 3) + results := make([]string, 0, len(auths)) + for _, a := range auths { + results = append(results, telemetrytest.Attrs(a)["authsome.result"]) + assert.Equal(t, codes.Unset, a.Status().Code, "a refused key is an answer, not a failure") + for _, k := range []string{rawKey, wrongSecret, unknownPrefix} { + assert.NotContains(t, telemetrytest.Text(a), k) + } + } + assert.Equal(t, []string{"ok", "invalid", "invalid"}, results) + body := scrape() + assert.Contains(t, body, `forge_authsome_apikey_authentications_total{result="ok"} 1`) + assert.Contains(t, body, `forge_authsome_apikey_authentications_total{result="invalid"} 2`) +} diff --git a/telemetry_background_test.go b/telemetry_background_test.go new file mode 100644 index 00000000..3ae15c85 --- /dev/null +++ b/telemetry_background_test.go @@ -0,0 +1,199 @@ +package authsome_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/app" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/secutil" + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/session" + "github.com/xraph/authsome/store/memory" + + "github.com/xraph/warden" + wardenmem "github.com/xraph/warden/store/memory" +) + +func TestRetentionSweepIsARootSpanOnlyWhenItHoldsTheLease(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, s := newTestEngine(t, authsome.WithMetricsSink(reg)) + ctx := context.Background() + appID := signInApp(t) + + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "sweep@example.com", Password: "SecureP@ss123", FirstName: "S"}) + require.NoError(t, err) + old, err := account.NewSession(appID, u.ID, account.SessionConfig{TokenTTL: time.Hour, RefreshTokenTTL: time.Hour}) + require.NoError(t, err) + old.ExpiresAt = time.Now().Add(-90 * 24 * time.Hour) + old.RefreshTokenExpiresAt = old.ExpiresAt + require.NoError(t, s.CreateSession(ctx, old)) + + n := len(rec.Ended()) + removed, err := eng.SweepRetention(ctx) + require.NoError(t, err) + assert.GreaterOrEqual(t, removed, int64(1)) + // The lease is held for half the interval, so a second sweep right away + // behaves as another replica would: it skips, and starts no span. + _, err = eng.SweepRetention(ctx) + require.NoError(t, err) + + sweeps := telemetrytest.Named(spansSince(rec, n), "sweep retention") + require.Len(t, sweeps, 1) + assert.False(t, sweeps[0].Parent().IsValid(), "a sweep outside any request is a root span") + assert.Equal(t, "1", telemetrytest.Attrs(sweeps[0])["authsome.retention.removed"]) + + body := scrape() + assert.Contains(t, body, `forge_authsome_retention_sweeps_attempted_total{result="ok"} 1`) + assert.Contains(t, body, `forge_authsome_retention_sweeps_attempted_total{result="skipped"} 1`) + assert.Contains(t, body, `forge_authsome_retention_rows_deleted_total{kind="sessions"} 1`) +} + +// spanSpyStore records whether the queries background work makes carry a +// span. grove opens a db.client span under any valid span context, even an +// ended one, so a true here means the query lands in someone else's trace. +type spanSpyStore struct { + *memory.Store + + mu sync.Mutex + seen map[string][]bool + hashed chan struct{} + leased chan struct{} + hashOnce sync.Once + kvOnce sync.Once +} + +func (s *spanSpyStore) note(ctx context.Context, op string) { + s.mu.Lock() + defer s.mu.Unlock() + s.seen[op] = append(s.seen[op], trace.SpanContextFromContext(ctx).IsValid()) +} + +func (s *spanSpyStore) HashLegacyTokens(ctx context.Context, batch int) (int64, error) { + s.note(ctx, "hash legacy tokens") + defer s.hashOnce.Do(func() { close(s.hashed) }) + return s.Store.HashLegacyTokens(ctx, batch) +} + +func (s *spanSpyStore) KVSetNX(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) { + s.note(ctx, "retention lease") + defer s.kvOnce.Do(func() { close(s.leased) }) + return s.Store.KVSetNX(ctx, key, value, ttl) +} + +// forge runs Engine.Start inside a "start authsome" span. Nothing the +// engine leaves running may keep that span: not the legacy token sweep and +// not the retention sweeper's lease check on an idle tick. +func TestBackgroundWorkDropsTheBootSpan(t *testing.T) { + rec := telemetrytest.Spans(t) + spy := &spanSpyStore{Store: memory.New(), seen: map[string][]bool{}, hashed: make(chan struct{}), leased: make(chan struct{})} + appID := signInApp(t) + now := time.Now() + require.NoError(t, spy.CreateApp(context.Background(), &app.App{ + ID: appID, Name: "Platform", Slug: "platform", PublishableKey: "pk_test_authsome_bootspan", + IsPlatform: true, CreatedAt: now, UpdatedAt: now, + })) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + cfg := testEngineConfig() + cfg.Session.CleanupInterval = 10 * time.Millisecond + eng, err := authsome.NewEngine( + authsome.WithStore(spy), + authsome.WithWarden(w), + authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithDisableMigrate(), + authsome.WithConfig(cfg), + authsome.WithAppID(appID.String()), + ) + require.NoError(t, err) + + bootCtx, boot := telemetry.Start(context.Background(), "start authsome") + require.NoError(t, eng.Start(bootCtx)) + boot.End() + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + secutil.RelaxAuthDefaults(t, eng) + + for _, ch := range []chan struct{}{spy.hashed, spy.leased} { + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatal("background work never ran") + } + } + require.NoError(t, eng.Stop(context.Background())) + + spy.mu.Lock() + defer spy.mu.Unlock() + for op, valid := range spy.seen { + require.NotEmpty(t, valid, op) + for _, v := range valid { + assert.False(t, v, "%s ran under the boot span", op) + } + } + for _, s := range telemetrytest.Named(telemetrytest.Authsome(rec), "sweep retention") { + assert.False(t, s.Parent().IsValid(), "a background sweep is its own root, never a child of the boot span") + } +} + +// sessionSpy records the context GetSession ran under. +type sessionSpy struct { + *memory.Store + mu sync.Mutex + spanned []bool + canceled []bool +} + +func (s *sessionSpy) GetSession(ctx context.Context, sid id.SessionID) (*session.Session, error) { + s.mu.Lock() + s.spanned = append(s.spanned, trace.SpanContextFromContext(ctx).IsValid()) + s.canceled = append(s.canceled, ctx.Err() != nil) + s.mu.Unlock() + return s.Store.GetSession(ctx, sid) +} + +// The JWT session check used to run on context.Background(), so its store +// lookup was in no trace at all. It now runs on the request's values, under +// the request's span, and still ignores the request's cancellation exactly +// as before. +func TestJWTSessionCheckRunsUnderTheRequestButNotItsCancellation(t *testing.T) { + telemetrytest.Spans(t) + spy := &sessionSpy{Store: memory.New()} + appID := signInApp(t) + now := time.Now() + require.NoError(t, spy.CreateApp(context.Background(), &app.App{ + ID: appID, Name: "Platform", Slug: "platform", PublishableKey: "pk_test_authsome_jwtcheck", + IsPlatform: true, CreatedAt: now, UpdatedAt: now, + })) + w, err := warden.NewEngine(warden.WithStore(wardenmem.New())) + require.NoError(t, err) + eng, err := authsome.NewEngine( + authsome.WithStore(spy), authsome.WithWarden(w), authsome.WithChronicle(bridge.NewMemoryChronicle()), + authsome.WithDisableMigrate(), authsome.WithConfig(testEngineConfig()), authsome.WithAppID(appID.String()), + ) + require.NoError(t, err) + require.NoError(t, eng.Start(context.Background())) + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + + reqCtx, cancel := context.WithCancel(context.Background()) + reqCtx, span := telemetry.Start(reqCtx, "request") + cancel() + _, _ = eng.JWTSessionContextChecker(reqCtx, appID.String(), id.NewSessionID().String()) + span.End() + + spy.mu.Lock() + defer spy.mu.Unlock() + require.Len(t, spy.spanned, 1) + assert.True(t, spy.spanned[0], "the lookup carries the request's span") + assert.False(t, spy.canceled[0], "and not its cancellation") +} diff --git a/telemetry_dpop_test.go b/telemetry_dpop_test.go new file mode 100644 index 00000000..e88af9c7 --- /dev/null +++ b/telemetry_dpop_test.go @@ -0,0 +1,57 @@ +package authsome_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xraph/forge" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/plugins/magiclink" + "github.com/xraph/authsome/settings" +) + +// An app that mandates DPoP refuses a sign-in whose client can't prove a +// key. That is a policy refusal, so it counts as "denied", the same as a +// plugin hook's refusal, and not as a bad credential. +func TestDPoPMandateRefusalCountsAsDenied(t *testing.T) { + reg, scrape := telemetrytest.Registry(t) + ml := magiclink.New() + eng, s := newTestEngine(t, authsome.WithMetricsSink(reg), authsome.WithPlugin(ml)) + ctx := context.Background() + appID := signInApp(t) + u, _, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "dpop-denied@example.com", Password: "SecureP@ss123", FirstName: "D"}) + require.NoError(t, err) + // Mandate DPoP for the app the way the dashboard does. + raw, err := json.Marshal("required") + require.NoError(t, err) + require.NoError(t, eng.Settings().Set(ctx, "dpop.mode", raw, settings.ScopeApp, appID.String(), appID.String(), "", "test")) + + v, err := account.NewVerification(ctx, appID, u.ID, magiclink.VerificationTypeMagicLink, 5*time.Minute) + require.NoError(t, err) + require.NoError(t, s.CreateVerification(ctx, v)) + + mux := forge.NewRouter() + require.NoError(t, ml.RegisterRoutes(mux)) + body, err := json.Marshal(map[string]string{"token": v.Token}) + require.NoError(t, err) + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/v1/magic-link/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + + assert.ElementsMatch(t, []string{ + `forge_authsome_signin_attempts_total{method="magic_link",result="denied"}`, + }, telemetrytest.Series(scrape(), "forge_authsome_signin_attempts_total")) +} diff --git a/telemetry_providers_test.go b/telemetry_providers_test.go new file mode 100644 index 00000000..8b9f52be --- /dev/null +++ b/telemetry_providers_test.go @@ -0,0 +1,59 @@ +package authsome_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/bridge" + "github.com/xraph/authsome/internal/telemetry" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" +) + +func TestMailerAndSMSAreTracedAndMeasured(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + smsErr := errors.New("twilio: 21211 invalid 'To' phone number +15550100") + eng, _ := newTestEngine(t, + authsome.WithMetricsSink(reg), + authsome.WithMailer(bridge.MailerFunc(func(context.Context, *bridge.EmailMessage) error { return nil })), + authsome.WithSMSSender(smsFunc(func(context.Context, *bridge.SMSMessage) error { return smsErr })), + ) + ctx, parent := telemetry.Start(context.Background(), "request") + require.NoError(t, eng.Mailer().SendEmail(ctx, &bridge.EmailMessage{To: []string{"alice@example.com"}, Subject: "code 492817"})) + require.ErrorIs(t, eng.SMSSender().SendSMS(ctx, &bridge.SMSMessage{To: "+15550100", Body: "code 492817"}), smsErr) + parent.End() + + spans := telemetrytest.Authsome(rec) + email := telemetrytest.Named(spans, "send email") + sms := telemetrytest.Named(spans, "send sms") + require.Len(t, email, 1) + require.Len(t, sms, 1) + assert.Equal(t, parent.SpanContext().SpanID(), email[0].Parent().SpanID()) + assert.Equal(t, codes.Unset, email[0].Status().Code) + assert.Equal(t, codes.Error, sms[0].Status().Code) + for _, s := range append(email, sms...) { + text := telemetrytest.Text(s) + for _, leaked := range []string{"alice@example.com", "492817", "+15550100", "21211"} { + assert.NotContains(t, text, leaked) + } + } + body := scrape() + assert.Contains(t, body, `forge_authsome_provider_request_duration_seconds_count{kind="email",result="ok"} 1`) + assert.Contains(t, body, `forge_authsome_provider_request_duration_seconds_count{kind="sms",result="error"} 1`) + + // The no-op mailer is handed back as it is: nothing was sent, so + // nothing is measured. + noop, _ := newTestEngine(t, authsome.WithMailer(bridge.NewNoopMailer(nil))) + _, isNoop := noop.Mailer().(*bridge.NoopMailer) + assert.True(t, isNoop) +} + +type smsFunc func(context.Context, *bridge.SMSMessage) error + +func (f smsFunc) SendSMS(ctx context.Context, msg *bridge.SMSMessage) error { return f(ctx, msg) } diff --git a/telemetry_test.go b/telemetry_test.go new file mode 100644 index 00000000..4359e558 --- /dev/null +++ b/telemetry_test.go @@ -0,0 +1,183 @@ +package authsome_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + authsome "github.com/xraph/authsome" + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/internal/telemetry/telemetrytest" + "github.com/xraph/authsome/plugin" +) + +// spansSince returns the authsome spans that ended after the first n. +func spansSince(rec *tracetest.SpanRecorder, n int) []sdktrace.ReadOnlySpan { + return telemetrytest.OnlyAuthsome(rec.Ended()[n:]) +} + +func signInApp(t *testing.T) id.AppID { + t.Helper() + appID, err := id.ParseAppID("aapp_01jf0000000000000000000000") + require.NoError(t, err) + return appID +} + +// shape is what a span shows an operator: its name and its attributes. +type shape struct { + name string + attrs map[string]string +} + +func shapes(spans []sdktrace.ReadOnlySpan) []shape { + out := make([]shape, len(spans)) + for i, s := range spans { + out[i] = shape{s.Name(), telemetrytest.Attrs(s)} + } + return out +} + +func TestSignInIsTracedAndCounted(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, _ := newTestEngine(t, authsome.WithMetricsSink(reg)) + ctx := context.Background() + appID := signInApp(t) + + _, _, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "trace-ok@example.com", Password: "SecureP@ss123", FirstName: "T"}) + require.NoError(t, err) + assert.Len(t, telemetrytest.Named(telemetrytest.Authsome(rec), "hash password"), 1, "sign-up hashes once") + + n := len(rec.Ended()) + _, sess, err := eng.SignIn(ctx, &account.SignInRequest{AppID: appID, Email: "trace-ok@example.com", Password: "SecureP@ss123"}) + require.NoError(t, err) + require.NotNil(t, sess) + got := spansSince(rec, n) + assert.Equal(t, []string{"verify password", "issue session"}, telemetrytest.Names(got)) + issue := telemetrytest.Named(got, "issue session")[0] + assert.Equal(t, trace.SpanKindInternal, issue.SpanKind()) + assert.Equal(t, map[string]string{ + "authsome.auth.method": "password", + "authsome.mfa.verified": "false", + "authsome.mfa.required": "false", + "authsome.result": "ok", + "authsome.session.format": "opaque", + }, telemetrytest.Attrs(issue)) + + body := scrape() + assert.Contains(t, body, `forge_authsome_signin_attempts_total{method="password",result="ok"} 1`) + assert.Contains(t, body, `forge_authsome_password_hash_duration_seconds_count{algorithm="bcrypt",operation="hash"} 1`) + assert.Contains(t, body, `forge_authsome_password_hash_duration_seconds_count{algorithm="bcrypt",operation="verify"} 1`) + assert.Contains(t, body, `forge_authsome_events_total{action="auth.signin",outcome="success",resource="session"} 1`) +} + +// A wrong password and an identifier nobody holds must look the same in a +// trace and on a metric: same spans, same attributes, same result. +func TestSignInDoesNotTellAnUnknownIdentifierFromAWrongPassword(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, _ := newTestEngine(t, authsome.WithMetricsSink(reg)) + ctx := context.Background() + appID := signInApp(t) + + _, _, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "known@example.com", Password: "SecureP@ss123", FirstName: "K"}) + require.NoError(t, err) + + n := len(rec.Ended()) + _, _, err = eng.SignIn(ctx, &account.SignInRequest{AppID: appID, Email: "known@example.com", Password: "wrong-password"}) + require.ErrorIs(t, err, account.ErrInvalidCredentials) + wrong := spansSince(rec, n) + + n = len(rec.Ended()) + _, _, err = eng.SignIn(ctx, &account.SignInRequest{AppID: appID, Email: "nobody-here@example.com", Password: "wrong-password"}) + require.ErrorIs(t, err, account.ErrInvalidCredentials) + unknown := spansSince(rec, n) + + require.NotEmpty(t, wrong) + assert.Equal(t, shapes(wrong), shapes(unknown)) + for _, s := range append(wrong, unknown...) { + assert.Equal(t, codes.Unset, s.Status().Code, "a refused password is an answer, not a failure") + } + assert.Contains(t, scrape(), `forge_authsome_signin_attempts_total{method="password",result="invalid"} 2`) +} + +// refusingPlugin turns every sign-in away in its before hook. +type refusingPlugin struct{} + +func (refusingPlugin) Name() string { return "test-refuser" } +func (refusingPlugin) OnBeforeSignIn(context.Context, *account.SignInRequest) error { + return errors.New("risk score too high for alice@example.com") +} + +var _ plugin.BeforeSignIn = refusingPlugin{} + +func TestPluginHooksAreSpansAndARefusalIsDenied(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, _ := newTestEngine(t, authsome.WithMetricsSink(reg), authsome.WithPlugin(refusingPlugin{})) + ctx := context.Background() + appID := signInApp(t) + + _, _, err := eng.SignIn(ctx, &account.SignInRequest{AppID: appID, Email: "alice@example.com", Password: "whatever-it-is"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "risk score too high", "the caller sees the hook's error unchanged") + + hooks := telemetrytest.Named(telemetrytest.Authsome(rec), "run plugin hook") + require.Len(t, hooks, 1) + assert.Equal(t, map[string]string{ + "authsome.plugin.name": "test-refuser", + "authsome.plugin.hook": "before_sign_in", + "authsome.result": "refused", + }, telemetrytest.Attrs(hooks[0])) + assert.NotContains(t, telemetrytest.Text(hooks[0]), "alice@example.com") + assert.Contains(t, scrape(), `forge_authsome_signin_attempts_total{method="password",result="denied"} 1`) +} + +func TestRefreshIsTracedAndCountedByResult(t *testing.T) { + rec := telemetrytest.Spans(t) + reg, scrape := telemetrytest.Registry(t) + eng, _ := newTestEngine(t, authsome.WithMetricsSink(reg)) + ctx := context.Background() + appID := signInApp(t) + + _, sess, err := eng.SignUp(ctx, &account.SignUpRequest{AppID: appID, Email: "refresh-trace@example.com", Password: "SecureP@ss123", FirstName: "R"}) + require.NoError(t, err) + spent := sess.RefreshToken + + n := len(rec.Ended()) + _, err = eng.Refresh(ctx, spent) + require.NoError(t, err) + // Presenting the spent token again is a replay, and the caller still + // gets the generic answer. + _, err = eng.Refresh(ctx, spent) + require.ErrorIs(t, err, account.ErrInvalidCredentials) + _, err = eng.Refresh(ctx, "not-a-refresh-token") + require.ErrorIs(t, err, account.ErrInvalidCredentials) + + refreshes := telemetrytest.Named(spansSince(rec, n), "refresh session") + require.Len(t, refreshes, 3) + results := make([]string, 0, len(refreshes)) + for _, s := range refreshes { + attrs := telemetrytest.Attrs(s) + assert.Equal(t, "refresh_token", attrs["authsome.refresh.via"]) + results = append(results, attrs["authsome.result"]) + } + assert.Equal(t, []string{"ok", "replay_detected", "invalid"}, results) + + body := scrape() + for _, want := range []string{ + `forge_authsome_sessions_refreshed_total{result="ok"} 1`, + `forge_authsome_sessions_refreshed_total{result="replay_detected"} 1`, + `forge_authsome_sessions_refreshed_total{result="invalid"} 1`, + } { + assert.Contains(t, body, want) + } +}