Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The two-factor middleware is registered under the vendor-scoped alias `laranail-authkit-two-factor`
(`AuthKitServiceProvider::TWO_FACTOR_MIDDLEWARE`). `NamingConventionTest` asserts it, and that no
other bare alias is registered, against the live router.
- A configurable token issuer registry with Sanctum as the default, allowing integration packages
such as AuthKit OAuth to add token drivers without replacing AuthKit's existing issuer.

- **Opt-in TOTP two-factor authentication for API clients.** Login can return a short-lived MFA challenge; clients can verify TOTP or recovery codes, manage enrollment and recovery codes, and protect routes with the `two-factor` middleware. Verified API tokens receive a `two-factor:verified` ability. The `two_factor_method` field defaults to `none` and is ready for future methods.

Expand All @@ -30,6 +32,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- `composer.json` `authors` email is `opensource@simtabi.com`, the community metadata address,
replacing `hello@simtabi.com`.
- Password updates, password resets, and API logout now revoke tokens through the registered token
issuers, so integrations can revoke their tokens through the same AuthKit actions.

- **Breaking. Social login moved to `laranail/authkit-social-login`.** Fifteen classes, the `socials`
migration, its factory and the `social` config block left this package. See
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Headless authentication for Laravel 13+. Ships the REST API; no views and no web
> This package is still in development. Breaking changes are imminent; use it in production at your own risk.

- **Fortify-backed** — password reset, profile updates, password updates, email verification, passkeys, login throttling
- **Sanctum-ready** — API token issuance via `IssueTokenForUser`
- **Extensible API tokens** — Sanctum is the default `IssueTokenForUser` backend; optional packages can register additional token issuers
- **Composable** — separate actions for credential check vs session login

## Requirements
Expand Down Expand Up @@ -213,7 +213,7 @@ The application client should use Fortify's `/passkeys/login/options`, `/passkey
| `ResetUserPassword` | Validate and reset password (Fortify `ResetsUserPasswords`) |
| `UpdateUserProfileInformation` | Validate and update profile (Fortify `UpdatesUserProfileInformation`) |
| `UpdateUserPassword` | Validate and update password (Fortify `UpdatesUserPasswords`) |
| `IssueTokenForUser` | Issue Sanctum personal access token, returns `TokenResult` |
| `IssueTokenForUser` | Issue a token using the configured backend, returns `TokenResult` |
| `CheckEmailExists` | Check if email is registered |
| `FindUserByEmail` | Retrieve user by email |

Expand Down
7 changes: 5 additions & 2 deletions config/laranail/authkit.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
*/

'tokens' => [
'driver' => env(key: 'AUTHKIT_TOKEN_DRIVER', default: 'sanctum'),

'abilities' => [
'user:read',
'user:update-profile',
Expand Down Expand Up @@ -91,8 +93,9 @@
*
* Set this to '' to fall back to bare names, if an application already depends on them.
*/
'name_prefix' => env(key: 'AUTHKIT_API_ROUTE_NAME_PREFIX', default: 'laranail-auth-api.'),
'middleware' => ['api', 'throttle:60,1'],
'name_prefix' => env(key: 'AUTHKIT_API_ROUTE_NAME_PREFIX', default: 'laranail-auth-api.'),
'middleware' => ['api', 'throttle:60,1'],
'token_guards' => ['sanctum'],
],

];
16 changes: 9 additions & 7 deletions docs/api-routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
These routes ship with `laranail/authkit`, so an API-only or Filament consumer installs the core
alone and has them — no Blade scaffolding required.

The consuming model must use Sanctum's `HasApiTokens` trait and the `personal_access_tokens`
migration must be installed; `laranail/authkit-preset`'s installer does both if you are using it.
With the default Sanctum token driver, the consuming model must use Sanctum's `HasApiTokens` trait
and the `personal_access_tokens` migration must be installed; `laranail/authkit-preset`'s installer
does both if you are using it. An OAuth package can register another guard and issuer; AuthKit OAuth
adds Passport as an accepted bearer-token guard without changing the Sanctum default.
The routes are registered when `laranail.authkit.api.enabled` is true; their default prefix is
`/api/auth` and middleware is `api` plus `throttle:60,1`. The API routes do not render preset Blade views, create a browser session, run the preset CAPTCHA middleware, or replace a client application's authorization policy.

