api

Laravel API

Laravel API

In this tutorial, you are going to learn how to integrate a Laravel resource server with FusionAuth. You will protect an API resource from unauthorized usage. You’ll be building it for ChangeBank, a global leader in converting dollars into coins.

The docker compose file and source code for a complete application are available at https://github.com/FusionAuth/fusionauth-quickstart-php-laravel-api.

Prerequisites

General Architecture

A client wants access to an API resource at /resource. However, it is denied this resource until it acquires an access token from FusionAuth.

ClientResource ServerFusionAuthGET /resource404 Not AuthorizedPOST /api/login200 Ok(token)GET /resource200 Ok(resource)ClientResource ServerFusionAuth

Resource Server Authentication with FusionAuth

While the access token is acquired via the Login API above, this is for simplicity of illustration. The token can be, and typically is, acquired through one of the OAuth grants.

Getting Started

In this section, you’ll get FusionAuth up and running and create a resource server which will serve the API.

Clone the Code

First off, grab the code from the repository and change into that directory.

git clone https://github.com/FusionAuth/fusionauth-quickstart-php-laravel-api.git
cd fusionauth-quickstart-php-laravel-api

Run FusionAuth via Docker

In the root directory of the repo you’ll find a Docker compose file (docker-compose.yml) and an environment variables configuration file (.env). Assuming you have Docker installed on your machine, you can stand up FusionAuth up on your machine with:

docker compose up -d

Here you are using a bootstrapping feature of FusionAuth, called Kickstart. When FusionAuth comes up for the first time, it will look at the kickstart/kickstart.json file and configure FusionAuth to a certain initial state.

If you ever want to reset the FusionAuth system, delete the volumes created by Docker Compose by executing docker compose down -v, then re-run docker compose up -d.

FusionAuth will be initially configured with these settings:

You can log into the FusionAuth admin UI and look around if you want, but with Docker/Kickstart you don’t need to.

Expose FusionAuth Instance

To make sure your local FusionAuth instance is accessible to your Laravel app, you need to expose it to the Internet. Write down the URL ngrok gave you as you’ll need it soon.

Create your Laravel Application

Now you are going to create a Laravel API application. While this section builds a simple API, you can use the same configuration to integrate an existing API with FusionAuth.

We are going to be building an API backend for a banking application called ChangeBank. This API will have two endpoints:

Both endpoints will be protected such that a valid JSON web token (JWT) will be required in the app.at cookie in order to be accessed. Additionally, the JWT must have a roles claim containing the appropriate role to use the endpoint.

If you simply want to run the application, there is a completed version in the complete-application directory. You can install PHP and Composer and use the following commands to get it up and running if you do not want to create your own.

cd complete-application
composer install
./vendor/bin/sail up -d

You can then follow the instructions in the “Run the API” section.

To create the application from scratch, you’ll use Laravel Sail to set up and run your application using Docker.

curl "https://laravel.build/your-application?with=mariadb" | bash
cd your-application

This may ask for your user password to run commands with sudo.

Initialize the Application

Initialize the Laravel application using the following command (it’ll run things in background, just like docker compose up -d would).

./vendor/bin/sail up -d

Laravel Sail will use port 80, so make sure you don’t have anything listening on that. Otherwise, you can pass an APP_PORT environment variable to specify another port, like shown below.

APP_PORT=3000 ./vendor/bin/sail up -d

Add Security

Include FusionAuth’s library to add JWT support to the authentication system.

./vendor/bin/sail composer require fusionauth/jwt-auth-webtoken-provider web-token/jwt-signature-algorithm-rsa

Create Fields in the User Entity

When installing Laravel, it will automatically make a migration to create a users table in the database. You’ll have to edit the file database/migrations/2014_10_12_000000_create_users_table.php to change the default behavior and use UUID for the primary key, remove the need for a password and the remember token.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->uuid('id');
            $table->string('name');
            $table->string('email')->unique();
            $table->boolean('email_verified')->default(false);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
    }
};

You’ll also need to edit app/Models/User.php to make the database changes consistent with your model.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Tymon\JWTAuth\Contracts\JWTSubject;

