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:
- The switch has no effect.
ProviderStoreandResolverare singletons created insidedi.NewRoot(), and theDynamicProviders.Enabledthey read at creation time is the defaultfalse. By the time the host gets*options.WicketOptions, they're already built; changingEnabledtotrueafterwards leavesroot.Ptr_Resolver().Enabled()atfalse, andRegisterreturnsdynamic provider subsystem is disabled. The same applies toStorageProvider,CacheTTL, andClock. - 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, andBearer. A scheme namedacme-ssogetsno authentication handler registered for scheme "acme-sso"(observed: registering that name statically and then callingAuthenticateproduces this error).Provider.Configis 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
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
type ProviderStore interface {
GetProvider(scheme string) (*Provider, error)
AllProviders() ([]*Provider, error)
PutProvider(provider *Provider) error
DeleteProvider(scheme string) error
}Contract semantics:
| Method | Semantics |
|---|---|
GetProvider | Returns (nil, nil) when not found; not an error |
AllProviders | Returns all records, including those with Enabled == false |
PutProvider | Inserts by Scheme or replaces the whole record |
DeleteProvider | Does 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
func NewResolver(store ProviderStore, clock Clock, enabled bool, cacheTTL time.Duration) *Resolver| Method | Behavior |
|---|---|
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 <= 0orclockisnil, nothing is cached and every read hits storage. - When
storeisnil, it falls back to in-memory storage. - Changes made through
Register/Removeare 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,
nilrecords, 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):
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(withProtocolasHandlerType) 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".