padosoft/laravel-rebel-bridge-otpz

Bridge the benbjurstrom/otpz email one-time-password package into Laravel Rebel step-up. Exposes OTP email magic-code as a step-up driver (AAL2, AMR otp). Part of padosoft/laravel-rebel-*.

Maintainers

Package info

github.com/padosoft/laravel-rebel-bridge-otpz

pkg:composer/padosoft/laravel-rebel-bridge-otpz

Statistics

Installs: 2

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

v0.1.0 2026-06-04 02:08 UTC

This package is auto-updated.

Last update: 2026-06-04 02:08:45 UTC


README

Laravel Rebel banner

laravel-rebel-bridge-otpz

Latest Version on Packagist CI License: MIT

Step-up driver that bridges benbjurstrom/otpz (email one-time-passcode login) into the Laravel Rebel step-up framework. A user who has already logged in can re-confirm sensitive actions by entering a short code emailed to them — no password required, fully audited, offline-testable.

Glossary

Term Meaning
Step-up A re-confirmation challenge issued to an already-authenticated user before a sensitive action (e.g. wire transfer, delete account). The user is not logged out; they just prove fresh intent.
OTP One-Time Password — a short numeric or alphanumeric code that is valid exactly once and expires in minutes.
Magic code The term otpz uses for its OTP: a 10-char uppercase code sent by email; hashed at rest; consumed on first successful use.
AAL Authenticator Assurance Level (NIST SP 800-63B). AAL1 = one factor; AAL2 = two distinct factors; AAL3 = hardware key.
AMR Authentication Methods Reference — a list of how the user proved identity, e.g. ['otp'].
Phishing-resistant A method that cannot be replayed on a fake site in real time. Email OTP is NOT phishing-resistant (the attacker can forward it). Passkeys are.
DriverRegistry Laravel Rebel step-up component that holds all registered step-up drivers.
AuditLogger Core service that writes security events to rebel_auth_events (DB), never to session or log files.

How it works

User clicks "Confirm wire transfer"
           │
           ▼
  RebelStepUp manager finds driver 'otpz'
           │
           ▼
  OtpzStepUpDriver::start(context)
    ├─ OtpzBroker::issue(subject)
    │    └─ CreateOtp::handle(user)  ← benbjurstrom/otpz
    │         ├─ generates 10-char code
    │         ├─ hashes + stores in DB (otps table)
    │         └─ emails code to user
    └─ returns UUID of Otp record (opaque reference)
    └─ emits stepup.otpz.started audit event
           │
           ▼
  User reads email, types code into your UI
           │
           ▼
  OtpzStepUpDriver::verify(context, code, reference)
    ├─ OtpzBroker::verify(reference, code)
    │    ├─ loads Otp by UUID
    │    ├─ checks: status=ACTIVE, not expired, attempts < 3
    │    ├─ Hash::check(code, stored_hash)
    │    └─ marks USED on success
    └─ emits stepup.otpz.verified OR stepup.otpz.failed
           │
           ▼
  RebelStepUp grants (or denies) the action

Why not use AttemptOtp::handle()?

The otpz package's AttemptOtp action validates an HTTP signed URL and the browser session ID. Neither exists in a step-up flow (the challenge/response is managed by the Rebel layer, not HTTP). This bridge therefore calls CreateOtp for issue and re-implements the same business rules (status, expiry, attempt limits, Hash::check) in OtpzBrokerImpl::verify() — all in-process, no HTTP coupling.

Requirements

Dependency Version
PHP ^8.3
Laravel ^12.0 or ^13.0
padosoft/laravel-rebel-core ^0.1
padosoft/laravel-rebel-step-up ^0.1
benbjurstrom/otpz ^0.7 (optional — see install)

Installation

1. Install this bridge

composer require padosoft/laravel-rebel-bridge-otpz

2. Install and configure otpz

composer require benbjurstrom/otpz
php artisan vendor:publish --tag="otpz-migrations"
php artisan migrate

3. Make your User model implement Otpable

// app/Models/User.php
use BenBjurstrom\Otpz\Models\Concerns\HasOtps;
use BenBjurstrom\Otpz\Models\Concerns\Otpable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable implements Otpable
{
    use HasOtps, Notifiable;

    // ...
}

4. Publish the bridge config (optional)

php artisan vendor:publish --tag="rebel-bridge-otpz-config"

That's it. The OtpzStepUpDriver registers itself automatically on boot.

Configuration

File: config/rebel-bridge-otpz.php