class User extends Authenticatable implements JWTSubject
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified' => 'boolean',
    ];

    /**
     * Indicates if the IDs are auto-incrementing.
     * @var bool
     */
    public $incrementing = false;

    /**
     * The "type" of the primary key ID.
     *
     * @var string
     */
    protected $keyType = 'string';

    /**
     * Get the identifier that will be stored in the subject claim of the JWT.
     *
     * @return mixed
     */
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     *
     * @return array
     */
    public function getJWTCustomClaims(): array
    {
        return [];
    }
}

You may remove all other migrations, as you won’t need them.

rm database/migrations/2014_10_12_100000_create_password_reset_tokens_table.php
rm database/migrations/2019_08_19_000000_create_failed_jobs_table.php
rm database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php

Run the remaining migrations to create the necessary tables in your database.

./vendor/bin/sail artisan migrate

Provisioning New Users

By default, Laravel only allows JWTs that correspond to users in your database, but one of the greatest benefits of using FusionAuth is to have a single source of truth of user management. So, you want your API to automatically provision new users when it receives a trusted JWT from FusionAuth.

To do so, first create a folder named FusionAuth inside app/, and another one named Providers inside FusionAuth, so you can have app/FusionAuth/Providers.

mkdir -p app/FusionAuth/Providers

Add a new User Provider at app/FusionAuth/Providers/FusionAuthEloquentUserProvider.php to create new users when receiving valid JWTs from FusionAuth.

<?php

declare(strict_types=1);

namespace App\FusionAuth\Providers;

use App\Models\User;
use Illuminate\Auth\EloquentUserProvider;
use Tymon\JWTAuth\Payload;

class FusionAuthEloquentUserProvider extends EloquentUserProvider
{

    /**
     * Returns a user from the provided payload
     *
     * @param \Tymon\JWTAuth\Payload $payload
     *
     * @return \App\Models\User
     */
    public function createModelFromPayload(Payload $payload): User
    {
        $email = $payload->get('email');
        /** @var \App\Models\User $model */
        $model = $this->createModel()
            ->setAttribute('id', $payload->get('sub'))
            ->setAttribute('email', $email)
            ->setAttribute('name', $email)
            ->setAttribute('email_verified', !!$payload->get('email_verified'));
        return $model;
    }
}

Now, add an Authentication Guard in app/FusionAuth/FusionAuthJwtGuard.php to call that custom method from the User Provider created above.

<?php

declare(strict_types=1);

namespace App\FusionAuth;

use App\FusionAuth\Providers\FusionAuthEloquentUserProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Tymon\JWTAuth\Exceptions\JWTException;
use Tymon\JWTAuth\JWTGuard as TymonJWTGuard;

class FusionAuthJwtGuard extends TymonJWTGuard
{

    /**
     * @return \Illuminate\Contracts\Auth\Authenticatable|null
     */
    public function user(): ?Authenticatable
    {
        // Calling the default method that will retrieve existing users
        $user = parent::user();
        if ($user !== null) {
            return $user;
        }

        // Otherwise, we'll use the custom FusionAuth user provider to create a user from the JWT
        if (!$this->provider instanceof FusionAuthEloquentUserProvider) {
            return null;
        }

        try {
            $payload = $this->jwt->getPayload();
            if (empty($payload)) {
                return null;
            }
            $this->user = $this->provider->createModelFromPayload($payload);
            $this->user->save();
            return $this->user;
        } catch (JWTException) {
            return null;
        }
    }

}

Let Laravel know about these two classes by editing config/auth.php.

