Skip to main content

Smart Link (HMAC SSO) — Design & Implementation

Document purpose​

This document is for Sequent developers. It describes how the externally generated, HMAC-based Smart Link is implemented as a Keycloak extension, why it is separate from the existing email "magic link", and the security properties it guarantees. For the integrator-facing contract, see the Smart Link SSO Integration guide.

1. Overview​

Smart Link lets an external application that already authenticated a voter send them straight into an election. The external application's backend mints a short-lived token, signed with a symmetric secret shared with Sequent, and the voter follows a link carrying it. Keycloak verifies the token and establishes the OIDC session.

This is the second-generation port of the first-generation IAM smart-link auth method (m_smart_link.py). The token envelope, message shape and HMAC-SHA256 calculation are unchanged, so existing generators can keep working once they use the configured second-generation election id and Keycloak URL. The runtime behavior is not identical; the deliberate differences are listed below.

The codebase already contains a smart_link package implementing an email magic link: Keycloak mints a signed action token, emails it, and the voter clicks it. That token is asymmetric (realm keys) and server-minted — an external app cannot produce it offline.

The feature in this document is the opposite trust model: a symmetric, client-minted token verified with a shared secret. The two coexist; this one is packaged as a separate Keycloak extension, smart-link-hmac-authenticator, and uses the shared login-bridge module to finish the login (see §5).

2. Goals​

  • Verify an external khmac:///sha-256;<hex>/<message> token using a per-event shared secret and the first-generation token format.
  • Require, from the integrator, only a change of host, Smart Link election id and secret — the token format is identical.
  • Establish a normal Keycloak OIDC session for the voting-portal client.
  • Be hardened: constant-time HMAC, reject expired and future-dated tokens, bind the token to one event, and never leak which check failed.

3. Compatibility with first generation​

Compatible:

  • Token envelope: khmac:///sha-256;<hex>/<message>.
  • Message shape: <user-id>:AuthEvent:<election-id>:vote:<unix_timestamp>, exactly five colon-separated fields. user-id cannot contain :.
  • HMAC calculation: lowercase-hex HMAC-SHA256 over the UTF-8 message with the shared secret.
  • Default external token lifetime: 90 seconds.

Different:

  • The direct endpoint is /realms/{realm}/election/{election-id}/public/login, because Keycloak owns the realm context and OIDC session. A root /election/{election-id}/public/login URL needs a reverse-proxy rule.
  • The expected election id is the election event id parsed from the event realm name. First generation used the IAM AuthEvent id directly.
  • First generation only checked expiry. This implementation also rejects tokens minted too far in the future, controlled by smart-link-clock-skew-secs.
  • Required attributes are intentionally smaller: email, tlf and exact text user-attribute matches. First-generation required-field machinery such as passwords, OTP codes, captchas, images, dictionaries, pipelines, parent-election checks, vote-count checks and post-verify hooks is not part of this Keycloak bridge.
  • Error responses are HTTP-oriented: disabled or misconfigured realms return 404; enabled authentication failures return a generic 401.
  • Session creation is delegated to a short-lived internal Keycloak action token; there is no second IAM backend authentication call returning IAM auth data.

4. Non-goals​

  • No new token format or crypto — HMAC-SHA256 over the existing message only.
  • No replacement of the email magic link or any other auth method.
  • No census management — voters must already exist in the realm (authorization stays with Sequent, authentication with the external app).

5. Architecture and components​

All Java lives in packages/keycloak-extensions/smart-link-hmac-authenticator/src/main/java/sequent/keycloak/authenticator/smart_link/hmac/.

ComponentResponsibility
HmacSmartLinkPure, JDK-only validation: parse the envelope, recompute the HMAC (constant-time), check structure, election id binding and the time window. No Keycloak dependency, so it is exhaustively unit-tested.
SmartLinkError / SmartLinkValidationExceptionTyped rejection reasons, logged server-side only.
HmacSmartLinkResourceJAX-RS endpoint GET /realms/{realm}/election/{election-id}/public/login. Reads realm-attribute config, validates the token, resolves the client and census user, then bridges to the action-token machinery.
HmacSmartLinkProviderExtends BaseRealmResourceProvider (free CORS preflight) and returns the resource.
HmacSmartLinkResourceProviderFactory@AutoService(RealmResourceProviderFactory.class), getId() = "election", which yields the first-generation-style URL path segment.
action-token-login-bridgeShared neutral module that owns the internal action-token type, handler, URL builder and CORS base resource used by both HMAC Smart Link and email magic links.

The action-token bridge​

The resource deliberately does not re-implement session creation. After the token validates and the user is resolved, it calls the neutral action-token-login-bridge module:

  1. Resolve the user in the current realm census. Unknown users fail authentication; this endpoint does not create voters.
  2. LoginBridge.createActionToken(...) — mint a short-lived, non-persistent internal action token for the voting-portal client.
  3. LoginBridge.linkFromActionToken(...) — build the executeActionToken URL.
  4. Return a 302 to it; LoginBridgeActionTokenHandler then creates the session and redirects to the portal with an OIDC code.