Expand All @@ -14,13 +16,13 @@ The routes are registered when `laranail.authkit.api.enabled` is true; their def
|--------------------------------------------------|--------------------|----------------------------------------|----------------------------------------------------------------------------------------|
| `POST /api/auth/register` | Registration | Guest; `throttle:10,1` | `201` with `status`, `data.token`, and `data.user`. |
| `POST /api/auth/login` | Login | Guest; `throttle:10,1` | `200` with token and user; invalid credentials return `422`, throttling returns `429`. |
| `POST /api/auth/logout` | Logout | `auth:sanctum` | Deletes the current access token. |
| `POST /api/auth/email/verification-notification` | Email verification | `auth:sanctum`, `throttle:6,1` | Sends a verification notification. |
| `GET /api/auth/email/verify/{id}/{hash}` | Email verification | `auth:sanctum`, signed, `throttle:6,1` | Completes verification. |
| `POST /api/auth/logout` | Logout | configured token guards | Revokes the current token. |
| `POST /api/auth/email/verification-notification` | Email verification | configured token guards, `throttle:6,1` | Sends a verification notification. |
| `GET /api/auth/email/verify/{id}/{hash}` | Email verification | configured token guards, signed, `throttle:6,1` | Completes verification. |
| `POST /api/auth/forgot-password` | Password reset | Guest; `throttle:10,1` | Sends a reset link through Laravel's password broker. |
| `POST /api/auth/reset-password` | Password reset | Guest; `throttle:10,1` | Validates the token and resets the password. |
| `PUT /api/auth/user/password` | Password updates | `auth:sanctum` | Uses authkit's password-update action. |
| `PUT /api/auth/user/profile-information` | Profile management | `auth:sanctum` | Uses authkit's profile-update action. |
| `PUT /api/auth/user/password` | Password updates | configured token guards | Uses AuthKit's password-update action. |
| `PUT /api/auth/user/profile-information` | Profile management | configured token guards | Uses AuthKit's profile-update action. |

Register, login and logout are always present when the API is enabled; the rest follow the Fortify-style feature list in `laranail.authkit.fortify.features`, so removing `reset-passwords` removes the two password endpoints. `POST /register` and `POST /login` have both the API group's `throttle:60,1` and their endpoint `throttle:10,1` middleware. Authentication failures from the login action return `422`; rate-limit responses return `429`.

Expand Down
6 changes: 3 additions & 3 deletions docs/api-tokens.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# API tokens

Use `IssueTokenForUser` to issue a Sanctum personal access token from an application-owned API authentication flow. The action returns a `TokenResult` containing the authenticated user and the newly created token.
Use `IssueTokenForUser` to issue a personal access token from an application-owned API authentication flow. Sanctum is the default backend. Optional packages can register another backend through `TokenIssuerRegistryInterface`; `laranail/authkit-oauth` adds Passport while keeping Sanctum as the default. The action returns a `TokenResult` containing the authenticated user and the newly created token.

```php
$result = app(IssueTokenForUser::class)->execute(
Expand All @@ -9,9 +9,9 @@ $result = app(IssueTokenForUser::class)->execute(
);
```

The consuming application's model must use Sanctum's `HasApiTokens` trait and its Sanctum migration must be installed. Auth Kit registers the REST API itself (see [API routes](api-routes.md)); a caller issuing a token directly should authenticate and authorize the request first, then choose a token name and abilities appropriate to the client. Return the plain-text token only at issuance, never log it, and use Sanctum ability middleware plus token revocation for client lifecycle management.
With the default Sanctum backend, the consuming application's model must use Sanctum's `HasApiTokens` trait and its Sanctum migration must be installed. When using Passport, follow the OAuth package's separate model/provider setup. Auth Kit registers the REST API itself (see [API routes](api-routes.md)); a caller issuing a token directly should authenticate and authorize the request first, then choose a token name and abilities appropriate to the client. Return the plain-text token only at issuance, never log it, and use the selected token system's scope middleware and revocation behavior for client lifecycle management.

Tokens are scoped and time-limited by default rather than wildcard and eternal — see [API routes](api-routes.md) for the endpoints and `laranail.authkit.tokens` for the defaults.
Tokens are scoped and time-limited by default rather than wildcard and eternal — see [API routes](api-routes.md) for the endpoints and `laranail.authkit.tokens` for AuthKit's defaults. A non-default issuer may apply its own expiry configuration.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/password-updates.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Password updates

Retain `update-passwords` in `laranail.authkit.fortify.features` to enable authenticated password updates. `UpdateUserPassword` implements Fortify's `UpdatesUserPasswords` contract, requires the current password for the configured guard, validates and hashes the replacement, clears the remember token, and revokes personal tokens when available.
Retain `update-passwords` in `laranail.authkit.fortify.features` to enable authenticated password updates. `UpdateUserPassword` implements Fortify's `UpdatesUserPasswords` contract, requires the current password for the configured guard, validates and hashes the replacement, clears the remember token, and asks all registered token issuers to revoke the user's tokens.

For the currently authenticated user, the action also asks a compatible guard to log out other devices. Invoke it only from an authenticated, CSRF-protected flow. Removing the feature prevents Auth Kit from enabling this Fortify capability; do not expose a corresponding application route or UI.

Expand Down
7 changes: 5 additions & 2 deletions docs/two-factor-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@ Route::middleware(['auth', 'laranail-authkit-two-factor'])->group(function () {

## API flow

When the core setting is enabled and the account method is `totp`, `POST /api/auth/login` returns HTTP 202 with `status: mfa_required`, a short-lived `challenge_token`, and `expires_in`. Submit that token with the user's TOTP or recovery code to `POST /api/auth/two-factor/challenge`; only a successful challenge returns a Sanctum bearer token. Failed attempts are throttled, and challenges expire after five minutes by default.
When the core setting is enabled and the account method is `totp`, `POST /api/auth/login` returns HTTP 202 with `status: mfa_required`, a short-lived `challenge_token`, and `expires_in`. Submit that token with the user's TOTP or recovery code to `POST /api/auth/two-factor/challenge`; only a successful challenge returns a bearer token from the configured issuer (Sanctum by default). Failed attempts are throttled, and challenges expire after five minutes by default.

Authenticated clients can inspect `GET /api/auth/user/two-factor`, start enrollment with `POST /api/auth/user/two-factor` (send the current `password`), confirm with `POST /api/auth/user/two-factor/confirm`, disable with `POST /api/auth/user/two-factor/disable`, and replace recovery codes with `POST /api/auth/user/two-factor/recovery-codes`. Enrollment confirmation returns the recovery codes once; disable and recovery-code replacement require a current TOTP or unused recovery code.

API tokens issued after the challenge carry the `two-factor:verified` ability. The `laranail-authkit-two-factor` middleware checks this exact ability (not wildcard ability matching) and confirms that TOTP remains enabled. Accounts with method `none` retain the existing password-only login response.
API tokens issued after the challenge carry the `two-factor:verified` ability or scope. The
`laranail-authkit-two-factor` middleware checks this exact ability or scope (not wildcard matching)
and confirms that TOTP remains enabled. Accounts with method `none` retain the existing
password-only login response.

TOTP verification delegates to Fortify's provider, which applies its configured verification window and replay cache. Keep the application encryption key secure and stable because it protects stored TOTP and recovery secrets.
12 changes: 6 additions & 6 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
->middleware('throttle:5,1')
->name('two-factor.challenge');

Route::middleware(['auth:sanctum', 'throttle:10,1'])->group(function (): void {
Route::middleware([AuthKit::apiTokenMiddleware(), 'throttle:10,1'])->group(function (): void {
Route::get('/user/two-factor', [Api\TwoFactorManagementController::class, 'show'])->name('user-two-factor.show');
Route::post('/user/two-factor', [Api\TwoFactorManagementController::class, 'begin'])->name('user-two-factor.begin');
Route::post('/user/two-factor/confirm', [Api\TwoFactorManagementController::class, 'confirm'])->name('user-two-factor.confirm');
Expand All @@ -54,7 +54,7 @@
}

Route::post('/logout', Api\LogoutController::class)
->middleware('auth:sanctum')
->middleware(AuthKit::apiTokenMiddleware())
->name('logout');

// CheckEmailExistsController ships with no route, exactly as it did before this move.
Expand All @@ -63,11 +63,11 @@

if (AuthKit::hasFeature('email-verification')) {
Route::post('/email/verification-notification', [Api\EmailVerificationNotificationController::class, 'store'])
->middleware(['auth:sanctum', 'throttle:6,1'])
->middleware([AuthKit::apiTokenMiddleware(), 'throttle:6,1'])
->name('verification.send');

Route::get('/email/verify/{id}/{hash}', Api\VerifyEmailController::class)
->middleware(['auth:sanctum', 'signed', 'throttle:6,1'])
->middleware([AuthKit::apiTokenMiddleware(), 'signed', 'throttle:6,1'])
->name('verification.verify');
}

Expand All @@ -83,13 +83,13 @@

if (AuthKit::hasFeature('update-passwords')) {
Route::put('/user/password', [Api\UpdatePasswordController::class, 'update'])
->middleware('auth:sanctum')
->middleware(AuthKit::apiTokenMiddleware())
->name('user-password.update');
}

if (AuthKit::hasFeature('update-profile-information')) {
Route::put('/user/profile-information', [Api\UpdateProfileInformationController::class, 'update'])
->middleware('auth:sanctum')
->middleware(AuthKit::apiTokenMiddleware())
->name('user-profile-information.update');
}
});
63 changes: 4 additions & 59 deletions src/Actions/IssueTokenForUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,74 +8,19 @@
use Illuminate\Contracts\Auth\Authenticatable;
use Simtabi\Laranail\AuthKit\Support\TokenResult;
use Simtabi\Laranail\AuthKit\Contracts\IssueTokenForUserInterface;
use Simtabi\Laranail\AuthKit\Contracts\TokenIssuerRegistryInterface;

class IssueTokenForUser implements IssueTokenForUserInterface
{
/**
* Issue a personal access token for a user.
*
* Both defaults come from configuration rather than being fixed here, because both were
* previously fixed here in the least safe way available: every token was minted with the
* wildcard ability `*` and no expiry at all.
*
* A wildcard token can do anything its owner can, so a leaked one is a full account
* compromise rather than a bounded one, and there was no way for a caller to narrow it. A
* token with no expiry stays valid forever, so one recovered from a log or an old backup
* never stops working. Sanctum's own `sanctum.expiration` is null by default, so nothing
* downstream supplied the missing lifetime either.
*
* @param array<int, string>|null $abilities null takes the configured default scope
* @param DateTimeInterface|null $expiresAt null takes the configured lifetime
*/
public function __construct(private TokenIssuerRegistryInterface $issuers) {}

public function execute(
Authenticatable $user,
?string $name = null,
?array $abilities = null,
?DateTimeInterface $expiresAt = null,
bool $twoFactorVerified = false,
): TokenResult {
$tokenAbilities = $abilities ?? $this->defaultAbilities();

if ($twoFactorVerified && ! in_array('two-factor:verified', $tokenAbilities, true)) {
$tokenAbilities[] = 'two-factor:verified';
}

$token = $user->createToken(
name: $name ?? 'api-token',
abilities: $tokenAbilities,
expiresAt: $expiresAt ?? $this->defaultExpiry(),
);

return new TokenResult(
user: $user,
token: $token->plainTextToken,
);
}

/** @return array<int, string> */
private function defaultAbilities(): array
{
$abilities = config(key: 'laranail.authkit.tokens.abilities', default: ['*']);

if (! is_array($abilities) || $abilities === []) {
return ['*'];
}

return array_values(array_filter($abilities, is_string(...)));
}

/**
* A null lifetime defers to Sanctum's own `sanctum.expiration`, which is the only way to
* genuinely opt out of expiry rather than silently inherit no expiry at all.
*/
private function defaultExpiry(): ?DateTimeInterface
{
$minutes = config(key: 'laranail.authkit.tokens.expires_after_minutes');

if ($minutes === null || ! is_numeric($minutes) || (int) $minutes <= 0) {
return null;
}

return now()->addMinutes((int) $minutes);
return $this->issuers->issue($user, $name, $abilities, $expiresAt, $twoFactorVerified);
}
}
Loading
Loading