Skip to content

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 forwicket is responsible for
HTML and routes for the login, consent, and error pagesValidating /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 ​

go
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:

FieldDefaultNotes
RedirectUrisemptyMust match exactly; unregistered addresses are rejected
RequirePkcetrueRequests without code_challenge are rejected
AllowPlainTextPkcefalseOnly S256 is accepted by default
RequireClientSecrettruePublic clients must explicitly set it to false
RequireConsentfalseWhen true, the user is first redirected to the host's consent page
AuthorizationCodeLifetime300 secondsAuthorization code lifetime
IdentityTokenLifetime300 secondsid_token lifetime
AllowOfflineAccessfalseSee 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...).

go
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)

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:

  1. Implement auth.Handler, along with auth.SignInHandler / auth.SignOutHandler;
  2. Register a handler factory with cfg.SetAuthenticationHandlerProvider;
  3. Register the scheme name with AddScheme on *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.

go
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()):

go
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

  • AddScheme not called: auth.Service.SignIn simply returns nil and 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.
  • AddScheme called but no handler registered: SignIn returns the error sign-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)).

bash
V=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
C=$(printf %s "$V" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
# C = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

The browser visits:

text
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
ParameterRequiredNotes
client_id, redirect_uriYesredirect_uri must be in RedirectUris
response_typeYesUse code for the authorization code flow
scopeYesAn id_token is issued when it includes openid
code_challenge, code_challenge_methodYes when RequirePkceS256 recommended
stateRecommendedReturned unchanged; protects against CSRF
nonceRecommendedWritten into the id_token; protects against replay
response_modeNoDefaults 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):

text
HTTP/1.1 302 Found
Location: http://localhost:5000/account/login?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fclient_id%3Dweb-app%26code_challenge%3DE9Melhoa...%26state%3Dxyz

returnUrl 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):

go
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):

text
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=xyz

The 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 ​

bash
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/token

Public 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:

json
{
  "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:

json
{
  "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 ​

bash
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:5000/connect/userinfo

The default ProfileService provides no profile data; in testing it returns only {"sub":"alice"}. To return name and other profile data, inject your own implementation:

go
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:

go
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 possible

Do 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.

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