Authorization code + PKCE
This page covers the standard sign-in flow with a user: the client redirects the browser to wicket's /connect/authorize; when wicket finds the user is not signed in, it hands the browser to the host application's login page; after sign-in, the browser returns to wicket, which issues an authorization code; finally, the client exchanges the code for tokens on its backend.
How this page was verified
The curl results on this page come from an experimental program actually run on v0.1.4: it adds the development cookie handler and login page shown below to the Quick Start example, and uses curl with cookies to walk the entire redirect chain. All Go snippets compile. Browser behavior on this page (forms, redirects) is simulated with curl.
Understand the division of responsibilities
wicket only implements the protocol endpoints. It does not provide a login UI, nor does it include a production-ready login cookie handler. In v0.1.4, the host is responsible for:
| Host is responsible for | wicket is responsible for |
|---|---|
| HTML and routes for the login, consent, and error pages | Validating /connect/authorize parameters and redirecting to these pages |
| Verifying username and password (or integrating external login) | Issuing authorization codes, verifying PKCE, issuing tokens |
Providing a cookie handler for the wicket authentication scheme (reading and writing sign-in state) | Using that handler to determine whether the current user is signed in |
User profile data (ProfileService) | The userinfo endpoint |
1. Configure the clients
web := models.NewClient()
web.ClientId = "web-app"
web.ClientSecrets = []*models.Secret{secretOf("web-secret")} // confidential client: server-side app
web.RedirectUris = []string{"http://127.0.0.1:8080/callback"}
web.PostLogoutRedirectUris = []string{"http://127.0.0.1:8080/signed-out"}
web.AllowOfflineAccess = true // allow requesting offline_access to get refresh tokens
web.AllowedScopes = []string{"openid", "profile", "orders.read"}
if err := web.SetAllowedGrantTypes(wicket.GrantTypesCode); err != nil {
log.Fatal(err)
}
spa := models.NewClient()
spa.ClientId = "spa"
spa.RequireClientSecret = false // public client: SPA / mobile, relies on PKCE only
spa.RedirectUris = []string{"http://127.0.0.1:8080/callback"}
spa.AllowedScopes = []string{"openid", "profile", "orders.read"}
if err := spa.SetAllowedGrantTypes(wicket.GrantTypesCode); err != nil {
log.Fatal(err)
}
cfg.SetIdentityScopes([]*models.IdentityResource{
models.IdentityResourcesOpenId(),
models.IdentityResourcesProfile(),
})secretOf is a small helper: call models.NewSecret() and set Value to utils.Sha256(plaintext).
Client fields relevant to this flow and their NewClient() defaults:
| Field | Default | Notes |
|---|---|---|
RedirectUris | empty | Must match exactly; unregistered addresses are rejected |
RequirePkce | true | Requests without code_challenge are rejected |
AllowPlainTextPkce | false | Only S256 is accepted by default |
RequireClientSecret | true | Public clients must explicitly set it to false |
RequireConsent | false | When true, the user is first redirected to the host's consent page |
AuthorizationCodeLifetime | 300 seconds | Authorization code lifetime |
IdentityTokenLifetime | 300 seconds | id_token lifetime |
AllowOfflineAccess | false | See Refresh tokens |
2. Tell wicket where the login page is
You must set LoginUrl explicitly
In UserInteractionOptions, LoginUrl, LoginReturnUrlParameter, and LogoutUrl default to empty strings (the constants /account/login and returnUrl exist, but are not set as defaults). If you don't set them, users who are not signed in are redirected to an invalid address (observed: /connect/?=%2Fconnect%2Fauthorize%2Fcallback...).
opts := godi.GetRequired[*options.WicketOptions](root)
opts.UserInteraction.LoginUrl = "/account/login"
opts.UserInteraction.LoginReturnUrlParameter = "returnUrl"
opts.UserInteraction.LogoutUrl = "/account/logout"
// The following three already have defaults; change them as needed:
// opts.UserInteraction.ConsentUrl = "/consent" (parameter name returnUrl)
// opts.UserInteraction.ErrorUrl = "/home/error" (parameter name errorId)3. Provide a login cookie handler
wicket uses the authentication scheme named wicket (constant wicket.DefaultCookieAuthenticationScheme) to determine whether the user is signed in. v0.1.4 has no built-in handler for this scheme, so the host needs to:
- Implement
auth.Handler, along withauth.SignInHandler/auth.SignOutHandler; - Register a handler factory with
cfg.SetAuthenticationHandlerProvider; - Register the scheme name with
AddSchemeon*authcore.SchemeProvider.
Below is the development implementation used by the experimental program: the cookie holds only a random ID, and sign-in state is kept in process memory. In production, replace it with a signed/encrypted cookie or a shared session store.
type devCookieHandler struct {
r *http.Request
w http.ResponseWriter
scheme *auth.Scheme
session services.UserSession
}
var tickets sync.Map // id -> *claims.ClaimsPrincipal (single-process development only)
func (h *devCookieHandler) Initialize(r *http.Request, w http.ResponseWriter, s *auth.Scheme) error {
h.r, h.w, h.scheme = r, w, s
return nil
}
func (h *devCookieHandler) Authenticate() (*auth.Result, error) {
c, err := h.r.Cookie(h.scheme.Name)
if err != nil {
return auth.NoResult(), nil
}
p, ok := tickets.Load(c.Value)
if !ok {
return auth.NoResult(), nil
}
return &auth.Result{Principal: p.(*claims.ClaimsPrincipal), Properties: auth.NewProperties()}, nil
}
func (h *devCookieHandler) SignIn(p *claims.ClaimsPrincipal, props *auth.Properties) error {
buf := make([]byte, 16)
_, _ = rand.Read(buf)
id := hex.EncodeToString(buf)
tickets.Store(id, p)
http.SetCookie(h.w, &http.Cookie{Name: h.scheme.Name, Value: id, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode})
// Create the wicket user session (written to session.Store when server-side sessions are enabled)
_, err := h.session.CreateSessionId(h.r.Context(), p, props)
return err
}
func (h *devCookieHandler) SignOut(*auth.Properties) error {
if c, err := h.r.Cookie(h.scheme.Name); err == nil {
tickets.Delete(c.Value)
}
http.SetCookie(h.w, &http.Cookie{Name: h.scheme.Name, Value: "", Path: "/", MaxAge: -1})
return nil
}
func (h *devCookieHandler) Challenge(*auth.Properties) error { return nil }
func (h *devCookieHandler) Forbid(*auth.Properties) error { return nil }Registration (before cfg.Freeze()):
cfg.SetAuthenticationHandlerProvider(wicket.DefaultCookieAuthenticationScheme,
func(p godi.Provider) auth.Handler {
return &devCookieHandler{session: godi.GetRequired[services.UserSession](p)}
})
if err := godi.GetRequired[*authcore.SchemeProvider](root).AddScheme(
auth.NewScheme(wicket.DefaultCookieAuthenticationScheme, "dev-cookie")); err != nil {
log.Fatal(err)
}Symptoms when a step is missing
AddSchemenot called:auth.Service.SignInsimply returnsniland writes no cookie. In testing, after the login page redirected back to/connect/authorize/callback, the browser was redirected to the login page again. If sign-in "succeeds" but you end up back on the login page, check this step first.AddSchemecalled but no handler registered:SignInreturns the errorsign-in is unsupported(observed).
4. Start the authorization request
The client first generates the PKCE parameters: code_verifier is a random string of 43–128 characters, and code_challenge = BASE64URL(SHA256(code_verifier)).
V=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
C=$(printf %s "$V" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
# C = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMThe browser visits:
GET /connect/authorize
?client_id=web-app
&response_type=code
&scope=openid%20profile%20orders.read%20offline_access
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fcallback
&state=xyz
&nonce=n-1
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256| Parameter | Required | Notes |
|---|---|---|
client_id, redirect_uri | Yes | redirect_uri must be in RedirectUris |
response_type | Yes | Use code for the authorization code flow |
scope | Yes | An id_token is issued when it includes openid |
code_challenge, code_challenge_method | Yes when RequirePkce | S256 recommended |
state | Recommended | Returned unchanged; protects against CSRF |
nonce | Recommended | Written into the id_token; protects against replay |
response_mode | No | Defaults to query; form_post and fragment are also supported |
When the user is not signed in, wicket returns a 302 to the host login page (observed):
HTTP/1.1 302 Found
Location: http://localhost:5000/account/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dweb-app%26code_challenge%3DE9Melhoa...%26state%3DxyzreturnUrl points to wicket's /connect/authorize/callback and carries the original request parameters.
5. Host login page
The login page does three things: validate returnUrl with InteractionService and retrieve the authorization context, verify the user, then write sign-in state with auth.Service.SignIn and redirect back to returnUrl. All services are resolved from the request scope (this requires hosting.ScopeMiddleware on an outer layer):
app.HandleFunc("/account/login", func(w http.ResponseWriter, r *http.Request) {
sp := hosting.ScopeFrom(r.Context())
returnUrl := r.URL.Query().Get("returnUrl")
// Accept only a returnUrl that wicket recognizes, to prevent open redirects
// (in testing, returnUrl=https://evil.example/ gives req == nil; we return 400 here)
req, err := godi.GetRequired[services.InteractionService](sp).
GetAuthorizationContext(r.Context(), returnUrl)
if err != nil || req == nil {
http.Error(w, "invalid returnUrl", http.StatusBadRequest)
return
}
if r.Method == http.MethodGet {
// Render the login form; you can show req.Client.ClientName, req.LoginHint, etc.
return
}
subjectID := checkPassword(r) // the host's own user verification
now := time.Now()
user := wicketmodels.NewLocalUser(wicketmodels.WithLocalUserSubjectId(subjectID))
user.DisplayName = "Alice"
user.AuthenticationTime = &now
user.AuthenticationMethods = []string{"pwd"}
principal, err := user.CreatePrincipal()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := godi.GetRequired[auth.Service](sp).SignIn(r, w,
wicket.DefaultCookieAuthenticationScheme, principal, auth.NewProperties()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, returnUrl, http.StatusFound)
})After the browser returns to /connect/authorize/callback, wicket confirms the user is signed in and redirects to the client (observed):
HTTP/1.1 302 Found
Location: http://127.0.0.1:8080/callback?code=5907BA0E3F12...&iss=http%3A%2F%2Flocalhost%3A5000&scope=openid+profile+orders.read+offline_access&state=xyzThe iss parameter in the callback can be used to defend against mix-up attacks; the client should verify that it equals the issuer.
Claims in the sign-in principal end up in tokens
In v0.1.4, claims such as name, amr, and auth_time produced by CreatePrincipal() appear in both the id_token and the access token (see below). Do not put anything in the sign-in principal that should not appear in tokens.
6. Exchange the authorization code for tokens
curl -s -u web-app:web-secret \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=http://127.0.0.1:8080/callback \
-d code_verifier=$V \
http://localhost:5000/connect/tokenPublic clients skip Basic authentication and include client_id=spa in the form instead. The observed response contains access_token, id_token, and refresh_token (because offline_access was requested). The id_token payload:
{
"amr": ["pwd"], "at_hash": "6rs1GDSrwppzB9k22f5OQQ", "aud": ["web-app"],
"auth_time": 1794539156, "exp": 1794539465, "iat": 1794539165,
"iss": "http://localhost:5000", "name": "Alice", "nbf": 1794539165,
"nonce": "n-1", "sub": "alice"
}The access token payload:
{
"amr": ["pwd"], "aud": ["orders"], "auth_time": 1794539156, "client_id": "web-app",
"exp": 1794542765, "iat": 1794539165, "iss": "http://localhost:5000", "name": "Alice",
"nbf": 1794539165, "scope": ["openid", "profile", "orders.read", "offline_access"], "sub": "alice"
}An authorization code can be used only once; redeeming it again returns invalid_grant. A mismatched code_verifier also returns invalid_grant (both observed).
7. userinfo
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:5000/connect/userinfoThe default ProfileService provides no profile data; in testing it returns only {"sub":"alice"}. To return name and other profile data, inject your own implementation:
type userProfile struct{ users map[string]map[string]string } // replace with the host user store
func (p *userProfile) GetProfileData(ctx *contexts.ProfileDataRequestContext) {
sub := ""
if c := ctx.Subject.FindFirst("sub"); c != nil {
sub, _ = c.Value.(string)
}
var issued []*claims.Claim
for t, v := range p.users[sub] {
issued = append(issued, claims.NewClaim(claims.WithClaimType(t), claims.WithClaimValue(v)))
}
ctx.AddRequestedClaims(issued) // keeps only the claim types requested by the current scopes
}
func (p *userProfile) IsActive(ctx *contexts.IsActiveContext) {
sub := ""
if c := ctx.Subject.FindFirst("sub"); c != nil {
sub, _ = c.Value.(string)
}
_, ctx.IsActive = p.users[sub]
}
cfg.SetProfileProvider(func() services.ProfileService { return prof })After injection, with profile data name, email, and website and scopes openid profile orders.read, userinfo returned {"name":"Alice Zhang","sub":"alice","website":"https://alice.example.com"} in testing — email was filtered out because the email scope was not requested. Users for whom IsActive returns false are sent back to the login page at the authorization endpoint.
8. Error page
In testing, the following authorization request errors all redirect to the host error page ErrorUrl?errorId=... first, rather than straight back to the client: redirect_uri not registered, missing code_challenge, requesting plain without AllowPlainTextPkce enabled, a code-only client requesting response_type=token, a client without AllowOfflineAccess requesting offline_access, and a nonexistent client_id. The error page reads the details with GetErrorContext:
msg, err := godi.GetRequired[services.InteractionService](hosting.ScopeFrom(r.Context())).
GetErrorContext(r.Context(), r.URL.Query().Get("errorId"))
// msg.Error / msg.ErrorDescription: e.g. invalid_request / "code challenge required"
// msg.RedirectUri: when redirect_uri is valid, the prebuilt error URL to return to the client; empty means no redirect is possibleDo not trust the error context directly
In v0.1.4, do not treat errorId or the error context it resolves to as confidential or trusted data. The error page should not redirect automatically based on msg.RedirectUri: if you need to redirect back to the client, first confirm that msg.RedirectUri (with the query string removed) is one of the registered RedirectUris of the client identified by msg.ClientId; otherwise, only display the error and do not redirect.
Related reading
- Refresh tokens:
offline_accessand current refresh token behavior - Sessions and logout: the logout page and
/connect/endsession - Embedding in a host application:
hosting.ScopeMiddlewareand middleware order - External login (Google / Facebook): adding third-party login to the login page