# Anikeen ID [![Latest Stable Version](https://img.shields.io/packagist/v/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) PHP Anikeen ID API Client for Laravel 11+ ## Table of contents 1. [Installation](#installation) 2. [Configuration](#configuration) 3. [General](#general) 4. [Examples](#examples) 5. [Documentation](#documentation) 6. [Development](#Development) ## Installation ``` composer require anikeen/id ``` ## Configuration Add environmental variables to your `.env` file: ``` ANIKEEN_ID_KEY= ANIKEEN_ID_SECRET= ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ``` To switch from `production` to `staging` use following variable: ``` ANIKEEN_ID_MODE=staging ``` You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. Add to `config/services.php` file: ```php 'anikeen' => [ 'mode' => env('ANIKEEN_ID_MODE'), 'client_id' => env('ANIKEEN_ID_KEY'), 'client_secret' => env('ANIKEEN_ID_SECRET'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'base_url' => env('ANIKEEN_ID_BASE_URL'), ], ``` ### Event Listener In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: ```php public function boot(): void { Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { $event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class); }); } ``` ### Registering Middleware Append it to the global middleware stack in your application's `bootstrap/app.php` file: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, ]); }) ``` ### Implementing Billable To implement the `Billable` trait, you need to add the `Billable` trait to your user model. ```php use Anikeen\Id\Billable; class User extends Authenticatable { use Billable; // Your model code... } ``` then, you can use the `Billable` trait methods in your user model. ### App subscriptions (trusted apps, no user token) `subscriptions()` acts with the user's own access token and stops working when the user's login expired. Trusted apps can manage a user's subscriptions with the app's own token (client credentials, scope `subscriptions:client`) instead: ```php $subscriptions = $team->owner->appSubscriptions(); // nothing is requested yet $subscription = $subscriptions->create([ 'group' => $team->id, 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => 0.0137, 'idempotency_key' => "server:{$pendingServer->id}", // retries return the same subscription ]); $subscription->checkout(); // bill the first period $subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then $subscription->pause(); // expires when the paid period ends $subscription->resume(); // expired subscriptions start a new period $subscription->revoke(refund: false); // cancel $subscriptions->find($id); // null if unknown or not this user's $subscriptions->all(['group' => $team->id, 'active' => true]); ``` The user's Anikeen ID is read from the billable's `anikeen_id` attribute (`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the subscriptions it created (plus those of clients delegated to it). Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`, `idempotency_key_conflict`) and `isNotFound()`, `isConflict()`, `isTemporary()`: ```php try { $subscription->revoke(); } catch (ApiException $e) { if ($e->isNotFound() || $e->error === 'already_cancelled') { // already gone } elseif ($e->isTemporary()) { // retry later } else { throw $e; } } ``` ### App orders (trusted apps, no user token) Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): ```php $team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first app(AnikeenId::class)->appOrders()->pending(); // of all users $team->owner->appOrders()->find($orderId); // null if unknown or not this user's ``` Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = no more automatic retries) and `last_payment_error`. ### Checkout sessions (the user confirms several subscriptions at once) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`. Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly; sessions are opt-in. Example: the Console's builder ("Jetzt kaufen") with several products and coupon prices: ```php use Anikeen\Id\Exceptions\ApiException; $session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token 'return_url' => route('builder.done'), // same origin as one of your redirect URIs 'metadata' => ['cart' => $cart->id], // returned as is 'idempotency_key' => "cart:{$cart->id}", // retries return the same session 'subscriptions' => $cart->items->map(fn ($item) => [ 'group' => $team->id, 'name' => $item->product->name, 'description' => $item->label, 'unit' => $item->unit, // hour, day, week, month, year 'price' => $item->priceAfterCoupon(), // net, per unit 'payload' => ['cart_item' => $item->id], ])->all(), ]); $cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]); return redirect($session->confirmationUrl()); // later: https://console.test/builder/done?status=completed&checkout_session=01K… ``` Provision when the webhook arrives (or after checking the session through the API; never trust the query string): ```php use Anikeen\Id\Webhooks\EventType; match ($event['type']) { EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null), default => null, }; app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session $session->refresh()->subscriptions(); // AppSubscription objects $session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed) ``` While a session is open, its subscriptions can't be checked out or repriced by the app (409 `in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`, `cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired). `subscription.activated` / `subscription.cancelled` are still sent per subscription, before the `checkout.*` event of the session. ### Selling as a third-party app (user confirms on Anikeen ID) Apps that are not trusted can create orders and subscriptions for the signed-in user (user token), but can't charge them. The user confirms on Anikeen ID and comes back to your `return_url`: ```php use Anikeen\Id\Exceptions\ConfirmationRequiredException; $order = $user->orders()->create([ 'billing_address_id' => $addressId, 'items' => [['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99']], ]); return redirect($order->confirmationUrl(route('shop.done'))); // later: https://your-app.test/shop/done?status=completed&order=01J… ``` `return_url` must have the origin of one of your app's redirect URIs. `status` is `completed`, `pending` (payment still processing) or `cancelled`; check it with `$user->orders()->find($id)` instead of trusting the query string. Subscriptions work the same way (`$user->subscriptions()->create([...])->confirmationUrl(...)`, `&subscription=…`). Unconfirmed bookings are cancelled after 24 hours. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. ### Webhooks Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (set up in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. ```php use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Webhooks\EventType; use Anikeen\Id\Webhooks\Webhook; Route::post('webhooks/anikeen', function (Request $request) { try { $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); } catch (SignatureVerificationException) { abort(400); } match ($event['type']) { EventType::ORDER_COMPLETED => Shop::fulfil($event['data']['object']['id']), EventType::SUBSCRIPTION_CANCELLED => Panel::suspend($event['data']['object']['id']), EventType::CHECKOUT_COMPLETED => Panel::provision($event['data']['object']['subscription_ids']), default => null, }; return response()->noContent(); })->withoutMiddleware(VerifyCsrfToken::class); ``` Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and reload the object through the API when the current state matters. ### Change the default access token / refresh token field name If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: ```php use Anikeen\Id\AnikeenId; public function boot(): void { AnikeenId::useAccessTokenField('anikeen_id_access_token'); AnikeenId::useRefreshTokenField('anikeen_id_refresh_token'); } ``` ### Implementing Auth (API tokens) The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them in `config/auth.php`: ```php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'anikeen', 'provider' => 'anikeen', ], ], 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, 'column' => 'anikeen_id', // optional, see below ], ], ``` The guard accepts `Authorization: Bearer ` with an access token issued by Anikeen ID (personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's `column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests. The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available: ```php $request->user()->anikeenTokenCan('user'); // has the scope (or `*`) $request->user()->anikeenTokenCanAny(['user', 'user:read']); ``` or as middleware (`403` without the scope): ```php use Anikeen\Id\Http\Middleware\CheckForAnyScope; use Anikeen\Id\Http\Middleware\CheckScopes; Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope ``` Options in `config/services.php` (`anikeen`): | Key | Default | | |-----|---------|-| | `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) | | `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used | | `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID | | `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable | | `revocation_cache_ttl` | `300` | seconds that answer is cached per token | The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token. ## General #### Setters and Getters ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setClientId('abc123'); $anikeenId->setClientSecret('abc456'); $anikeenId->setToken('abcdef123456'); $anikeenId = $anikeenId->withClientId('abc123'); $anikeenId = $anikeenId->withClientSecret('abc123'); $anikeenId = $anikeenId->withToken('abcdef123456'); ``` #### Error handling for an unsuccessful query: ```php $result = $anikeenId->sshKeysByUserId('someInvalidId'); // Check, if the query was successfully if (!$result->success()) { die('Ooops: ' . $result->error()); } ``` #### Shift result to get single key data: ```php $result = $anikeenId->sshKeysByUserId('someValidId'); $sshKey = $result->shift(); echo $sshKey->name; ``` ## Examples #### Get User SSH Key ```php $anikeenId = new Anikeen\IdAnikeenId(); $anikeenId->setClientId('abc123'); // Get SSH Key by User ID $result = $anikeenId->sshKeysByUserId('someValidId'); // Check, if the query was successfully if (!$result->success()) { die('Ooops: ' . $result->error()); } // Shift result to get single key data $sshKey = $result->shift(); echo $sshKey->name; ``` #### Create Order Preview ```php $anikeenId = new \Anikeen\Id\AnikeenId(); // Create new Order Preview $result = $anikeenId->createOrderPreview([ 'country_iso' => 'de', 'items' => [ [ 'type' => 'physical', 'name' => 'Test', 'price' => 2.99, 'unit' => 'onetime', 'units' => 1, ] ] ])->shift(); echo $preview->gross_total; ``` #### OAuth Tokens ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setClientId('abc123'); $anikeenId->setToken('abcdef123456'); $result = $anikeenId->getAuthedUser(); $user = $userResult->shift(); ``` ```php $anikeenId->setToken('uvwxyz456789'); $result = $anikeenId->getAuthedUser(); ``` ```php $result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser(); ``` #### Facade ```php use Anikeen\Id\Facades\AnikeenId; AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); ``` ## Documentation [**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Enums/Scope.php) ## Development #### Run Tests ```shell composer test ``` ```shell BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test ``` #### Generate Documentation ```shell composer docs ```