So the new code is a thin, security-critical translator: external symmetric token → validated → internal Keycloak action token → existing handler. The internal token is valid for 60 s and persistent = false, so it cannot be replayed.

Configuration (realm attributes)​

Config is stored as realm attributes on the event realm, set through harvest's update-realm-attributes route. Keys are defined once in packages/sequent-core/src/types/keycloak.rs and mirrored in HmacSmartLink:

Attribute (REALM_ATTR_SMARTLINK_*)Default
smart-link-enabledfalse
smart-link-shared-secretunset
smart-link-timeout-secs90
smart-link-clock-skew-secs5
smart-link-client-idvoting-portal
smart-link-required-attributesempty

smart-link-enabled=true is the feature switch. The shared secret is required only once the feature is enabled. A realm with smart-link-enabled unset or false returns 404 from the HMAC endpoint; a realm with Smart Link enabled but no shared secret also returns 404 and logs a server-side misconfiguration warning. Configured timeout/skew values must be positive; 0, negative or non-numeric values also return 404 and log a misconfiguration warning.

update_realm_attributes in packages/sequent-core/src/services/keycloak/realm_attributes.rs validates each value (boolean enable flag; non-blank bounded secret; positive integer timeouts; comma-separated required attribute names) and drops anything malformed.

The Smart Link election id used in both the /election/<election-id>/public/login path and the HMAC message is the election event id parsed from the event realm name. For example, realm tenant-acme-event-150017 makes the path and token use 150017.

smart-link-required-attributes is the second-generation equivalent of the first-generation Smart Link required extra-field check. It is intentionally small: a comma-separated list of request parameter names. The external token format is unchanged; these values are passed as ordinary query parameters on the same endpoint after auth-token.

Supported checks:

Required attributeUser-side value
emailUserModel.getEmail(), normalized case-insensitively.
tlfKeycloak user attribute sequent.read-only.mobile-number.
any other nameKeycloak user attribute with the same name, exact string match.

Unsupported first-generation field types (password, otp-code, captcha, image, dict, etc.) are deliberately out of scope for this HMAC bridge.

6. Flow description​

Event realms are still resolved by name, usually tenant-<tenant_id>-event-<election_event_id> (get_event_realm in sequent-core). Smart Link parses that realm name with the same Java helper used by the voting-portal token mapper and uses <election_event_id> as the expected Smart Link election id.

The path election id and the token's AuthEvent:<election-id>:vote field must both equal that parsed election event id. There is no separate realm attribute for overriding the Smart Link election id.

8. Security considerations​

Verification order​

  1. Enabled and configured? smart-link-enabled unset/false returns 404. Enabled with no secret also returns 404 and logs a misconfiguration warning. Token validation only runs when the realm is explicitly enabled and has a secret.
  2. Structural parse of envelope, digest (sha-256 only), 64-hex hash and the message. The message must have exactly five colon-separated fields, so : is rejected inside user-id.
  3. Permission / election id binding — the path election id, AuthEvent/vote, and token election id all match the expected Smart Link election id for the realm.
  4. HMAC — recomputed and compared in constant time (MessageDigest.isEqual) before any timing decision, so the time checks only ever run on authentic messages.
  5. Temporal window — the token must have been created in the past (timestamp <= now + skew, rejecting future-dated tokens) and still be valid (timestamp > now - timeout, rejecting expired ones). The expiry rule matches the first generation; the future-dated rejection is an added defense.
  6. Required attributes — if configured, request parameters must match the resolved census user before the internal action token is issued.

Other properties​

  • Bearer token, short-lived. The default 90 s window limits the blast radius of a leaked link. The internal bridge token is 60 s and single-use.
  • Generic errors. Disabled or misconfigured realms return 404. Once a realm is enabled and configured, token and login failures return one vague 401 ({"error":"authentication_failed"}); the specific SmartLinkError is only written to the server log, denying an attacker an oracle.
  • No secret on the client. Verification happens entirely server-side in Keycloak; the voting portal never receives the secret.
  • Secret at rest. The shared secret is a realm attribute, readable by realm admins via the admin API — the same trust level as the first generation's config secret. Rotate per event as needed.

9. Testing​

HmacSmartLinkTest covers the happy path plus every rejection: wrong secret, tampered message, mismatched election id, expired, future-dated, missing secret, malformed envelope, unsupported digest, wrong permission, empty user id, malformed short messages, realm-derived Smart Link election id selection, and rejection of : inside user-id. The "known vector" test asserts the Java HMAC equals the value produced by the Python/Scala/Go generators for the same message, locking token-level compatibility. SmartLinkRequiredAttributesTest covers comma-separated parsing and the supported email, tlf, and generic text attribute checks.

Because HmacSmartLink is JDK-only it runs as a fast unit test (mvn -pl smart-link-hmac-authenticator -am test). Resource/provider wiring should be covered by an e2e suite before release.

11. Future considerations​

  • Single-use external tokens. Parity with the first generation keeps tokens replayable within their window; a short-lived nonce cache keyed by HMAC could enforce strict one-time use if a client requires it.
  • Admin-portal UI. Surface the realm attributes in the event settings screen with a "rotate secret" action, rather than relying on the raw API.