First, change the driver for the web Guard to jwt:

    'guards' => [
        'web' => [
            'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],

Now, change the default provider to fusionauth_eloquent:

    'providers' => [
        'users' => [
            'driver' => 'fusionauth_eloquent',
            'model' => App\Models\User::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

Finally, create a Service Provider in app/FusionAuth/Providers/FusionAuthServiceProvider.php to make sure Laravel will use the newly created classes instead of its defaults.

<?php

declare(strict_types=1);

namespace App\FusionAuth\Providers;

use App\FusionAuth\Claims\Audience;
use App\FusionAuth\Claims\Issuer;
use App\FusionAuth\FusionAuthJwtGuard;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Tymon\JWTAuth\Http\Parser\Cookies;

class FusionAuthServiceProvider extends ServiceProvider
{

    public function boot(): void
    {
        Auth::provider('fusionauth_eloquent', function (Application $app, array $config) {
            return new FusionAuthEloquentUserProvider($this->app['hash'], $config['model']);
        });
        Auth::extend('jwt', function (Application $app, string $name, array $config) {
            $guard = new FusionAuthJwtGuard(
                $app['tymon.jwt'],
                $app['auth']->createUserProvider($config['provider']),
                $app['request']
            );

            $app->refresh('request', $guard, 'setRequest');

            return $guard;
        });

        /** @var \Tymon\JWTAuth\Claims\Factory $factory */
        $factory = $this->app['tymon.jwt.claim.factory'];
        $factory->extend('iss', Issuer::class);
        $factory->extend('aud', Audience::class);

        /** @var \Tymon\JWTAuth\Http\Parser\Parser $parsers */
        $parsers = $this->app['tymon.jwt.parser'];
        foreach ($parsers->getChain() as $parser) {
            if ($parser instanceof Cookies) {
                $parser->setKey('app_at');
                break;
            }
        }
    }

}

Update Routes

Change routes/api.php to add both /api/make-change and /api/panic endpoints for your ChangeBank application.

<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

$middleware = Route::middleware('auth:sanctum');
$middleware->get('/user', function (Request $request) {
    return $request->user();
});
$middleware->post('/panic', \App\Http\Controllers\ChangeBank\PanicController::class);
$middleware->get('/make-change', \App\Http\Controllers\ChangeBank\MakeChangeController::class);

Add Controllers

Change the base controller at app/Http/Controllers/Controller.php to include a helper function to check for roles.

<?php

namespace App\Http\Controllers;

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Gate;

class Controller extends BaseController
{
    use AuthorizesRequests, ValidatesRequests;

    protected function checkRoles(string ...$roles): void
    {
        /** @var \Tymon\JWTAuth\Payload $payload */
        $payload = auth('web')->payload();
        $rolesFromJwt = (array) $payload->get('roles');

        $hasAtLeastOneRole = false;
        foreach ($roles as $role) {
            foreach ($rolesFromJwt as $roleFromJwt) {
                if ($roleFromJwt === $role) {
                    $hasAtLeastOneRole = true;
                    break;
                }
            }
        }
        if (!$hasAtLeastOneRole) {
            throw new AuthorizationException('Proper role not found for user.');
        }
    }
}

Create a folder named ChangeBank inside app/Http/Controllers to hold the two controllers you’re going to create.

mkdir app/Http/Controllers/ChangeBank

To respond the /api/make-change endpoint, create a file at app/Http/Controllers/ChangeBank/MakeChangeController.php. This controller will verify that the authenticated user has either the teller or customer role. It then takes in the URL parameter total to calculate which coins will be returned in the JSON payload.

<?php

namespace App\Http\Controllers\ChangeBank;

use App\Http\Controllers\Controller;
use Symfony\Component\HttpFoundation\Response;

use function response;

class MakeChangeController extends Controller
{
    /**
     * Make Change entrypoint for the ChangeBank API.
     *
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function __invoke(): Response
    {
        $this->checkRoles('teller', 'customer');

        $total = (float) request()->query('total', 0);
        $output = $this->makeChange($total);

        return response()->json($output);
    }

    protected function makeChange(float $total): array
    {
        if ($total <= 0) {
            return [
                'Message' => 'Please provider a total parameter greater than 0.',
            ];
        }

        $message = 'We can make change using';
        $remainingAmount = $total;

        $coins = [
            'quarters' => 0.25,
            'dimes' => 0.10,
            'nickels' => 0.05,
            'pennies' => 0.01,
        ];

        $output = [
            'Message' => $message,
            'Change'  => [],
        ];

        foreach ($coins as $coinName => $value) {
            $coinCount = intval($remainingAmount / $value);
            $remainingAmount = round(($remainingAmount - $coinCount * $value) * 100) / 100;
            $output['Message'] .= " {$coinCount} {$coinName}";
            $output['Change'][] = ['Denomination' => $coinName, 'Count' => $coinCount];
        }

        return $output;
    }
}

To finish, create a controller at app/Http/Controllers/ChangeBank/PanicController.php to handle the /api/panic endpoint for users with the teller role.

<?php

namespace App\Http\Controllers\ChangeBank;

use App\Http\Controllers\Controller;
use Symfony\Component\HttpFoundation\Response;

use function intval;
use function request;
use function response;
use function round;

class PanicController extends Controller
{
    /**
     * Panic entrypoint for the ChangeBank API.
     *
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    public function __invoke(): Response
    {
        $this->checkRoles('teller');

        return response()->json([
            'message' => "We've called the police!",
        ]);
    }
}

Validating Issuer and Audience Claims

Besides verifying the signature, it is also recommended to validate the issuer (iss) and audience (aud) claims when receiving a JWT. To do those checks, you need to create two files.

Create a folder named Claims inside app/FusionAuth.

mkdir app/FusionAuth/Claims

First, create a file named app/FusionAuth/Claims/Audience.php that will check if the audience (aud) claim actually contains the Client Id for the application you created in FusionAuth.

<?php

declare(strict_types=1);

namespace App\FusionAuth\Claims;

use Tymon\JWTAuth\Claims\Audience as BaseAudienceClaim;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;

class Audience extends BaseAudienceClaim
{
    private string $expectedValue;

    /**
     * @throws \Tymon\JWTAuth\Exceptions\TokenInvalidException
     */
    public function validatePayload(): bool
    {
        if (!$this->validate()) {
            throw new TokenInvalidException('Audience (aud) invalid');
        }

        return true;
    }

    private function validate(): bool
    {
        // Audience must be set
        $value = $this->getValue();
        if (empty($value)) {
            return false;
        }

        if (!isset($this->expectedValue)) {
            $this->expectedValue = \strtolower(config('app.fusionauth.client_id'));
        }

        if (empty($this->expectedValue)) {
            return false;
        }

        // If we have specified valid values, we check if the current audience is present there
        return \strtolower($value) === $this->expectedValue;
    }
}

Now, create a file called app/FusionAuth/Claims/Issuer.php to check if the issuer (iss) is actually your FusionAuth instance address.

<?php

declare(strict_types=1);

namespace App\FusionAuth\Claims;

use Tymon\JWTAuth\Claims\Issuer as BaseIssuerClaim;
use Tymon\JWTAuth\Exceptions\TokenInvalidException;

class Issuer extends BaseIssuerClaim
{
    private string $expectedValue;

    /**
     * @throws \Tymon\JWTAuth\Exceptions\TokenInvalidException
     */
    public function validatePayload(): bool
    {
        if (!$this->validate()) {
            throw new TokenInvalidException('Issuer (iss) invalid');
        }

        return true;
    }

    private function validate(): bool
    {
        // Issuer must be set
        $value = $this->getValue();
        if (empty($value)) {
            return false;
        }

        if (!isset($this->expectedValue)) {
            $this->expectedValue = \strtolower(config('app.fusionauth.url'));
        }

        // If we have specified valid values, we check if the current issue is present there
        if (empty($this->expectedValue)) {
            return false;
        }

        return \strtolower($value) === $this->expectedValue;
    }
}

Let Your Code Know about the FusionAuth Instance

Now that you created all these files, you need to pass some details about the FusionAuth configuration to them.

Start by adding the environment variables below to your .env file, changing https://The-Address-Ngrok-Gave-You to the actual address you copied from ngrok.

FUSIONAUTH_CLIENT_ID=e9fdb985-9173-4e01-9d73-ac2d60d1dc8e
FUSIONAUTH_URL=http://localhost:9011
JWT_ALGO=RS256
JWT_JWKS_URL_CACHE=86400
JWT_JWKS_URL=https://The-Address-Ngrok-Gave-You/.well-known/jwks.json

Now open the config/app.php file and browse to the providers section. This is a Laravel configuration to automatically load these files when booting your application. Add App\FusionAuth\Providers\FusionAuthServiceProvider::class to the end of the providers array, like shown below.

    'providers' => ServiceProvider::defaultProviders()->merge([
        /*
         * Package Service Providers...
         */

        /*
         * Application Service Providers...
         */
        App\Providers\AppServiceProvider::class,
        App\Providers\AuthServiceProvider::class,
        // App\Providers\BroadcastServiceProvider::class,
        App\Providers\EventServiceProvider::class,
        App\Providers\RouteServiceProvider::class,
        App\FusionAuth\Providers\FusionAuthServiceProvider::class,
    ])->toArray(),

In that same config/app.php file, add these lines at the end (just before the last line) to pass values from the environment variables to the application.

    /*
    |--------------------------------------------------------------------------
    | FusionAuth instance config
    |--------------------------------------------------------------------------
    |
    | Retrieving FusionAuth instance settings.
    |
     */
    'fusionauth' => [
        'url' => rtrim(env('FUSIONAUTH_URL'), '/'),
        'client_id' => env('FUSIONAUTH_CLIENT_ID'),
    ],

To make the library available for use, publish its configuration by running the command below.

./vendor/bin/sail artisan vendor:publish --provider="FusionAuth\JWTAuth\WebTokenProvider\Providers\WebTokenServiceProvider"

Run the API

Get a Token

There are several ways to acquire a token in FusionAuth, but for this example you will use the Login API to keep things simple.

First, try requests as the teller@example.com user. Based on the configuration this user has the teller role and should be able to use both /api/make-change and /api/panic.

  1. Acquire an access token for teller@example.com by making the following request
curl --location 'http://localhost:9011/api/login' \
--header 'Authorization: this_really_should_be_a_long_random_alphanumeric_value_but_this_still_works' \
--header 'Content-Type: application/json' \
--data-raw '{
  "loginId": "teller@example.com",
  "password": "password",
  "applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e"
}'

Copy the token from the response, which should look like this:

{
    "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InVOYl9iQzFySHZZTnZMc285VzRkOEprZkxLWSJ9.eyJhdWQiOiJlOWZkYjk4NS05MTczLTRlMDEtOWQ3My1hYzJkNjBkMWRjOGUiLCJleHAiOjE2ODkzNTMwNTksImlhdCI6MTY4OTM1Mjk5OSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDExIiwic3ViIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMTExMTExMTExMTExIiwianRpIjoiY2MzNWNiYjUtYzQzYy00OTRjLThmZjMtOGE4YWI1NTI0M2FjIiwiYXV0aGVudGljYXRpb25UeXBlIjoiUEFTU1dPUkQiLCJlbWFpbCI6InRlbGxlckBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhcHBsaWNhdGlvbklkIjoiZTlmZGI5ODUtOTE3My00ZTAxLTlkNzMtYWMyZDYwZDFkYzhlIiwicm9sZXMiOlsiY3VzdG9tZXIiLCJ0ZWxsZXIiXSwiYXV0aF90aW1lIjoxNjg5MzUyOTk5LCJ0aWQiOiJkN2QwOTUxMy1hM2Y1LTQwMWMtOTY4NS0zNGFiNmM1NTI0NTMifQ.WLzI9hSsCDn3ZoHKA9gaifkd6ASjT03JUmROGFZaezz9xfVbO3quJXEpUpI3poLozYxVcj2hrxKpNT9b7Sp16CUahev5tM0-4_FaYlmUEoMZBKo2JRSH8hg-qVDvnpeu8nL6FXxJII0IK4FNVwrQVFmAz99ZCf7m5xruQSziXPrfDYSU-3OZJ3SRuvD8bMopSiyRvZLx6YjWfBsvGSmMXeh_8vHG5fVkq5w1IkaDdugHnivtJIivHuCfl38kQBgw9rAqJLJoKRHHW0Ha7vHIcS6OCWWMDIIVspLyQNcLC16pL9Nss_5v9HMofow1OvQ9sUSMrbbkipjKq2peSjG7qA",
    "tokenExpirationInstant": 1689353059670,
    "user": {
        ...
    }
}

Make the Request

Make a request to /api/make-change with a query parameter total=5.12 and passing the token in an app.at cookie.

curl --cookie 'app.at=YOUR_TOKEN' 'http://localhost/api/make-change?total=5.12'

The response should look similar to below, however it has been formatted here for readability:

{
  "Message": "We can make change using 20 quarters 1 dimes 0 nickels 2 pennies",
  "Change": [
    {
      "Denomination": "quarters",
      "Count": 20
    },
    {
      "Denomination": "dimes",
      "Count": 1
    },
    {
      "Denomination": "nickels",
      "Count": 0
    },
    {
      "Denomination": "pennies",
      "Count": 2
    }
  ]
}

You were authorized, success! You can try making the request without the cookie or with a different string rather than a valid token, and see that you are denied access.

Next call the /api/panic endpoint because you are in trouble!

curl --cookie 'app.at=YOUR_TOKEN' --request POST 'http://localhost/api/panic'

This is a POST (not a GET) because you want all your emergency calls to be non-idempotent.

Your response should look like this:

{"message":"We've called the police!"}

Nice, help is on the way!

Now let’s try as customer@example.com who has the role customer. Acquire a token for customer@example.com.

curl --location 'http://localhost:9011/api/login' \
--header 'Authorization: this_really_should_be_a_long_random_alphanumeric_value_but_this_still_works' \
--header 'Content-Type: application/json' \
--data-raw '{
  "loginId": "customer@example.com",
  "password": "password",
  "applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e"
}'

Your response should look like this:

{
    "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InVOYl9iQzFySHZZTnZMc285VzRkOEprZkxLWSJ9.eyJhdWQiOiJlOWZkYjk4NS05MTczLTRlMDEtOWQ3My1hYzJkNjBkMWRjOGUiLCJleHAiOjE2ODkzNTQxMjMsImlhdCI6MTY4OTM1MzUyMywiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDExIiwic3ViIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMjIyMjIyMjIyMjIyIiwianRpIjoiYjc2YWMwMGMtMDdmNi00NzkzLTgzMjgtODM4M2M3MGU4MWUzIiwiYXV0aGVudGljYXRpb25UeXBlIjoiUEFTU1dPUkQiLCJlbWFpbCI6ImN1c3RvbWVyQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImFwcGxpY2F0aW9uSWQiOiJlOWZkYjk4NS05MTczLTRlMDEtOWQ3My1hYzJkNjBkMWRjOGUiLCJyb2xlcyI6WyJjdXN0b21lciJdLCJhdXRoX3RpbWUiOjE2ODkzNTM1MjMsInRpZCI6ImQ3ZDA5NTEzLWEzZjUtNDAxYy05Njg1LTM0YWI2YzU1MjQ1MyJ9.T1bELQ6a_ItOS0_YYpvqhIVknVMNeamcoC7BWnPjg2lgA9XpCmFA2mVnycoeuz-mSOHbp2cCoauP5opxehBR2lCn4Sz0If6PqgJgXKEpxh5pAxCPt91UyfjH8hGDqE3rDh7E4Hqn7mb-dFFwdfX7CMdKvC3dppMbXAGCZTl0LizApw5KIG9Wmt670339pSf5lzD38P9WAG5Wr7fAmVrIJPVu6yv2FoR-pMYD2lnAF63HWKknrWB-khmhr9ZKRLXKhP1UK-ThY1FSnmpp8eNblsBqCxf6WaYxYkdp5_F2e56M4sQwHzrg4P9tZGVCmMri4dShF3Ck7OGa7hel-iIPew",
    "tokenExpirationInstant": 1689354123118,
    "user": {
        ...
    }
}

Now use that token to call /api/make-change with a query parameter total=3.24.

curl --cookie 'app.at=YOUR_TOKEN' --location 'http://localhost/api/make-change?total=3.24'

The response should look similar to below, however it has been formatted here for readability:

{
  "Message": "We can make change using 12 quarters 2 dimes 0 nickels 4 pennies",
  "Change": [
    {
      "Denomination": "quarters",
      "Count": 12
    },
    {
      "Denomination": "dimes",
      "Count": 2
    },
    {
      "Denomination": "nickels",
      "Count": 0
    },
    {
      "Denomination": "pennies",
      "Count": 4
    }
  ]
}

So far so good. Now call the /api/panic endpoint with -i flag to see the headers of the response).

curl --cookie 'app.at=YOUR_TOKEN' -i --request POST -H "Accept: application/json" 'http://localhost/api/panic'

Your should receive a 403 Forbidden response with the following message and a huge stack trace (because you are in development mode):

{
    "message": "Proper role not found for user.",
    "exception": "Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException",
    ...
}

Looks like this user does not have access to this function. Enjoy your secured resource server!

Next Steps

This quickstart is a great way to get a proof of concept up and running quickly, but to run your API in production, there are some things you’re going to want to do.

FusionAuth Integration

Security

Troubleshooting

Ensure FusionAuth is running in the Docker container. You should be able to login as the admin user, admin@example.com with a password of password at http://localhost:9011/admin.

Make sure you are making a POST call and using a token with the teller role.

You can always pull down a complete running application and compare what’s different.

git clone https://github.com/FusionAuth/fusionauth-quickstart-php-laravel-api.git
cd fusionauth-quickstart-php-laravel-api
docker compose up -d
cd complete-application
composer install
./vendor/bin/sail up -d