External login (Google / Facebook)
This page explains how to let browser users sign in to an authorization server that embeds Wicket using a Google or Facebook account: wiring the handler, triggering the redirect, handling the callback, and cleaning up on failure.
The short version: four things the host must do
Wicket ships OAuth 2.0 authorization code handlers for Google and Facebook, but in v0.1.4 the container doesn't configure them for you. To get external login working end to end, the host must:
- Provide a handler with credentials: hand it to the container through
cfg.SetAuthenticationHandlerProvider(schemeName, factory). The container's built-in default handler reads options whoseClientId/ClientSecretare empty, and there's no configuration hook for the host to fill them in. - Register the authentication scheme: the static scheme table is empty when the container starts. Register the scheme with
auth.NewRequestHandlerSchemeso the middleware calls the handler on the callback path. - Register and implement a cookie scheme: after a successful callback the user must be signed into a cookie scheme (
wicketby default). In the container this scheme has only a placeholder handler that doesn't support sign-in, and by default it isn't in the scheme table—the host must both implement the handler and register the scheme. If you forget to register it, the callback "appears to succeed" but no session is created; see How the callback is handled. - (Recommended) Set a failure path:
cfg.SetExternalFailurePath(...); otherwise a failed callback returns 500.
Complete wiring example
The following code has been compiled and tested on v0.1.4: visiting /login/google yields a 302 to Google's authorization endpoint, and a failed callback yields a 302 to the failure page.
import (
"log/slog"
"github.com/gonewx/godi"
wicket "github.com/gonewx/wicket"
"github.com/gonewx/wicket/auth"
"github.com/gonewx/wicket/auth/google"
oauthcore "github.com/gonewx/wicket/auth/oauth/core"
"github.com/gonewx/wicket/di"
optspkg "github.com/gonewx/wicket/di/options"
"github.com/gonewx/wicket/dp"
"github.com/gonewx/wicket/services"
)
// fixedMonitor hands a fully configured set of options to the handler (the handler reads options through an OptionsMonitor).
type fixedMonitor[T optspkg.Entity[T]] struct{ opts T }
func (m fixedMonitor[T]) CurrentValue() (T, error) { return m.opts, nil }
func (m fixedMonitor[T]) Get(string) (T, error) { return m.opts, nil }
// newGoogleOptions builds the options and fills in runtime dependencies: the protection format for state and the backchannel HTTP client.
func newGoogleOptions(protection dp.DataProtectionProvider, clientID, clientSecret string) (*google.Options, error) {
o := google.NewOptions()
o.SetClientId(clientID)
o.SetClientSecret(clientSecret)
o.SetDataProtectionProvider(protection)
if err := oauthcore.NewPostConfigureOptions[*google.Options](protection).
PostConfigure(google.AuthenticationScheme, o); err != nil {
return nil, err
}
return o, o.Validate()
}
func wireGoogle(root *di.Root, logger *slog.Logger, protection dp.DataProtectionProvider) error {
cfg := root.ServerConfig()
opts, err := newGoogleOptions(protection, "<client-id>", "<client-secret>")
if err != nil {
return err // surface missing credentials at startup
}
cfg.SetAuthenticationHandlerProvider(wicket.GoogleAuthenticationScheme, func(p godi.Provider) auth.Handler {
return google.NewHandler(fixedMonitor[*google.Options]{opts}, logger,
godi.GetRequired[services.SystemClock](p))
})
cfg.SetExternalFailurePath("/account/external-failure")
// Have the middleware call this handler on /signin-google
return root.Ptr_SchemeProvider().AddScheme(
auth.NewRequestHandlerScheme(wicket.GoogleAuthenticationScheme, "google"))
}Call wireGoogle during wiring, before cfg.Freeze(), and it must run before srv.UseWicket(): the failure path is read when UseWicket() builds the middleware, so setting it afterwards has no effect. The rest of the wiring (signing keys, clients, wrapping with hosting.ScopeMiddleware) is the same as in Quick start.
For the protection parameter, see Protecting state below.
Triggering the redirect
In the host's own route, get auth.Service from the request scope and issue a challenge against the external scheme. RedirectUri decides where the user is sent after a successful callback; if it's empty, the user returns to the address that issued the challenge:
app.HandleFunc("/login/google", func(w http.ResponseWriter, r *http.Request) {
svc := godi.GetRequired[auth.Service](hosting.ScopeFrom(r.Context()))
props := auth.NewProperties()
back := r.URL.Query().Get("returnUrl") // where to return after login
// Accept only on-site paths to prevent open redirects
if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || strings.Contains(back, `\`) {
back = "/"
}
props.SetRedirectUri(&back)
if err := svc.Challenge(r, w, wicket.GoogleAuthenticationScheme, props); err != nil {
http.Error(w, "login unavailable", http.StatusInternalServerError)
}
})The handler writes a correlation cookie named .wicket.Correlation.<random value> (for CSRF protection) and returns a 302 to the authorization endpoint. The query string contains client_id, redirect_uri (built from the current request's scheme and host, with the callback path as the path), response_type=code, scope, and state.
How the callback is handled
On every request, Wicket's authentication middleware does the following in order:
- Runs Authenticate once against the default authentication scheme (
wicket, if registered); - Enumerates all schemes that "handle requests" and asks each handler whether the current path equals its
CallbackPath; - The matching handler validates the state and correlation cookie, exchanges the code for tokens, calls the user info endpoint, and builds the identity from the claim mappings;
- Calls
SignIn(SignInScheme, ...)to write the identity into the cookie scheme—whenSignInSchemeisn't set, the default sign-in scheme is used (DefaultSignInSchemeis empty, so it falls back toDefaultScheme, i.e.wicket); - Returns a 302 to
RedirectUri, or to/if it isn't set.
The outcome of step 4 depends on the state of the sign-in scheme. The table below comes from running the full callback on v0.1.4 against a stub token endpoint and a stub user info endpoint:
State of the sign-in scheme wicket | Callback response | Session created? |
|---|---|---|
| Not registered in the scheme table | 302 /welcome (appears to succeed) | No—in this case v0.1.4 neither returns an error nor logs anything |
| Registered, but still using the container's placeholder handler | 302 to the failure page, log sign-in is unsupported | No |
Registered, with an auth.SignInHandler provided by the host | 302 /welcome | Yes: SignIn receives an identity with NameIdentifier = g-123 and Email = a@example.com |
The first row is a silent failure
Without a registered sign-in scheme, the user is sent to the target page as usual but isn't actually signed in, and is asked to log in again on reaching the authorization endpoint. Before going live, confirm that the cookie really is written after the callback.
Once the user is signed in to the wicket session, entering /connect/authorize again treats them as authenticated, and Wicket continues with the normal authorization code flow.
The cookie handler the host must provide
The two cookie scheme names are defined in the root package:
| Constant | Value | Purpose |
|---|---|---|
wicket.DefaultCookieAuthenticationScheme | wicket | The main session, and also DefaultScheme |
wicket.ExternalCookieAuthenticationScheme | wicket.external | A temporary identity from external login (if you choose to sign in here first and complete account linking on your own page) |
The default handlers the container registers for these two names don't support challenge or forbid, and don't implement sign-in. The host must implement auth.SignInHandler (and auth.SignOutHandler, used for sign-out), hand it to the container with SetAuthenticationHandlerProvider, and then register the scheme in root.Ptr_SchemeProvider() with auth.NewScheme:
type SignInHandler interface {
Initialize(*http.Request, http.ResponseWriter, *auth.Scheme) error
Authenticate() (*auth.Result, error)
Challenge(*auth.Properties) error
Forbid(*auth.Properties) error
SignIn(*claims.ClaimsPrincipal, *auth.Properties) error
}The non-test code in v0.1.4 contains no handler implementation with a SignIn method (only the authentication service itself, which forwards the call), which is why the host must provide one.
Implementation notes: SignIn serializes the identity, encrypts it, and writes it into the cookie; Authenticate reads it back and restores it as auth.Success(ticket). The cookie contents must have confidentiality and integrity protection (see the next section). To sign in to wicket.external first, call SetSignInScheme(wicket.ExternalCookieAuthenticationScheme) on the external scheme's options.
Protecting state
The state in the OAuth round trip is serialized by StateDataFormat and protected by a DataProtectionProvider.
The default DataProtectionProvider is not for production
The default dp.DataProtectionProvider registered in the container (root.DataProtectionProvider()) is for development only and isn't suitable for production. In production, implement your own dp.DataProtectionProvider (for example based on AES-GCM with key rotation support) and pass it into the options as in the example above, rather than using the container default.
The dp package's interface is small:
type DataProtectionProvider interface{ Create(purpose string) DataProtector }
type DataProtector interface {
Create(purpose string) DataProtector
Protect(plaintext []byte) []byte
Unprotect(protected []byte) ([]byte, error)
}In a multi-instance deployment, all instances must use the same set of keys; otherwise a request that starts on instance A and calls back to instance B fails because the state can't be decrypted.
Google options
| Item | Value |
|---|---|
| Scheme name | Google (wicket.GoogleAuthenticationScheme, which is also google.AuthenticationScheme) |
| Authorization endpoint | https://accounts.google.com/o/oauth2/v2/auth |
| Token endpoint | https://oauth2.googleapis.com/token |
| User info endpoint | https://www.googleapis.com/oauth2/v2/userinfo |
| Default callback path | /signin-google |
| Default scopes | openid, profile, email |
On top of the common OAuth options, google.Options adds just one field, AccessType *string: when it's nil (the default), no access_type parameter is sent; set it to "offline" when you need Google to return a refresh token.
Default claim mappings (left: the claim type in Wicket; right: the field in the user info JSON):
| Claim type | JSON field |
|---|---|
claims.NameIdentifier | id |
claims.Name (value name) | name |
claims.GivenName | given_name |
claims.Surname | family_name |
urn:google:profile | link |
claims.Email | email |
Constants such as NameIdentifier and GivenName have long URI-form values (for example http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier). When reading claims, use the constants in the claims package rather than hand-written strings.
Facebook options
| Item | Value |
|---|---|
| Scheme name | Facebook (wicket.FacebookAuthenticationScheme) |
| Authorization endpoint | https://www.facebook.com/v11.0/dialog/oauth |
| Token endpoint | https://graph.facebook.com/v11.0/oauth/access_token |
| User info endpoint | https://graph.facebook.com/v11.0/me |
| Default callback path | /signin-facebook |
| Default scope | email |
| Default requested fields | name, email, first_name, last_name |
Facebook uses the terms "App ID / App Secret"; SetAppId / SetAppSecret are just aliases for SetClientId / SetClientSecret. Other fields:
SendAppSecretProof bool: defaults totrue; attachesappsecret_proofwhen requesting user info.AddFields(...): appends fields to fetch from the Graph API (deduplicated automatically), such asbirthday,gender,locale,location.
The Graph API version is hard-coded as v11.0. To use another version, override it with SetAuthorizationEndpoint / SetTokenEndpoint / SetUserInformationEndpoint.
Default claim mappings: id→NameIdentifier, email→Email, name→Name, first_name→GivenName, last_name→Surname, birthday→DateOfBirth, gender→Gender, locale→Locality, plus the urn:facebook:-prefixed age_range_min, age_range_max (from age_range.min/max), middle_name, link, location (from location.name), and timezone.
Validate isn't run automatically
facebook.Options.Validate() checks that AppId and AppSecret are non-empty, and the Validate() that google.Options inherits checks the client id/secret, endpoints, and callback path—but the v0.1.4 container doesn't call them automatically. Call them yourself during wiring, as in the example, so configuration errors surface at startup.
Common options at a glance
Both the Google and Facebook options embed oauthcore.Options (which in turn embeds the common remote authentication options). Frequently used setters:
| Method | Default | Description |
|---|---|---|
SetClientId / SetClientSecret | Empty | Credentials assigned by the provider |
SetCallbackPath | Provider-specific | Callback path; must match what's registered in the provider's console |
AddScope / SetScope / ClearScope | Provider-specific | Requested permissions |
SetUsePkce | false | Whether to use PKCE in the authorization request |
SetSignInScheme | Empty (falls back to the default sign-in scheme) | Which cookie scheme to sign in to after a successful callback |
SetSaveTokens | false | Whether to store the external tokens in the authentication properties |
SetRemoteAuthenticationTimeout | 15 minutes | Time limit for the whole round trip |
SetBackchannelTimeout | 60 seconds | HTTP timeout for exchanging tokens and fetching user info |
SetBackchannelHttpHandler | None | Custom transport (proxy, test double) |
AddClaimAction / RemoveClaimAction / ClearClaimActions | See tables above | Adjust claim mappings |
SetDataProtectionProvider / SetStateDataFormat | See previous section | How state is protected |
Event hooks live on oauthcore.Events: OnCreatingTicket (before the identity is created; you can add claims) and OnRedirectToAuthorizationEndpoint (before the redirect is sent).
Failure handling
Any failure on the callback path—missing or invalid state, a mismatched correlation cookie, a failed token exchange, a failed user info request—causes the middleware to terminate the request instead of falling through to the host's 404:
SetExternalFailurePath set? | Response |
|---|---|
Set, e.g. /account/external-failure | 302 Location: /account/external-failure?error=external_login_failed |
| Not set | 500, with body External login failed. |
external_login_failed (the constant auth.ExternalLoginFailedError) is the only error code and doesn't distinguish the specific cause; details are written only to the log (request-handler processing failed) and never appear in the response. The host implements the failure page: read the error parameter and show your own message.
Observed behavior (visiting the callback with a forged state):
GET /signin-google?code=abc&state=bogus
→ 302 /account/external-failure?error=external_login_failed
log: request-handler processing failed scheme=Google error="... The oauth state was missing or invalid."