craftpulse / craft-auth-kit
Foundational authentication primitives for Craft CMS — passwordless tokens (magic links + email OTP), passkey wrappers, a recent-auth gate, and a password-validator contract. The shared base for the CraftPulse security ecosystem.
Package info
github.com/craftpulse/craft-auth-kit
Type:craft-plugin
pkg:composer/craftpulse/craft-auth-kit
Requires
- php: ^8.2
- craftcms/cms: ^5.10.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- markhuot/craft-pest-core: ^3.2
This package is auto-updated.
Last update: 2026-07-13 21:15:42 UTC
README
Foundational authentication primitives for Craft CMS 5 — passwordless tokens (magic links + email OTP), passkey wrappers, a recent-auth gate, a password-validator contract, and an audit-event contract. The shared base for the CraftPulse security ecosystem.
Auth Kit is primitives + contracts. It ships no routes, controllers, or
UX — consuming plugins (Warden,
Warp, a Password Policy adapter) own those and call into Auth Kit's services.
It is a free, foundational plugin rather than a bare library because the token
store needs a table and migrations: a single installed plugin owns the
authkit_* schema once, so consumers never collide on it.
Requirements
- Craft CMS 5.10.0 or later (the passkey wrappers use core's WebAuthn serializer, which is only public as of 5.10.0)
- PHP 8.2 or later
Installation
composer require craftpulse/craft-auth-kit ./craft plugin/install auth-kit
Most of the time you won't install Auth Kit directly — it is pulled in as a Composer dependency of the plugin that uses it.
What it provides
Token core — AuthKit::$plugin->tokens
Issue and consume hashed, single-use, TTL'd passwordless credentials. The raw secret is never persisted (only its SHA-256 hash); consumption burns the token atomically; issuance is enumeration- and timing-safe.
use craftpulse\authkit\AuthKit; $tokens = AuthKit::$plugin->tokens; // Magic links — a 32-byte secret emailed as a verify URL, no attempt cap. $tokens->issueMagicLink($email, $returnUrl); // bool — respond identically regardless $user = $tokens->consumeMagicLink($rawToken); // ?User // Email OTP — short numeric code, attempt-capped, superseded on re-issue. $tokens->issueOtp($email); // bool $user = $tokens->consumeOtp($email, $code); // ?User // Registration — a 32-byte secret for an address with no account yet. // Mints no user row; the email lives in the token payload until verify time. $tokens->issueRegistration($email, $returnUrl); // bool — unknown address only $token = $tokens->consumeRegistration($rawToken); // ?Token — payload carries the email // Maintenance — prune expired rows (safe on a schedule). $tokens->purgeExpiredTokens(); // int rows deleted
Registration is the inverse of a login issuance: issueRegistration() proceeds
only for an address with no account yet, and refuses (silently, timing-equalized)
any address that already maps to a user of any status. The two branches a unified
sign-in/sign-up endpoint dispatches between (existing address to issueMagicLink(),
unknown address to issueRegistration()) stay indistinguishable by timing.
consumeRegistration() proves only mailbox possession and returns the burned
token — the consuming plugin owns the account decision (create, activate, log in)
from the payload's email.
Tunable as service properties (no settings model — set on the component, e.g.
via config/app.php): tokenTtl (default 900s), otpDigits (6),
otpMaxAttempts (5), perEmailLimit (5), perEmailWindow (300s),
magicLinkRoute, and registrationRoute — the site routes your plugin
registers for the verify URLs (Auth Kit imposes no URLs).
Important
issueMagicLink() / issueOtp() / issueRegistration() return whether a
credential was actually sent, but any public-facing caller must respond
identically whether or not the address exists — that is what keeps the
endpoint enumeration-safe. Per-IP rate limiting belongs on your controller
(core's RateLimiter); the per-address throttle here covers every channel
including programmatic use.
Passkeys & recent-auth — AuthKit::$plugin->passkeys
Thin wrappers over core's WebAuthn machinery for front-end users, plus the recent-auth gate (the passwordless replacement for elevated sessions; stamped automatically on login).
$passkeys = AuthKit::$plugin->passkeys; $passkeys->getCreationOptions($user); // string (JSON) for the browser $passkeys->verifyCreation($credentials, $name); // bool $passkeys->getPasskeys($user); // array $passkeys->hasPasskeys($user); // bool $passkeys->deletePasskey($user, $uid); $passkeys->hasRecentAuth($within); // bool — gate sensitive actions $passkeys->stampRecentAuth();
Important
The credential-changing operations enforce the gate themselves:
verifyCreation() and deletePasskey() throw a
yii\web\ForbiddenHttpException when the session has not authenticated
within the recent-auth window. Check hasRecentAuth() in your controller
first for a friendly response. The gate authenticates the session, not the
target: always pass the authenticated user's own element, never a user
resolved from request input.
Password validation contract — AuthKit::$plugin->passwords
The neutral cooperation seam for password strength and breach checks. Plugins
cooperate through this contract and a registry — never by sniffing each
other with isPluginInstalled().
$result = AuthKit::$plugin->passwords->validate($password, $user); if (!$result->isValid) { // surface $result errors }
A provider (e.g. Password Policy) registers a validator implementing
craftpulse\authkit\passwords\PasswordValidatorInterface:
use craftpulse\authkit\services\Passwords; use craftpulse\authkit\events\RegisterPasswordValidatorsEvent; use yii\base\Event; Event::on( Passwords::class, Passwords::EVENT_REGISTER_PASSWORD_VALIDATORS, function(RegisterPasswordValidatorsEvent $event) { $event->validators[] = new MyPolicyValidator(); } );
If no validator is registered, validate() is a graceful no-op (valid). The
interface is deliberately tiny and stable — treat any change to it as a major
version bump.
Audit events — AuthKit::$plugin->audit
The neutral cooperation seam for authentication audit logging. Emitters describe
an auth fact with the AuthEvent value object and hand it to record();
provider plugins register sinks that persist or forward it. Plugins cooperate
through this contract and a registry — never by sniffing each other with
isPluginInstalled().
An emitter (Warden, Warp) records an event:
use craftpulse\authkit\audit\AuthEvent; AuthKit::$plugin->audit->record(new AuthEvent( name: AuthEvent::LOGIN_SSO, emitter: 'warden', userId: $user->id, details: ['provider' => $providerHandle], // scalar-only, no PII ));
A provider (e.g. Password Policy) registers a sink implementing
craftpulse\authkit\audit\AuditSinkInterface:
use craftpulse\authkit\audit\AuditSinkInterface; use craftpulse\authkit\audit\AuthEvent; use craftpulse\authkit\events\RegisterAuditSinksEvent; use craftpulse\authkit\services\Audit; use yii\base\Event; Event::on( Audit::class, Audit::EVENT_REGISTER_AUDIT_SINKS, function(RegisterAuditSinksEvent $event) { $event->sinks[] = new class implements AuditSinkInterface { public function handle(AuthEvent $event): void { // Persist or forward. Ignore names you don't recognize. } }; } );
record() fans the event out to every registered sink in order, wrapping each
in its own try/catch: a sink that throws is logged and skipped, never blocking
the auth flow nor the sinks after it. With no sink registered, record() is a
cheap no-op.
The contract has three rules:
detailsis scalar-only and carries no PII — no emails, raw IPs, or raw user agents. Onlybool,int,float, andstringvalues are accepted; a non-scalar value throws at construction. (outcomeis likewise validated: it must beOUTCOME_SUCCESSorOUTCOME_FAILURE.)- Sinks ignore unknown event names silently. Auth Kit adds names in minor releases, so a sink may receive a name newer than the vocabulary it was written against — it must not error on one.
- Emitters never edition-gate emission. Emit unconditionally; the sink side decides what to keep.
The AuthEvent shape is frozen at 1.2.0 — treat any change to its properties or
constructor as a major version bump. New event-name constants, by contrast, are
additive and ship in minors.
Front end
A craft.authKit Twig variable exposes hasPasskeys, passkeys, and
webauthnJsUrl for templates, and Auth Kit publishes a shared
authkit-webauthn.js browser client. Default auth_kit_magic_link,
auth_kit_otp, and auth_kit_register system messages ship out of the box;
consumers override them.
Events
| Service | Event | Fired |
|---|---|---|
tokens |
EVENT_AFTER_ISSUE_TOKEN |
after a token is issued |
tokens |
EVENT_BEFORE_CONSUME_TOKEN |
before consume (cancelable — refuses login, leaves the token unburned) |
tokens |
EVENT_AFTER_CONSUME_TOKEN |
after consume, user resolved |
passwords |
EVENT_REGISTER_PASSWORD_VALIDATORS |
to register password validators |
audit |
EVENT_REGISTER_AUDIT_SINKS |
to register audit sinks |
Consumers
| Plugin | Uses Auth Kit for |
|---|---|
| Warden | Lite passwordless: magic links, passkeys, recent-auth |
| Warp (planned) | the whole passwordless product surface |
| Password Policy (planned adapter) | provides a PasswordValidatorInterface adapter |
License
MIT — © CraftPulse