Skip to content

Extension grants ​

This page answers one question: can you make wicket's token endpoint accept a custom grant_type (for example urn:example:grant-type:sms)? In v0.1.4 the answer is no. Below are the reasons and the alternatives available today.

Current status ​

Itemv0.1.4
Extension grant interface validators.ExtensionGrantValidatorExists
Token endpoint dispatch logic for unknown grant_typesExists; looks up registered extension validators
A way for the host to register a custom validatorDoes not exist
Actual result of sending a custom grant_typeunsupported_grant_type
Discovery document grant_types_supportedNever includes any extension type

Why you can't register one ​

  1. ServerConfig has no corresponding setter. All the ways for the host to inject behavior into wicket are on root.ServerConfig(). The only grant-validation-related ones are SetResourceOwnerValidator (password grant) and the CIBA user validator; there is none for extension grant validators.
  2. The container is fixed at compile time. The container returned by di.NewRoot() is pre-generated code; which services it can resolve is fixed at generation time, and there is no API to register additional services after NewRoot(). In the generated code, the validators.ExtensionGrantValidator interface has no registrations, so the list of extension validators the token endpoint gets is always empty.

What you observe ​

When a client declares a custom grant type, SetAllowedGrantTypes does not report an error:

go
xc := models.NewClient()
xc.ClientId = "ext-app"
// ... secrets, scopes
_ = xc.SetAllowedGrantTypes([]string{"urn:example:grant-type:sms"}) // returns nil

But when you request the token endpoint:

bash
curl -s -u ext-app:x-secret \
  -d grant_type=urn:example:grant-type:sms -d scope=orders.read \
  http://localhost:5000/connect/token
json
{"custom":{},"error":"unsupported_grant_type","error_description":""}

wicket's log shows No validator is registered for the grant type, with the grantType field set to the value from the request.

As for the discovery document, opts.Discovery.ShowExtensionGrantTypes defaults to true and appends registered extension types to grant_types_supported; since the list is empty, nothing is actually added.

What the interface looks like ​

For reference on the code structure (in v0.1.4, there is nowhere to register an implementation even if you write one):

go
// validators package
type ExtensionGrantValidator interface {
	Validate(ctx *validators_contexts.ExtensionGrantValidationContext) error
	GrantType() string
}

When handling an extension grant, the token endpoint checks in this order: does the client's AllowedGrantTypes include the type → is there a validator whose GrantType() matches → scope validation → call the validator → if the result carries a user principal, check that the user is active.

Alternatives ​

Option 1: password grant (when the credentials fit into username + password) ​

The password grant (resource owner password credentials) is off by default: the default implementation in the container rejects every request, and the discovery document does not list password. The host enables it by injecting its own validator with cfg.SetResourceOwnerValidator.

go
import (
	"time"

	"github.com/gonewx/wicket/validators"
	validators_contexts "github.com/gonewx/wicket/validators/contexts"
	validators_models "github.com/gonewx/wicket/validators/models"
)

type pwdValidator struct{}

func (pwdValidator) Validate(ctx *validators_contexts.ResourceOwnerPasswordValidationContext) error {
	// Example only: in practice, query the user store and compare password hashes in constant time
	if ctx.UserName == "alice" && ctx.Password == "s3cret" {
		ctx.Result = validators_models.NewGrantValidationResult(
			validators_models.WithGrantValidationResultSubject("user-1001", "pwd", time.Now(), nil, "local", nil),
		)
	}
	return nil
}

var _ validators.ResourceOwnerPasswordValidator = pwdValidator{}

Setup (before cfg.Freeze()):

go
pc := models.NewClient()
pc.ClientId = "legacy-app"
pc.ClientSecrets = []*models.Secret{s2} // s2.Value = utils.Sha256("pwd-secret")
pc.AllowedScopes = []string{"orders.read"}
if err := pc.SetAllowedGrantTypes(wicket.GrantTypesPassword); err != nil {
	log.Fatal(err)
}

