API

Laravel API

Laravel API

In this quickstart, 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 /resource401 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

You'll find a Docker Compose file (docker-compose.yml) and an environment variables configuration file (.env) in the root directory of the repo.

Assuming you have Docker installed, you can stand up FusionAuth on your machine with the following.

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 your specified state.

If you ever want to reset the FusionAuth application, you need to 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:

  • Your client Id is e9fdb985-9173-4e01-9d73-ac2d60d1dc8e.
  • Your client secret is super-secret-secret-that-should-be-regenerated-for-production.
  • Your example teller username is teller@example.com and the password is password. They will have the role of teller.
  • Your example customer username is customer@example.com and the password is password. They will have the role of customer.
  • Your admin username is admin@example.com and the password is password.
  • The base URL of FusionAuth is http://localhost:9011/.

You can log in to the FusionAuth admin UI and look around if you want to, but with Docker and Kickstart, everything will already be configured correctly.

If you want to see where the FusionAuth values came from, they can be found in the FusionAuth app. The tenant Id is found on the Tenants page. To see the Client Id and Client Secret, go to the Applications page and click the View icon under the actions for the ChangeBank application. You'll find the Client Id and Client Secret values in the OAuth configuration section.

The .env file contains passwords. In a real application, always add this file to your .gitignore file and never commit secrets to version control.

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:

  • make-change: This endpoint will allow you to call GET with a total amount and receive a response indicating how many nickels and pennies are needed to make change. Valid roles are customer and teller.
  • panic: Tellers may call this endpoint to call the police in case of an incident. The only valid role is teller.

Both endpoints will be protected such that a valid JSON web token (JWT) will be required in an app.at cookie or the Authorization header 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')->unique();
            $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 should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'remember_token',
    ];

    /**
     * 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'));
        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 pass the token in an app.at cookie (or as a Bearer token in the Authorization header).

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

You can replace --cookie 'app.at=<your_token>' with --header 'Authorization: Bearer <your_token>' if you wish to use the Authorization header instead of the app.at cookie.

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!

Made it this far? Want a free t-shirt? We got ya.

Thank you for spending some time getting familiar with FusionAuth.

*Offer only valid in the United States and Canada, while supplies last.

fusionauth tshirt

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

  • I get This site can’t be reached localhost refused to connect. when I call the Login API.

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.

  • The /api/panic endpoint doesn’t work when I call it.

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

  • It still doesn’t work

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