# 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 This method should typically be called in the `boot` method of your `AppServiceProvider` class: ```php use Anikeen\Id\AnikeenId; use Anikeen\Id\Providers\AnikeenIdUserProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; public function boot(): void { Auth::provider('anikeen', function ($app, array $config) { return new AnikeenIdUserProvider( $app->make(AnikeenId::class), $app->make(Request::class), $config['model'], $config['fields'] ?? [], ); }); } ``` reference the guard in the `guards` configuration of your `auth.php` configuration file: ```php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'anikeen', 'provider' => 'anikeen', ], ], ``` reference the provider in the `providers` configuration of your `auth.php` configuration file: ```php 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, 'fields' => ['first_name', 'last_name', 'email'], ], ], ``` ## 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 ```