Skip to content

feat(oauth2): add an error hook so server_error causes are observable - #65

Merged
euskadi31 merged 1 commit into
masterfrom
feature/63-oauth2-error-hook
Aug 23, 2026
Merged

feat(oauth2): add an error hook so server_error causes are observable#65
euskadi31 merged 1 commit into
masterfrom
feature/63-oauth2-error-hook

Conversation

@euskadi31

Copy link
Copy Markdown
Contributor

Closes #63.

Problem

An RFC 6749 §5.2 response carries a code, a description and a URI — never
the cause. The grants and the endpoints attach the cause carefully
(oauth2.ErrServerError.WithCause(err), 14 sites), but writeOAuthError
read everything except Cause, and ServerConfig exposed no logger and no
hook. An operator facing a 500 on /token had no way, from anywhere, to
learn why.

RFC 7009 §2.2 made it worse on /revoke: revocation answers 200 OK even
when the revocation itself failed, and the failure was dropped
(_ = s.cfg.Storage.RevokeAccessToken(...)). A revocation that fails is
exactly the event an operator needs to see.

What this adds

A new public type oauth2.ErrorHook:

type ErrorHook func(ctx context.Context, err error)

wired at two levels:

ServerConfig.OnError — called with the normalized *oauth2.Error
envelope, cause intact, at the point the server decides on its answer:

  • every error serialized as an RFC 6749 §5.2 body (/token, /revoke,
    /introspect, metadata) — writeOAuthError became a *Server method
    taking the request context;
  • every error redirected back to the client by /authorize — same for
    redirectAuthorizeError;
  • the pre-redirect refusals /authorize answers with a bare 400
    (unknown client, unregistered redirect_uri, ParseForm), which left no
    trace at all — the LoadClient error in particular was swallowed
    entirely;
  • the best-effort revocations /revoke swallows to honour RFC 7009 §2.2.

grant.Config.OnError — the same hook for the family revocation a
grant swallows during BCP §8.10.3 reuse detection
(grant/refresh_token.go:63). The returned error stays
oauth2.ErrRefreshTokenReused, so errors.Is keeps working — the failure
travels through the hook, not through the returned error.

Usage:

srv, err := oauth2.NewServer(oauth2.ServerConfig{
    // ...
    OnError: func(ctx context.Context, err error) {
        if oauth2.IsCode(err) != oauth2.CodeServerError {
            return // expected 4xx traffic
        }

        slog.ErrorContext(ctx, "oauth2 server error", "err", err)
    },
})

Wire behaviour

Unchanged. Same codes, same statuses, same redirects, same bodies. The hook
is purely observational and optional — a nil OnError is a no-op, so this
is backward compatible for every existing ServerConfig / grant.Config
literal.

Design notes

  • The hook receives the whole envelope, not just the cause: it is
    strictly more informative (the cause is reachable via errors.Unwrap /
    errors.As) and lets the application filter on the OAuth2 code with
    oauth2.IsCode instead of guessing.
  • It fires for every §5.2 error, not only server_error — as the issue
    requested. Filtering by code is documented and shown in the example,
    otherwise normal 4xx traffic floods the logs.
  • It runs synchronously on the request goroutine (documented): a slow
    implementation slows the request down.
  • Lookup failures on /revoke stay quiet — an unknown token is not an
    incident, only a failed revocation is.

Files

  • oauth2/hook.go (new), server.go, token_endpoint.go,
    authorize_endpoint.go, revoke_endpoint.go, introspect_endpoint.go,
    metadata_endpoint.go, doc.go
  • oauth2/grant/grant.go, oauth2/grant/refresh_token.go
  • Tests: oauth2/hook_test.go, oauth2/grant/hook_test.go — 10 tests
    covering the server_error cause, the notified 4xx, the optional hook, the
    /authorize pre-redirect and redirect paths, failed access-token and
    family revocations, and the silence on an unknown token / a successful
    revocation.
  • Docs: CHANGELOG.md, docs/observability.md (new "OAuth2 error hook"
    section), examples/oauth2/main.go (demo wiring).

Checks

  • make test — 26 packages pass with -race; oauth2 coverage 90.9% ->
    91.0%, oauth2/grant 91.2%.
  • make lint — 0 issues (a nestif triggered by bestEffortRevoke was
    resolved by extracting revokeAccess / revokeFamily).
  • make build — OK.

An RFC 6749 §5.2 response carries a code, a description and a URI — never
the cause. A server_error therefore reached the client as an opaque 500 and
was dropped everywhere else, leaving an operator with nothing to diagnose.
RFC 7009 §2.2 made it worse on /revoke: a failed revocation still answers
200 OK, so the failure was unobservable too.

ServerConfig.OnError (type ErrorHook) is called with the normalized *Error
envelope — cause intact — at the point the server decides on its answer:
every RFC 6749 §5.2 body, every /authorize redirect, the pre-redirect
refusals answered with a bare 400, and the best-effort revocations /revoke
swallows. grant.Config.OnError does the same for the family revocation a
grant swallows during BCP §8.10.3 reuse detection.

The hook is purely observational — no response, status code or redirect
changes. Refs #63.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 32661203771

Coverage increased (+0.07%) to 91.755%

Details

  • Coverage increased (+0.07%) from the base build.
  • Patch coverage: 13 uncovered changes across 4 files (57 of 70 lines covered, 81.43%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
oauth2/authorize_endpoint.go 17 11 64.71%
oauth2/revoke_endpoint.go 20 16 80.0%
oauth2/introspect_endpoint.go 4 2 50.0%
oauth2/token_endpoint.go 10 9 90.0%
Total (8 files) 70 57 81.43%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 4257
Covered Lines: 3906
Line Coverage: 91.75%
Coverage Strength: 12.54 hits per line

💛 - Coveralls

@euskadi31
euskadi31 merged commit 18549e4 into master Aug 23, 2026
2 checks passed
@euskadi31
euskadi31 deleted the feature/63-oauth2-error-hook branch August 23, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

oauth2: the cause of a server_error is unobservable (no logger or error hook on ServerConfig)

2 participants