Key Default Effect
drivers.otpz true Register the OtpzStepUpDriver into DriverRegistry. Set false to disable without uninstalling the bridge.
options.max_attempts 3 Maximum failed verification attempts before the OTP is invalidated (mirrors otpz's hard-coded value; for reference).

The otpz package's own config (config/otpz.php) controls expiry time, rate-limit thresholds, mailable class, and user resolver.

Usage examples

Example 1 — Trigger step-up before a destructive action

use Padosoft\Rebel\StepUp\RebelStepUp;
use Padosoft\Rebel\StepUp\StepUpContext;
use Padosoft\Rebel\Core\Context\SecurityContext;

public function deleteAccount(Request $request, RebelStepUp $stepUp): JsonResponse
{
    $context = new StepUpContext(
        subject: $request->user(),
        purpose: 'account-deletion',
        security: SecurityContext::fromRequest(
            $request,
            app(\Padosoft\Rebel\Core\Contracts\KeyedHasher::class)
        ),
    );

    // start() emails the OTP; returns the opaque reference to store in session
    $reference = $stepUp->driver('otpz')->start($context);
    session(['stepup_ref' => $reference]);

    return response()->json(['status' => 'otp_sent']);
}

Example 2 — Verify the submitted code

public function confirmDelete(Request $request, RebelStepUp $stepUp): JsonResponse
{
    $context = new StepUpContext(
        subject: $request->user(),
        purpose: 'account-deletion',
        security: SecurityContext::fromRequest(
            $request,
            app(\Padosoft\Rebel\Core\Contracts\KeyedHasher::class)
        ),
    );

    $ok = $stepUp->driver('otpz')->verify(
        context: $context,
        input: $request->input('code'),
        reference: session('stepup_ref'),
    );

    if (! $ok) {
        return response()->json(['error' => 'Invalid or expired code'], 422);
    }

    // proceed with deletion…
    $request->user()->delete();

    return response()->json(['status' => 'deleted']);
}

Example 3 — Check availability before starting

$driver = app(\Padosoft\Rebel\StepUp\DriverRegistry::class)->get('otpz');

if ($driver && $driver->isAvailableFor($context)) {
    $ref = $driver->start($context);
    // store $ref and redirect to code-entry screen
}

Example 4 — Testing with FakeOtpzBroker (offline, no email)

use Padosoft\Rebel\Bridge\Otpz\Contracts\OtpzBroker;
use Padosoft\Rebel\Bridge\Otpz\Testing\FakeOtpzBroker;

beforeEach(function (): void {
    $fake = new FakeOtpzBroker(capable: true);
    app()->instance(OtpzBroker::class, $fake);
    $this->fake = $fake;
});

it('accepts the correct code', function (): void {
    $driver = app(\Padosoft\Rebel\Bridge\Otpz\Drivers\OtpzStepUpDriver::class);
    $user = User::factory()->create();
    $ctx = new StepUpContext($user, 'checkout', new SecurityContext('r'));

    $ref = $driver->start($ctx);

    expect($driver->verify($ctx, $this->fake->defaultCode, $ref))->toBeTrue();
});

Example 5 — Sanctum API (mobile/SPA)

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::post('/step-up/start',  [StepUpController::class, 'start']);
    Route::post('/step-up/verify', [StepUpController::class, 'verify']);
});

The step-up driver is transport-agnostic: start() and verify() work identically over a Sanctum-protected API endpoint. Store the returned reference (UUID) in the response body; the mobile client submits it back with the code.

Audit events

All events are written to rebel_auth_events by the core AuditLogger. They are never stored in the session or PHP log files.

Event type When emitted Key fields
stepup.otpz.started start() succeeds (OTP sent) channel=email, aal=aal2, amr=['otp'], subjectType, subjectId, purpose
stepup.otpz.verified verify() returns true same
stepup.otpz.failed verify() returns false (wrong/expired/consumed code, null reference, or broker error) same

The OTP code itself is never present in any audit event.

Security notes

  • Fail closed: any \Throwable in start() returns null; any \Throwable in verify() returns false. The driver never propagates exceptions to the caller.
  • Single-use: once verified, the OTP record is marked USED and cannot be redeemed again.
  • Attempt limiting: after 3 failed attempts the record is marked ATTEMPTED (invalidated).
  • Expiry: default 5 minutes (configurable in config/otpz.php).
  • Hashed at rest: otpz uses Laravel's hashed cast on the code column — the plaintext code is never persisted.
  • Reference is opaque: the step-up layer stores only the OTP UUID as its reference. Even if an attacker reads the reference from a compromised session, they still need to know the code.

Competitor card-battle

Feature laravel-rebel-bridge-otpz Laravel Fortify Laravel Sanctum Shopify Multipass
Email OTP step-up (post-auth) ✅ Native driver ❌ No step-up concept ❌ No step-up concept ❌ Login-only, no step-up
AAL2 assurance declared Aal::Aal2 ❌ No assurance model ❌ No assurance model ❌ No assurance model
AMR metadata in audit ['otp'] per event
Offline-testable seam FakeOtpzBroker ❌ HTTP-coupled
Fail-closed on Throwable ✅ Always false/null ❌ Throws
Audit trail to DB rebel_auth_events ❌ No audit ❌ No audit ❌ No audit
Single-use + attempt limit ✅ otpz enforces
Code hashed at rest ✅ otpz hashed cast
PSD2/SCA compliance hooks ✅ via core purpose binding
No HTTP coupling in verify ✅ Bypass AttemptOtp

🔋 Vibe coding with batteries included

This package ships with:

  • CLAUDE.md — AI-readable working guide: what it is, non-negotiable conventions, security rules, seam pattern, Definition of Done.
  • AGENTS.md — Operational rules for AI agents and humans: branching, DoD, guardrails, security design-lock.
  • .claude/skills/rebel-package-dev/ — Invocable skill for Claude Code agents: dev loop commands, PHPStan-max recipes, key design decisions, test patterns.
  • FakeOtpzBroker — Deterministic in-memory broker for offline tests.
  • CI matrix — PHP 8.3/8.4/8.5 × Laravel 12/13 via GitHub Actions.

AI agent? Start with /rebel-package-dev and read CLAUDE.md. Your first composer test should be green before you open a PR.

Contributing

PRs welcome. Follow AGENTS.md. All contributions must pass:

composer test && composer phpstan && composer pint -- --test

License

MIT — see LICENSE.