mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-04 06:39:32 +00:00
- Register the guard driver and the anikeen user provider with static closures: Laravel binds extend() closures to the AuthManager, so $this->makeGuard() failed. - Look the token's sub up in the user's Anikeen ID column (anikeen_id by default; provider 'column', services.anikeen.user_id_column or useUserIdField()), not in the primary key; works with the anikeen, eloquent and database providers. - JwtParser: key from services.anikeen.public_key(_path) or the bundled key, read once per process; misconfiguration is an error, not a 401. Passport tokens have no client claim, so it is optional now. - Optional revocation check against Anikeen ID (verify_revocation), cached per token. - anikeenTokenCan() without a token, anikeenTokenCanAny(), scope middleware accept JSON responses. - Update the bundled staging public key to the current staging key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
494 lines
16 KiB
Plaintext
494 lines
16 KiB
Plaintext
# Anikeen ID
|
||
|
||
[](https://packagist.org/packages/anikeen/id)
|
||
[](https://packagist.org/packages/anikeen/id)
|
||
[](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 <access token>` 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
|
||
|
||
<!-- GENERATED-DOCS -->
|
||
|
||
[**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
|
||
```
|