Skip to content

Dynamic authentication schemes ​

This page covers Wicket's dynamic authentication scheme subsystem, which aims to solve "adding and removing login methods without restarting the service". It describes the current status first and then each component, so you can judge how far you can use it today.

Status (v0.1.4) ​

Currently can't be enabled end to end through the container

Both points below have been verified on v0.1.4:

  1. The switch has no effect. ProviderStore and Resolver are singletons created inside di.NewRoot(), and the DynamicProviders.Enabled they read at creation time is the default false. By the time the host gets *options.WicketOptions, they're already built; changing Enabled to true afterwards leaves root.Ptr_Resolver().Enabled() at false, and Register returns dynamic provider subsystem is disabled. The same applies to StorageProvider, CacheTTL, and Clock.
  2. No handler interprets the records. Even if a scheme appears in the scheme table, its handler is looked up in the container by scheme name, and the container registers handlers only for five names: wicket, wicket.external, Google, Facebook, and Bearer. A scheme named acme-sso gets no authentication handler registered for scheme "acme-sso" (observed: registering that name statically and then calling Authenticate produces this error). Provider.Config is treated as opaque data inside the package, and no handler reads it today.

So what you can use directly in v0.1.4 is the dynamicscheme package itself: a storage and resolution layer for scheme records with a TTL cache. You can use it in your own admin console to store "which login methods are enabled", and let host code decide how to present and wire them. When you need to add a scheme that users can actually sign in with at runtime, for now use the static wiring described in External login and restart.

The Provider record ​

go
type Provider struct {
	Scheme      string            // unique key, corresponds to the authentication scheme name
	DisplayName string            // display name
	Enabled     bool              // per-record switch; records with false are invisible to the resolver
	Protocol    string            // protocol identifier; becomes HandlerType when converted to a scheme
	Config      map[string]string // protocol parameters (authority, client_id, etc.), not interpreted by the package
}

Every record going into or out of storage within the package is deep-copied (including the Config map), so callers modifying a record they received doesn't affect stored data.

The ProviderStore interface ​

go
type ProviderStore interface {
	GetProvider(scheme string) (*Provider, error)
	AllProviders() ([]*Provider, error)
	PutProvider(provider *Provider) error
	DeleteProvider(scheme string) error
}

Contract semantics:

MethodSemantics
GetProviderReturns (nil, nil) when not found; not an error
AllProvidersReturns all records, including those with Enabled == false
PutProviderInserts by Scheme or replaces the whole record
DeleteProviderDoes nothing and returns nil when not found

The interface methods take no context.Context and have no tenant dimension—it's a single server-wide table. If you implement it with a database, you must handle timeouts inside your implementation.

Built-in implementation dynamicscheme.NewMemoryStore(): protected by a read-write lock; AllProviders sorts by scheme name; PutProvider rejects nil and empty scheme names.

Resolver: a resolution layer with a TTL cache ​

go
func NewResolver(store ProviderStore, clock Clock, enabled bool, cacheTTL time.Duration) *Resolver
MethodBehavior
Enabled()Whether it's enabled
Get(scheme)Returns the enabled record; returns (nil, false, nil) in all three cases: disabled subsystem, not found, or record individually disabled
All()All enabled records, sorted by scheme name; returns (nil, nil) when disabled
Register(p)Writes to storage and invalidates the cache immediately; errors when disabled
Remove(scheme)Deletes from storage and invalidates the cache immediately; errors when disabled

Caching rules:

  • The cache holds an immutable snapshot of the "enabled records". The expiry is the absolute instant clock.Now() + cacheTTL; the first read after expiry reloads from storage.
  • When cacheTTL <= 0 or clock is nil, nothing is cached and every read hits storage.
  • When store is nil, it falls back to in-memory storage.
  • Changes made through Register / Remove are visible immediately; changes that bypass the Resolver and write storage directly (for example, another process writing the database) become visible only after the TTL expires. In a multi-instance deployment, the TTL is the maximum inconsistency window between instances.
  • On reload, nil records, empty scheme names, and disabled records are filtered out, and the result is deduplicated and sorted by scheme name, even if the storage implementation returns duplicate or unordered data.

Clock only requires Now() time.Time and is compatible with services.SystemClock; the package never reads time.Now() directly.

Standalone usage example ​

The following code has been compiled and run (output shown in comments):

go
import (
	"fmt"
	"time"

	"github.com/gonewx/wicket/dynamicscheme"
	"github.com/gonewx/wicket/services/impl"
)

func main() {
	store := dynamicscheme.NewMemoryStore() // replace with your own persistent implementation in production
	res := dynamicscheme.NewResolver(store, impl.NewSystemClock(), true, 30*time.Second)

	_ = res.Register(&dynamicscheme.Provider{
		Scheme: "acme-sso", DisplayName: "Acme SSO", Enabled: true, Protocol: "oidc",
		Config: map[string]string{"authority": "https://sso.acme.example", "client_id": "wicket"},
	})
	_ = res.Register(&dynamicscheme.Provider{Scheme: "legacy", Enabled: false, Protocol: "oidc"})

	all, _ := res.All()
	for _, p := range all {
		fmt.Println(p.Scheme, p.DisplayName, p.Config["authority"]) // acme-sso Acme SSO https://sso.acme.example
	}
	_, ok, _ := res.Get("legacy")
	fmt.Println(ok) // false: an individually disabled record is invisible
}

Relationship to the authentication pipeline (design intent) ​

The auth.SchemeProvider in the container is a two-state wrapper:

  • When the subsystem is disabled, it passes the static scheme table through unchanged;
  • When the subsystem is enabled, it merges the static schemes with the enabled records in the Resolver: on a name clash the static scheme wins; dynamic records are converted into schemes with HandlesRequests = true (with Protocol as HandlerType) so the middleware calls them on the callback path; the default scheme is always chosen from static configuration only; if the Resolver errors, it logs a warn entry and degrades to a purely static view instead of failing the request.

This logic is already in place. What's missing are the two pieces described above: "when it gets enabled" and "building handlers by protocol".

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors