diff --git a/CHANGELOG.md b/CHANGELOG.md index c086ec2..0bdeb44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/README.md b/README.md index e25fcdb..3fffb0b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/config/laranail/authkit.php b/config/laranail/authkit.php index 5336b57..9190175 100644 --- a/config/laranail/authkit.php +++ b/config/laranail/authkit.php @@ -57,6 +57,8 @@ */ 'tokens' => [ + 'driver' => env(key: 'AUTHKIT_TOKEN_DRIVER', default: 'sanctum'), + 'abilities' => [ 'user:read', 'user:update-profile', @@ -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'], ], ]; diff --git a/docs/api-routes.md b/docs/api-routes.md index 370d0e6..f756569 100644 --- a/docs/api-routes.md +++ b/docs/api-routes.md @@ -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. @@ -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`. diff --git a/docs/api-tokens.md b/docs/api-tokens.md index 20f4ec6..8d0bdb3 100644 --- a/docs/api-tokens.md +++ b/docs/api-tokens.md @@ -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( @@ -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. --- diff --git a/docs/password-updates.md b/docs/password-updates.md index 571f871..e7a0e55 100644 --- a/docs/password-updates.md +++ b/docs/password-updates.md @@ -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. diff --git a/docs/two-factor-authentication.md b/docs/two-factor-authentication.md index df6a83c..270680f 100644 --- a/docs/two-factor-authentication.md +++ b/docs/two-factor-authentication.md @@ -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. diff --git a/routes/api.php b/routes/api.php index 259fb4e..3157ec9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); @@ -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. @@ -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'); } @@ -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'); } }); diff --git a/src/Actions/IssueTokenForUser.php b/src/Actions/IssueTokenForUser.php index 5adbad5..1547cb5 100644 --- a/src/Actions/IssueTokenForUser.php +++ b/src/Actions/IssueTokenForUser.php @@ -8,25 +8,12 @@ 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|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, @@ -34,48 +21,6 @@ public function execute( ?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 */ - 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); } } diff --git a/src/Actions/ResetUserPassword.php b/src/Actions/ResetUserPassword.php index dc5dbb8..7bb94cf 100644 --- a/src/Actions/ResetUserPassword.php +++ b/src/Actions/ResetUserPassword.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Hash; use Simtabi\Laranail\AuthKit\Services\UserValidationService; use Simtabi\Laranail\AuthKit\Http\Requests\ResetPasswordRequest; +use Simtabi\Laranail\AuthKit\Contracts\TokenIssuerRegistryInterface; use Laravel\Fortify\Contracts\ResetsUserPasswords as FortifyResetUserPassword; class ResetUserPassword implements FortifyResetUserPassword @@ -23,8 +24,6 @@ public function reset($user, array $input): void 'remember_token' => null, ])->save(); - if (method_exists($user, 'tokens')) { - $user->tokens()->delete(); - } + app(TokenIssuerRegistryInterface::class)->revokeAll($user); } } diff --git a/src/Actions/SanctumTokenIssuer.php b/src/Actions/SanctumTokenIssuer.php new file mode 100644 index 0000000..1a25c37 --- /dev/null +++ b/src/Actions/SanctumTokenIssuer.php @@ -0,0 +1,95 @@ +defaultAbilities(); + + if ($twoFactorVerified && ! in_array('two-factor:verified', $tokenAbilities, true)) { + $tokenAbilities[] = 'two-factor:verified'; + } + + if (! method_exists($user, 'createToken')) { + throw new InvalidArgumentException('The configured AuthKit user model cannot create Sanctum tokens.'); + } + + $token = $user->createToken( + name: $name ?? 'api-token', + abilities: $tokenAbilities, + expiresAt: $expiresAt ?? $this->defaultExpiry(), + ); + + return new TokenResult(user: $user, token: $token->plainTextToken); + } + + public function revokeCurrent(Authenticatable $user): void + { + $token = method_exists($user, 'currentAccessToken') ? $user->currentAccessToken() : null; + + if ($token instanceof SanctumPersonalAccessToken) { + $token->delete(); + } + } + + public function revokeAll(Authenticatable $user): void + { + if (! $user instanceof Model) { + return; + } + + $model = Sanctum::$personalAccessTokenModel; + $morphType = $user->getMorphClass(); + $userModel = config('laranail.authkit.user_model') ?? config('auth.providers.users.model'); + + if (is_string($userModel) && class_exists($userModel) && is_subclass_of($userModel, Model::class)) { + $morphType = (new $userModel)->getMorphClass(); + } + + $model::query() + ->where('tokenable_type', $morphType) + ->where('tokenable_id', $user->getAuthIdentifier()) + ->delete(); + } + + /** @return array */ + private function defaultAbilities(): array + { + $abilities = config('laranail.authkit.tokens.abilities', ['*']); + + if (! is_array($abilities) || $abilities === []) { + return ['*']; + } + + return array_values(array_filter($abilities, is_string(...))); + } + + private function defaultExpiry(): ?DateTimeInterface + { + $minutes = config('laranail.authkit.tokens.expires_after_minutes'); + + if ($minutes === null || ! is_numeric($minutes) || (int) $minutes <= 0) { + return null; + } + + return now()->addMinutes((int) $minutes); + } +} diff --git a/src/Actions/UpdateUserPassword.php b/src/Actions/UpdateUserPassword.php index efc6ac1..4c5d940 100644 --- a/src/Actions/UpdateUserPassword.php +++ b/src/Actions/UpdateUserPassword.php @@ -11,6 +11,7 @@ use Simtabi\Laranail\AuthKit\Services\BrowserSessionService; use Simtabi\Laranail\AuthKit\Services\UserValidationService; use Simtabi\Laranail\AuthKit\Http\Requests\UpdatePasswordRequest; +use Simtabi\Laranail\AuthKit\Contracts\TokenIssuerRegistryInterface; class UpdateUserPassword implements UpdatesUserPasswords { @@ -30,9 +31,7 @@ public function update($user, array $input): void 'remember_token' => null, ])->save(); - if (method_exists($user, 'tokens')) { - $user->tokens()->delete(); - } + app(TokenIssuerRegistryInterface::class)->revokeAll($user); $guardInstance = auth()->guard($guard); diff --git a/src/Contracts/TokenIssuerInterface.php b/src/Contracts/TokenIssuerInterface.php new file mode 100644 index 0000000..5ff6487 --- /dev/null +++ b/src/Contracts/TokenIssuerInterface.php @@ -0,0 +1,25 @@ +|null $abilities */ + public function issue( + Authenticatable $user, + ?string $name = null, + ?array $abilities = null, + ?DateTimeInterface $expiresAt = null, + bool $twoFactorVerified = false, + ): TokenResult; + + public function revokeCurrent(Authenticatable $user): void; + + public function revokeAll(Authenticatable $user): void; +} diff --git a/src/Contracts/TokenIssuerRegistryInterface.php b/src/Contracts/TokenIssuerRegistryInterface.php new file mode 100644 index 0000000..5db9f05 --- /dev/null +++ b/src/Contracts/TokenIssuerRegistryInterface.php @@ -0,0 +1,27 @@ +|null $abilities */ + public function issue( + Authenticatable $user, + ?string $name = null, + ?array $abilities = null, + ?DateTimeInterface $expiresAt = null, + bool $twoFactorVerified = false, + ): TokenResult; + + public function revokeCurrent(Authenticatable $user): void; + + public function revokeAll(Authenticatable $user): void; +} diff --git a/src/Http/Controllers/Api/LogoutController.php b/src/Http/Controllers/Api/LogoutController.php index fabe1d2..a002c7d 100644 --- a/src/Http/Controllers/Api/LogoutController.php +++ b/src/Http/Controllers/Api/LogoutController.php @@ -8,14 +8,18 @@ use Illuminate\Http\JsonResponse; use Simtabi\Laranail\AuthKit\Support\AuthKit; use Simtabi\Laranail\AuthKit\Contracts\LogoutUserInterface; +use Simtabi\Laranail\AuthKit\Contracts\TokenIssuerRegistryInterface; use Simtabi\Laranail\AuthKit\Http\Controllers\AbstractLogoutController; class LogoutController extends AbstractLogoutController { public function __invoke(Request $request, LogoutUserInterface $action): JsonResponse { + $user = $request->user(); $action->execute(guard: $this->guard()); - $request->user()?->currentAccessToken()?->delete(); + if ($user !== null) { + app(TokenIssuerRegistryInterface::class)->revokeCurrent($user); + } return $this->jsonResponse(status: 'success', data: [ 'message' => 'Logged out successfully.', @@ -29,7 +33,9 @@ protected function guard(): string protected function loggedOut(Request $request): JsonResponse { - $request->user()?->currentAccessToken()?->delete(); + if ($request->user() !== null) { + app(TokenIssuerRegistryInterface::class)->revokeCurrent($request->user()); + } return response()->json([ 'status' => 'logged_out', diff --git a/src/Http/Middleware/RequireTwoFactorAuthentication.php b/src/Http/Middleware/RequireTwoFactorAuthentication.php index 879d958..e5de136 100644 --- a/src/Http/Middleware/RequireTwoFactorAuthentication.php +++ b/src/Http/Middleware/RequireTwoFactorAuthentication.php @@ -24,7 +24,7 @@ public function handle(Request $request, Closure $next): Response $token = method_exists($user, 'currentAccessToken') ? $user->currentAccessToken() : null; if ($token !== null) { - $abilities = $token->abilities ?? []; + $abilities = $token->abilities ?? $token->oauth_scopes ?? $token->scopes ?? []; abort_unless(is_array($abilities) && in_array('two-factor:verified', $abilities, true), 403, 'MFA verification is required.'); } else { abort_unless($request->hasSession(), 403, 'MFA verification is required.'); diff --git a/src/Providers/AuthKitServiceProvider.php b/src/Providers/AuthKitServiceProvider.php index 9b827ed..4470c82 100644 --- a/src/Providers/AuthKitServiceProvider.php +++ b/src/Providers/AuthKitServiceProvider.php @@ -56,6 +56,12 @@ public function packageRegistered(): void $this->app->bind(abstract: Contracts\LoginUserInterface::class, concrete: Actions\LoginUser::class); $this->app->bind(abstract: Contracts\LogoutUserInterface::class, concrete: Actions\LogoutUser::class); $this->app->bind(abstract: Contracts\IssueTokenForUserInterface::class, concrete: Actions\IssueTokenForUser::class); + $this->app->singleton(Contracts\TokenIssuerRegistryInterface::class, function (): Services\TokenIssuerRegistry { + $registry = new Services\TokenIssuerRegistry; + $registry->register('sanctum', app(Actions\SanctumTokenIssuer::class)); + + return $registry; + }); // A singleton because registrations accumulate: every sub-package that contributes a // provider does so against the same instance, and a fresh one per resolution would drop // whatever registered before it. diff --git a/src/Services/TokenIssuerRegistry.php b/src/Services/TokenIssuerRegistry.php new file mode 100644 index 0000000..8fbe9f3 --- /dev/null +++ b/src/Services/TokenIssuerRegistry.php @@ -0,0 +1,55 @@ + */ + private array $issuers = []; + + public function register(string $driver, TokenIssuerInterface $issuer): void + { + $this->issuers[$driver] = $issuer; + } + + public function issue( + Authenticatable $user, + ?string $name = null, + ?array $abilities = null, + ?DateTimeInterface $expiresAt = null, + bool $twoFactorVerified = false, + ): TokenResult { + return $this->selectedIssuer()->issue($user, $name, $abilities, $expiresAt, $twoFactorVerified); + } + + public function revokeCurrent(Authenticatable $user): void + { + foreach ($this->issuers as $issuer) { + $issuer->revokeCurrent($user); + } + } + + public function revokeAll(Authenticatable $user): void + { + foreach ($this->issuers as $issuer) { + $issuer->revokeAll($user); + } + } + + private function selectedIssuer(): TokenIssuerInterface + { + $driver = (string) config('laranail.authkit.tokens.driver', 'sanctum'); + + return $this->issuers[$driver] + ?? throw new InvalidArgumentException("The AuthKit token issuer [{$driver}] is not registered."); + } +} diff --git a/src/Support/AuthKit.php b/src/Support/AuthKit.php index 71cdeb8..27a3fa6 100644 --- a/src/Support/AuthKit.php +++ b/src/Support/AuthKit.php @@ -82,6 +82,24 @@ public static function apiMiddleware(): array return (array) config(key: 'laranail.authkit.api.middleware', default: ['api']); } + /** + * Authentication middleware for API endpoints that accept AuthKit-issued bearer tokens. + * Multiple guards let Sanctum and Passport coexist without changing the default issuer. + */ + public static function apiTokenMiddleware(): string + { + $guards = array_values(array_filter( + (array) config(key: 'laranail.authkit.api.token_guards', default: ['sanctum']), + is_string(...), + )); + + if (config(key: 'laranail.authkit-oauth.enabled', default: false)) { + $guards[] = (string) config(key: 'laranail.authkit-oauth.passport.guard', default: 'passport'); + } + + return 'auth:' . implode(',', array_unique($guards ?: ['sanctum'])); + } + public static function redirect(string $key, string $default = '/'): string { if (static::$redirectResolver !== null) { diff --git a/tests/Feature/TokenIssuerRegistryTest.php b/tests/Feature/TokenIssuerRegistryTest.php new file mode 100644 index 0000000..edc7fcb --- /dev/null +++ b/tests/Feature/TokenIssuerRegistryTest.php @@ -0,0 +1,33 @@ +shouldReceive('issue')->once()->with($user, 'client', ['user:read'], null, false)->andReturn($result); + + config()->set('laranail.authkit.tokens.driver', 'passport'); + $registry = new TokenIssuerRegistry; + $registry->register('passport', $issuer); + + expect($registry->issue($user, 'client', ['user:read']))->toBe($result); +}); + +it('asks every registered issuer to revoke a user current token and all tokens', function (): void { + $user = Mockery::mock(Authenticatable::class); + $issuer = Mockery::mock(TokenIssuerInterface::class); + $issuer->shouldReceive('revokeCurrent')->once()->with($user); + $issuer->shouldReceive('revokeAll')->once()->with($user); + + $registry = new TokenIssuerRegistry; + $registry->register('test', $issuer); + $registry->revokeCurrent($user); + $registry->revokeAll($user); +});