Skip to content

Security Contracts

wiki edited this page Sep 4, 2026 · 1 revision

Security contracts

These interfaces let the security extension and the OpenAPI extension cooperate without either importing the other. rextension declares the shapes; the security extension implements them; OpenAPI, Swagger and the router read them.

SecuritySchemeAccessor

The minimum a scheme exposes so it can be documented and challenged with.

type SecuritySchemeAccessor interface {
	Name() string        // unique id — "bearer", "basic", "api-key"
	Type() string        // OpenAPI type — "http", "apiKey"
	Description() string // human-readable
	Challenge() string   // WWW-Authenticate value — "Bearer"
}

Optional capabilities

A scheme implements these only when they apply. Consumers type-assert.

// Carried in a named request parameter: an API key in a header, query or cookie.
type ParameterizedScheme interface {
	ParamName() string // "X-API-Key"
	Location() string  // "header" | "query" | "cookie"
}

// A bearer scheme that documents its token format.
type BearerFormatProvider interface {
	BearerFormat() string // "JWT"
}

// A scheme that documents which token claim carries the caller's roles.
type RoleClaimProvider interface {
	RolesClaim() string // "" when the scheme exposes no roles
}
if p, ok := scheme.(rextension.ParameterizedScheme); ok {
	doc.In = p.Location()
	doc.Name = p.ParamName()
}

Location() returns a plain string on purpose. It used to return a named type (security.APIKeyLocation), which is why the OpenAPI generator reached it with reflect.MethodByName("Location") and formatted the result with %s — a named type cannot satisfy an interface declaring Location() string. Returning string makes the interface expressible and deletes the reflection.

SecuredRouteAccessor

How a route declares what must authenticate it.

type SecuredRouteAccessor interface {
	RequiredSchemes() []string // empty or nil means public
}
type AdminRoute struct{ rxroute.Route }

func (r *AdminRoute) RequiredSchemes() []string { return []string{"bearer"} }

Read by the security middleware to decide what to enforce, by OpenAPI to emit the security block, and by Swagger to render the authorization panel.

The scheme registry

type SchemeRegistry interface {
	Register(schemes ...SecuritySchemeAccessor) // ignores nils and duplicate names
	Schemes() []SecuritySchemeAccessor          // snapshot, in registration order
	Lookup(name string) (SecuritySchemeAccessor, bool)
}

The registry is an instance in the DI container, not a package-level variable. The security extension registers it; consumers resolve it:

func (e *OpenAPIExtension) OnStart(ctx context.Context, r rextension.Rex) error {
	var reg rextension.SchemeRegistry
	if err := r.Container().Resolve(&reg); err != nil {
		return nil // no security extension configured — document nothing
	}
	for _, s := range reg.Schemes() {
		e.doc.AddSecurityScheme(s)
	}
	return nil
}

Why not a global. It was one, written by a package-level RegisterSecuritySchemes that replaced rather than appended and had no unregister. Two Rex instances in one process clobbered each other's schemes, and state leaked between tests in the same binary. An instance in the container has the lifetime of the application that owns it.

Making misconfiguration a startup failure

The contracts above describe intent; RouteValidator is what checks that the intent is satisfiable, before any listener binds. Two checks that are only possible at that moment:

  • A route requiring a scheme the application never registered. Previously a 500 on every request to that route, discoverable only by making one.
  • A route declaring roles for a scheme that cannot enforce them (no RoleClaimProvider). Previously served to every authenticated caller with no diagnostic anywhere — an endpoint marked "admin only" that was not.

Both are now deployment failures.

Guidance for scheme authors

  • Name() must be stable. It is the key routes use in RequiredSchemes and the key that appears in the OpenAPI document. Changing it is a breaking change for every route that names it.
  • Reject on failure with a Problem documentProblemUnauthorized for "authenticate", ProblemForbidden for "authenticated but not permitted" — and set WWW-Authenticate from Challenge() on the 401.
  • Never put the reason in detail. "token expired at 14:02 for subject u_1934" tells an attacker which half of a guess was right. Log it; answer with "credentials were not accepted".

Clone this wiki locally