CIBA backchannel authentication
CIBA (Client Initiated Backchannel Authentication) is for scenarios where the initiator cannot reach the user's browser: for example, a service counter system handling a transaction for a customer, or a call center verifying a caller's identity. The client calls wicket from its backend, wicket records a pending request, the host application notifies the user and records the user's decision, and the client polls the token endpoint to get tokens.
All requests and responses on this page are actual results from running on v0.1.4. All Go snippets compile and have been run in the experimental program.
Scope of support
| Item | v0.1.4 status |
|---|---|
Endpoint POST /connect/ciba | Registered |
| Token delivery modes | poll only (discovery document backchannel_token_delivery_modes_supported: ["poll"]) |
ping / push | Not supported (ping has notification code, but it is not in the allowed list and is unverified; push is not implemented) |
| User confirmation | No built-in interaction service; the host updates the request store directly (see below) |
id_token | Not issued: even if the request includes openid, id_token in the token response is an empty string |
| Refresh tokens | Issued when the client has AllowOfflineAccess and the request includes offline_access |
1. Configure the client
ciba := models.NewClient()
ciba.ClientId = "ciba-client"
secret := models.NewSecret()
secret.Value = utils.Sha256("ciba-secret")
ciba.ClientSecrets = []*models.Secret{secret}
ciba.AllowedScopes = []string{"openid", "orders.read"}
ciba.AllowOfflineAccess = true // optional
if err := ciba.SetAllowedGrantTypes(wicket.GrantTypesBackchannelAuthentication); err != nil {
log.Fatal(err)
}
// Optional: override the global defaults
// interval := 10; ciba.PollingInterval = &interval
// lifetime := 120; ciba.BackchannelAuthenticationLifetime = &lifetimeLeaving BackchannelTokenDeliveryMode empty means poll. Global defaults live in WicketOptions.BackchannelAuthentication:
| Option | Default | Notes |
|---|---|---|
AllowedDeliveryModes | ["poll"] | Allowed delivery modes |
DefaultRequestLifetime | 300 seconds | Request lifetime; the client's requested_expiry can only shorten it |
DefaultPollingInterval | 5 seconds | The interval returned to the client |
SlowDownIntervalIncrement | 5 seconds | How much the request's polling interval increases each time slow_down is triggered |
In v0.1.4, no code reads BackchannelAuthentication.Enabled, so you cannot use it to turn CIBA off.
2. Replace the default user validator
The default validator is not for production
The default BackchannelAuthenticationUserValidator that wicket uses when none is injected is only for development and integration testing: it does not resolve or verify user hints against the host user store. In production, you must inject your own BackchannelAuthenticationUserValidator that accepts only hints resolving to real users; id_token_hint and login_hint_token must have their signatures validated before use.
Inject your own implementation that resolves the hint to a real user in the host user store:
// userValidator resolves login_hint to a real user in the host user store.
type userValidator struct {
lookup func(ctx context.Context, hint string) (subjectID string, ok bool)
}
func (v *userValidator) Validate(ctx context.Context, c *validators_models.BackchannelAuthenticationUserValidationContext) error {
if c.LoginHint == "" {
// This example does not accept id_token_hint / login_hint_token; to support them, validate the signature before reading sub
return errors.New("login_hint required")
}
sub, ok := v.lookup(ctx, c.LoginHint)
if !ok {
return errors.New("unknown user")
}
p, err := wicketmodels.NewLocalUser(wicketmodels.WithLocalUserSubjectId(sub)).CreatePrincipal()
if err != nil {
return err
}
c.Subject = p
return nil
}
cfg.SetBackchannelAuthenticationUserValidator(&userValidator{lookup: findUserByHint})When Validate returns any error, the endpoint always responds with invalid_request (observed for an unknown user: {"error":"invalid_request","error_description":""}).
Rely on Subject
In v0.1.4, the SubjectId field of the stored record is not necessarily equal to the user's sub; do not rely on it. Subject is the user principal resolved by the validator, and the sub in tokens comes from Subject. When the host notifies the user, read sub from Subject.
3. Send the request
curl -s -u ciba-client:ciba-secret \
-d "scope=openid orders.read" \
-d login_hint=alice@example.com \
-d "binding_message=Pay 42" \
http://localhost:5000/connect/ciba{"auth_req_id":"zUzdxlNeR9ql20ix3rJQ8W29kEw","expires_in":300,"interval":5}| Parameter | Notes |
|---|---|
scope | Required; returns invalid_scope when missing |
login_hint / login_hint_token / id_token_hint | At least one; returns invalid_request if all are missing |
binding_message | Short text shown to the user for cross-checking |
user_code | A code the user set up in advance; stored as is, and the host decides whether to check it |
requested_expiry | In seconds; can only be shorter than the default |
resource | Resource indicator; may be repeated |
Client authentication works the same as at the token endpoint (client_secret_basic / client_secret_post). A client not allowed the CIBA grant type gets unauthorized_client. Note that error bodies from this endpoint lack the custom field that the token endpoint includes.
4. Notify the user: wrap the request store
After a request passes validation, wicket calls BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest to save it, and notifies no one else. The most direct integration point is to wrap this store and push to the user after a successful save:
type notifyingRequests struct {
storage.BackchannelAuthenticationRequestStore
notify func(req *models.BackchannelAuthenticationRequest)
}
func (s *notifyingRequests) StoreBackchannelAuthenticationRequest(ctx context.Context, id string, req *models.BackchannelAuthenticationRequest) error {
if err := s.BackchannelAuthenticationRequestStore.StoreBackchannelAuthenticationRequest(ctx, id, req); err != nil {
return err
}
s.notify(req) // req.RequestId, req.Subject, req.BindingMessage, and req.RequestedScopes are available
return nil
}
requests := ¬ifyingRequests{
BackchannelAuthenticationRequestStore: myStore, // for development, use memory.NewMemoryBackchannelAuthenticationRequestStore()
notify: pushToUserDevice,
}
cfg.SetBackchannelAuthenticationRequestStore(requests)notify runs synchronously on the request path; hand off slow work to the host's own queue.
5. Record the user's decision
After the user taps "Approve / Deny" in an app or a web page, the host updates the request status directly:
func decide(ctx context.Context, store storage.BackchannelAuthenticationRequestStore, requestID string, approve bool) error {
req, err := store.FindBackchannelAuthenticationRequest(ctx, requestID)
if err != nil {
return err
}
if req.Status != models.BackchannelAuthenticationRequestStatusPending {
return errors.New("request is no longer pending")
}
if approve {
req.Status = models.BackchannelAuthenticationRequestStatusApproved
} else {
req.Status = models.BackchannelAuthenticationRequestStatusDenied
}
return store.UpdateBackchannelAuthenticationRequest(ctx, requestID, req)
}The confirmation page must first verify that the currently signed-in user is the user corresponding to req.Subject before calling decide. Status values are pending, approved, denied, and consumed; after issuing tokens, wicket sets the status to consumed.
6. Client polling
curl -s -u ciba-client:ciba-secret \
-d grant_type=urn:openid:params:grant-type:ciba \
-d auth_req_id=$AUTH_REQ_ID \
http://localhost:5000/connect/tokenResponses at each stage (the "belongs to another client" case is based on the source code; all others were observed):
| When | Response |
|---|---|
| User has not yet confirmed | {"custom":{},"error":"authorization_pending","error_description":""} |
Less than interval seconds since the last poll | slow_down (the request's interval increases by SlowDownIntervalIncrement) |
| User approved | 200 with access_token (sub is the confirming user); id_token is an empty string |
| User denied | access_denied |
| Past the lifetime | expired_token |
Already redeemed / auth_req_id belongs to another client / does not exist | invalid_grant |
Clients should honor the returned interval, and after receiving slow_down, increase the interval by 5 seconds before retrying.
Related reading
- Choosing a flow: the difference between CIBA and device authorization
- Storage ports: the
BackchannelAuthenticationRequestStorecontract - ServerConfig: injection methods such as
SetBackchannelAuthenticationUserValidator - Refresh tokens