Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2393eab
build: re-pin forge v1.12.9, grove v1.8.0 and the forgery libraries
juicycleff Oct 18, 2026
42c9b0c
fix(sdkgen): keep element types for forge's nullable list items
juicycleff Oct 18, 2026
67b6446
chore(sdkgen): regenerate the spec for forge v1.12.9
juicycleff Oct 18, 2026
0665503
feat(telemetry): add a shared package for authsome's spans and metrics
juicycleff Oct 18, 2026
b47d8e8
feat(metrics)!: record authsome's families in the app registry, never…
juicycleff Oct 18, 2026
3953f90
feat(engine): trace and count sign-ins, refreshes, password hashing a…
juicycleff Oct 18, 2026
8d1a2f9
fix(engine): keep background work and request lookups on the right trace
juicycleff Oct 18, 2026
a42e592
feat(engine): trace and measure third-party calls without leaking URL…
juicycleff Oct 18, 2026
3010a30
feat(plugins): count every sign-in method, MFA check, API key and OAu…
juicycleff Oct 18, 2026
98b6e2c
feat(retention, sharedsignals): give background work a root span only…
juicycleff Oct 18, 2026
64aec57
test: prove no credential or personal data reaches spans or metrics
juicycleff Oct 18, 2026
d209a91
docs: describe authsome's spans and metrics and drop the claims that …
juicycleff Oct 18, 2026
0bf6b0d
ci(release): let the prepare job push the tag it creates
juicycleff Oct 18, 2026
322aa17
fix(telemetry): count a DPoP mandate refusal as a denied sign-in
juicycleff Oct 18, 2026
041413c
fix(account): let the breach check say when it could not run
juicycleff Oct 18, 2026
558ff4e
fix(retention): keep an overridden HubSpot host off the client span
juicycleff Oct 18, 2026
abab0e3
fix(api): make a duplicate sign-up look like a fresh one in traces an…
juicycleff Oct 18, 2026
714c0f7
fix(retention): stop logging CRM response text when a job dead-letters
juicycleff Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
46 changes: 37 additions & 9 deletions account/breach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -15,19 +16,36 @@ 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 {
client *http.Client
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/",
}
}
Expand All @@ -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()
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions account/breach_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
27 changes: 20 additions & 7 deletions api/auth_handlers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
})),
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
26 changes: 26 additions & 0 deletions api/captcha_telemetry.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading