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
| Item | v0.1.4 |
|---|---|
Extension grant interface validators.ExtensionGrantValidator | Exists |
Token endpoint dispatch logic for unknown grant_types | Exists; looks up registered extension validators |
| A way for the host to register a custom validator | Does not exist |
Actual result of sending a custom grant_type | unsupported_grant_type |
Discovery document grant_types_supported | Never includes any extension type |
Why you can't register one
ServerConfighas no corresponding setter. All the ways for the host to inject behavior into wicket are onroot.ServerConfig(). The only grant-validation-related ones areSetResourceOwnerValidator(password grant) and the CIBA user validator; there is none for extension grant validators.- 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 afterNewRoot(). In the generated code, thevalidators.ExtensionGrantValidatorinterface 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:
xc := models.NewClient()
xc.ClientId = "ext-app"
// ... secrets, scopes
_ = xc.SetAllowedGrantTypes([]string{"urn:example:grant-type:sms"}) // returns nilBut when you request the token endpoint:
curl -s -u ext-app:x-secret \
-d grant_type=urn:example:grant-type:sms -d scope=orders.read \
http://localhost:5000/connect/token{"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):
// 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.
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()):
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.Resultstarts out as aninvalid_granterror. If the validator leaves it unchanged, authentication fails: the token endpoint returnsinvalid_grantwitherror_descriptionset toinvalid_username_or_password.- On successful authentication, set the user principal with
WithGrantValidationResultSubject. Its arguments are, in order:sub, authentication method (goes intoamr), authentication time, additional claims, identity provider (goes intoidp), and custom fields added to the token response. - Returning a non-nil
erroris also treated asinvalid_grant. ctx.Requestis the validated token request; you can read client information and the raw parameters from it.
Observed (with the setup above):
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/tokenThe 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:
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
typisJWT, not theat+jwtthat wicket access tokens use. In testing, passing it to wicket's introspection endpoint returned{"active":false}, and the log showedtyp 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.