cfg.SetClients([]*models.Client{client, pc})
cfg.SetResourceOwnerValidator(pwdValidator{})

The validator contract:

  • ctx.Result starts out as an invalid_grant error. If the validator leaves it unchanged, authentication fails: the token endpoint returns invalid_grant with error_description set to invalid_username_or_password.
  • On successful authentication, set the user principal with WithGrantValidationResultSubject. Its arguments are, in order: sub, authentication method (goes into amr), authentication time, additional claims, identity provider (goes into idp), and custom fields added to the token response.
  • Returning a non-nil error is also treated as invalid_grant.
  • ctx.Request is the validated token request; you can read client information and the raw parameters from it.

Observed (with the setup above):

bash
curl -s -u legacy-app:pwd-secret -d grant_type=password \
  -d username=alice -d password=s3cret -d scope=orders.read \
  http://localhost:5000/connect/token

The response contains access_token, expires_in: 3600, token_type: Bearer, and scope: orders.read. The access token payload has sub: user-1001, amr: ["pwd"], idp: local, and client_id: legacy-app, and the introspection endpoint reports it as active: true. Once the validator is injected, password appears in the discovery document's grant_types_supported; without it, the same request returns unsupported_grant_type.

If your custom credentials happen to consist of two parts, "identifier + one-time credential" (for example phone number + SMS code), you can borrow these two fields and verify them in the validator with your own rules. But be clear about the cost:

When the password grant is appropriate

The password grant requires the client to handle user credentials directly, and OAuth 2.0 security best practices no longer recommend it. Only offer it to first-party clients you control, and those clients should be able to keep a client secret safe. For third-party or browser-based scenarios, use Option 2.

Option 2: complete custom authentication on the login page and use the authorization code flow ​

For custom authentication methods that involve the user (SMS codes, QR-code login, third-party accounts, etc.), we recommend handling them on the host's login page: wicket redirects the user to the host login page, the host verifies the user by any means and establishes a sign-in session, then returns to the authorization endpoint to continue the standard authorization code flow. Tokens are issued by wicket through the standard flow, and client authentication, scope validation, and refresh tokens all work as usual.

wicket's built-in packages such as phoneverify, wechatverify, and oidcverify are designed for exactly this kind of "verify identity on the login page" scenario; see Related reading below.

Option 3: the host issues JWTs itself (special cases only) ​

The container has a *tools.TokenTools that can sign JWTs with arbitrary claims using wicket's signing key. In the host's own handler, resolve it from the request scope:

go
app.HandleFunc("/internal/issue", func(w http.ResponseWriter, r *http.Request) {
	// The host must first authenticate and authorize the caller itself
	scope := hosting.ScopeFrom(r.Context())
	tt := godi.GetRequired[*tools.TokenTools](scope)
	jwt, err := tt.IssueJwt(r.Context(), 300, []*claims.Claim{
		claims.NewClaim(claims.WithClaimType("sub"), claims.WithClaimValue("user-1001")),
		claims.NewClaim(claims.WithClaimType("aud"), claims.WithClaimValue("orders")),
		claims.NewClaim(claims.WithClaimType("scope"), claims.WithClaimValue("orders.read")),
	})
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Write([]byte(jwt))
})

In testing, the resulting token carries iss, iat, nbf, exp, and the claims you passed in, and it can be validated with the JWKS published in the discovery document. But it is not an access token in wicket's sense:

  • The JWT header typ is JWT, not the at+jwt that wicket access tokens use. In testing, passing it to wicket's introspection endpoint returned {"active":false}, and the log showed typ not match.
  • It does not go through the token endpoint: no client authentication, no scope or resource validation, no refresh token issuance, and no grant record. The host is responsible for all of these.

This option works only when the recipient is your own API, which validates signatures directly with the JWKS and does not require at+jwt. In most cases, prefer Option 1 or Option 2.

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