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.

Maintainers

Package info

github.com/craftpulse/craft-auth-kit

Documentation

Type:craft-plugin

pkg:composer/craftpulse/craft-auth-kit

Transparency log

Statistics

Installs: 9

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.2.0 2026-07-13 21:15 UTC

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:

  1. details is scalar-only and carries no PII — no emails, raw IPs, or raw user agents. Only bool, int, float, and string values are accepted; a non-scalar value throws at construction. (outcome is likewise validated: it must be OUTCOME_SUCCESS or OUTCOME_FAILURE.)
  2. 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.
  3. 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