5

Build your own OAuth2 Server with PHP and Symfony

 2 years ago
source link: https://davegebler.com/post/php/build-oauth2-server-php-symfony
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
  1. Home
  2. PHP
  3. Build your own OAuth2 Server with PHP and Symfony

Build your own OAuth2 Server with PHP and Symfony

In this tutorial, I will be guiding you through the code to build your own OAuth2 server using the PHP League's OAuth2 Server library and their corresponding Symfony bundle.

The bundle isn't too well document as of the time of writing, so I thought this would be useful not only as a tutorial in creating a full server application and simple client demonstration, but also as a (hopefully) easy to understand reference for all the concepts involved.

What we'll be building

We'll be building a simple OAuth2 server that will allow users to log in and authorize a separate (i.e. third-party, client) application to access their data.

Screenshot showing OAuth2 server demo

In the Symfony app which acts as the server, this will take the form of an access token being issued to the client app which can be used as part of an API request to authenticate as the user on the server, which will have additional roles granted inside their security token corresponding to the specific permissions they have authorized the client to access.

This means inside our server app, we can build API endpoints which are limited to requests with an access token which grants particular permissions.

The client app will be a simple PHP script that will allow the user to log in and then make an API request to the server app, which will return some data about the user.

The complete sample code for this tutorial is available on my GitHub and is conveniently Dockerized so you can run it locally without having to install any dependencies.

There is also a live demo available at https://davegebler.com/oauth2client Just log in with the username [email protected] and the password password and you'll be able to call the API on https://auth.davegebler.com/api/test with your access token as a Bearer token in the Authorization header.

Before we get in to the code, let's take a closer look at OAuth2 and the flow we'll be using.

What is OAuth2?

OAuth2 is a standardised protocol for authorizing access to resources on a server on behalf of an owner.

Imagine you are a user of Application A. Application A holds some data about you. Maybe that's your name, email address, a photo, or maybe it's more sensitive data like your bank account details or your medical records.

Application B is a separate application that you also use. Application B wants to access some of the data that Application A holds about you, usually to save you needing to re-enter that same information in two different places, to give you convenience and a better experience.

In order to do this, Application B needs two things; your consent about exactly what data it can get, and a secure way to access the data once you have given it.

This is where OAuth 2 comes in. It replaces the original OAuth specification and is now the most widely used protocol for authorizing access to resources on a server.

A bit of terminology

OAuth2 is a protocol, which means it's a set of rules that define how two or more parties can interact with each other.

OAuth2 uses some particular terminology to describe the different pieces involved in this sharing of data.

A resource owner is you, the user. You are the person who owns some data that you want to share with another application. The data you own is called a resource.

The application that you want to share your data with is called a client. This is the application you will authorize to access your data. It is also the application that will request your consent to access your data.

The authorization server is the application which is able to grant access to your data and which the client will send you to in order to obtain your consent.

This may or may not be the same server as the resource server, which is the application that actually holds your data.

Your consent will be given to the authorization server, which will then issue an access token to the client.

The client will then use this access token to access your data on the resource server.

Your consent will be limited to the scope of the access token, which is the set of permissions that the client has been granted by you. These are the specific types of data you are agreeing that the client can access - for example, maybe the resource server is your bank, and you are happy to allow the client to access your name, email address and a list of your recent debit transactions, but not your other bank account details.

Finally, your consent will be revoked by the authorization server if you decide to withdraw it.

The OAuth2 flow

OAuth2 defines a specific flow for authorizing access to resources on a server. This flow is called the authorization code grant.

There are other flows defined in the OAuth2 specification, but this is the most common and the one we will be using in this tutorial.

It is also the only flow I've ever actually had to work with in the real world as a web developer, so I'm not going to go in to the others in too much detail here, but a quick overview:

Client credentials grant

This is the simplest flow. It is used when the client is acting on its own behalf, rather than on behalf of a user, i.e. when the client itself is the resource owner. This is used mostly in server-to-server communications.

Resource owner credentials grant

This is used when the client is acting on behalf of a user, but the user has given its full credentials to the client for future use. This is used mostly in mobile applications and should only be used where the user has a high degree of trust in the client app.

Implicit grant

This is a simplified version of the authorization code grant, which is used when the client is a web application that can't keep a secret token without exposing it to the wider world, such as a single-page JavaScript application with no server-side component. It is not recommended for use in most cases and should be avoided if possible.

Authorization code with PKCE (Proof Key for Code Exchange) grant

This is a version of the authorization code grant which is now the preferred alternative to the implicit grant. The difference is that the client app essentially generates a temporary secret in the form of a code verifier, which it then hashes to create a code challenge. This code challenge is sent to the authorization server, which then sends it back to the client app along with the authorization code. The client app then sends the authorization code and the code verifier to the authorization server. The authorization server then hashes the code verifier and compares it to the code challenge it received earlier. If they match, an access token can be issued.

Refresh token grant

This is used when the client has been issued an access token with a limited lifetime, and needs to be able to renew it without having to go through the full authorization code grant flow again.

The authorization code grant flow

The authorization code grant flow works like this:

  1. The client sends the user directly to the authorization server, in their browser, along with some information about itself and the permissions it wants to request.
  2. The user is presented with a login screen and asked to log in.
  3. The user is presented with a consent screen and asked to grant the client the permissions it has requested.
  4. The user is able to allow or deny this request.
    1. If the user denies the request, the authorization server will redirect the user back to the client with an error message.
  5. If the user allows the request, the authorization server will redirect the user back to the client with an authorization code.
  6. The client will then send this authorization code to the authorization server, along with its own secret token, to obtain an access token.
  7. The client will then use this access token to access the resource server on behalf of the user.

OAuth2 vs OpenID Connect

OAuth2 is a protocol for authorizing access to resources on a server. OpenID Connect is a protocol for authenticating users.

OpenID Connect (usually abbreviated to OIDC) is built on top of OAuth2. You can think of it as an opinionated use of and extension to the OAuth2 protocol, to enable authentication and user management.

Authorization vs authentication

You'll see these two terms crop up a lot in the world of computing.

Authentication is about establishing identity. It's about proving who you are. This doesn't necessarily mean your real-world identity; your identity in this context could be as simple as you are the person who has access to a particular email address.

Authorization is about what you're allowed to do. In most systems, you will need to be authenticated before you can be authorized to do anything.

Imagine I turn up at Number 10 Downing Street and I show the policeman at the gate my passport. The policeman might check my passport, look at me, be satisfied that my passport is genuine and that I am the person pictured on the passport. I am now authenticated; I have reasonably proved that I am Dave Gebler.

But does being Dave Gebler mean I am allowed to enter No. 10? No, because I am not authorized to do so.

That's authentication versus authorization.

Authentication with OIDC

So what OIDC does as a layer on top of OAuth2 is provide a standard set of rules by which a client application can use another application it trusts to authenticate a user, to provide data about that user's identity.

OIDC does this by providing a standard set of claims that the authentication server can provide to the client application.

Claims are simply pieces of information about the user. Some standard OIDC claims are:

  • sub - the user's unique identifier as held by the issuer
  • name - the user's full name
  • given_name - the user's given name
  • family_name - the user's family name
  • profile - the user's profile URL
  • picture - the user's profile picture URL
  • email - the user's email address
  • email_verified - whether the user's email address has been successfully verified
  • phone_number - the user's phone number
  • phone_number_verified - whether the user's phone number has been successfully verified

There are others, but as we can see - each of the claims is a piece of information about who the user is.

The most common use case, then, of OIDC is to allow a user to use the data held about them by one application to create an account with and log in to another application.

In other words, your social sign in - the "Sign in with Google / Facebook / Twitter" buttons you see on so many websites - are all built on top of OIDC.

What is a JSON Web Token (JWT)?

Whereas OAuth2 returns an access token to a client, which is typically then used to access an API on the resource server, OIDC returns an ID token which directly contains identity information about the user.

Thus, the difference is a client is intended to parse and read an ID token directly.

OIDC uses a specific format called a JSON Web Token (JWTs) to encode this information.

A JWT is a string of text that is made up of three parts, separated by dots:

header.payload.signature

Each of these three parts is a standard JSON object.

The header contains information about the token itself, in particular the algorithm used to sign it.

The payload contains the actual data, as a set of claims.

The signature is a cryptographic signature of the header and payload, which is used to verify that the token is both authentic and has not been altered.

Each of these pieces is then base64 encoded.

Although OIDC specifically mandates the use of JWTs, OAuth2 does not - an access token can be any string of text, provided it uniquely identifies the user and the client. OAuth2 client applications should not rely on an access token being encoded in any particular format, and they should not try to decode or read it for any information.

That said, there is absolutely nothing wrong with providing an OAuth2 access token in JWT format; one reason you might want to do this is to store all necessary information about the user and scopes inside the access token itself, rather than having to store it in a database. The JWT will be signed with the private key of the authorization server and can be verified by the resource server using the public key.

We are not building a full OIDC server in this tutorial, but the OAuth2 server we are building will provide access tokens in JWT format, and we will use the signature of these tokens to verify that they are authentic in our sample client application. Most client applications will not need to do this and would treat the access token as an opaque string, but it is a useful exercise to understand how it works.

Thus, you can use what you learn here to go further with your implementation and issue ID tokens, or build a full OIDC server on top if you wish.

The decoded JWT our server will provide will look like this:

Header:
{
  "typ": "JWT",
  "alg": "RS256"
}
Payload:
{
  "aud": "testclient",
  "jti": "a4c3d9a9d6ac5ff1b5",
  "iat": 1662814887,
  "nbf": 1662814887,
  "exp": 1662818487,
  "sub": "ac4fbaf2-30a5-11ed-94e9-09ec9787bcf6",
  "scopes": [
    "blog_read",
    "profile",
    "email"
  ],
  "kid": "1",
  "custom": {
    "foo": "bar"
  }
}
Signature:
[Binary signature]

Prerequisites

To follow along with this tutorial, I will be assuming you are already reasonably familiar with Symfony.

The sample code I've provided is built on Symfony 6.1 and PHP 8.1, though I do provide a Docker Compose file to make it easy to get up and running.

If you're not using Docker, you'll need to have PHP 8.1 installed, along with the composer and symfony CLI tools. Please also ensure you have OpenSSL installed on your system; we'll need this to generate the private and public keypair used to sign our JWTs.

Set up the server application

We'll start by creating a new Symfony application to act as our OAuth2 server.

symfony new --webapp oauth2-server

This will create a new Symfony application in the oauth2-server directory.

We'll also need to install the PHP League OAuth2 Server Bundle, which provides a Symfony integration for the PHP League's OAuth2 Server library.

cd oauth2-server
composer require league/oauth2-server-bundle

Once your app is created, take a look at the default configuration. In my sample project, I've used SQLite as the database backend for simplicity's sake, but if you prefer to use MySQL / Postgres / whatever, feel free.

Generate the keys.

In the var directory of your Symfony project, create a new subdirectory called keys and run the following OpenSSL commands:

openssl genrsa -out var/keys/private.key
openssl rsa -in var/keys/private.key -pubout -out var/keys/public.key

Configure the OAuth2 server bundle

The OAuth2 server bundle provides a default configuration, which you can find in your app under config/packages/league_oauth2_server.yaml.

For our sample application, we'll need to make a few changes to this configuration. Find and change the following config keys with these values:

 private_key: '%kernel.project_dir%/var/keys/private.key'
 public_key: '%kernel.project_dir%/var/keys/public.key'
 enable_auth_code_grant: true
 enable_refresh_token_grant: true
 scopes:
    available: ['email', 'profile', 'blog_read', 'blog_write']
    default: ['email', 'profile']

The rest of the configuration is fine as-is.

Create our entities

We'll need to create a user entity to represent the users of our application.

php bin/console make:user

Create a User entity with email as the username and additionally include a uuid property of the UUID type.

We're then going to make a couple of small changes so we can log in with email address, but also issue access tokens where the user's ID is a UUID.

In your UserRepository, ensure it implements UserLoaderInterface:

class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface, UserLoaderInterface

And add the following method:

public function loadUserByIdentifier(string $identifier): ?User
{
    $entityManager = $this->getEntityManager();

    // Check if the identifier is an email address
    if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) {
        return $this->findOneBy(['email' => $identifier]);
    }
    if (Uuid::isValid($identifier)) {
        return $this->findOneBy(['uuid' => Uuid::fromString($identifier)->toBinary()]);
    }
    return null;
}

Finally, in the User entity, add the following method:

public function getUserIdentifier(): string
{
    return $this->uuid->toRfc4122();
}

The bundle will include the entities we need to represent client applications, tokens and authorization codes, but there are still a couple more entities we want to create to save the consents granted by users to client applications, and to describe the applications in some meaningful way (we'll stick to a simple name for now).

Create two more entities:

php bin/console make:entity

Create a OAuth2ClientProfile entity with the following properties:

  • id - the primary key, an integer, auto increment
  • client - a one-to-one relationship with the League\Bundle\OAuth2ServerBundle\Model\Client entity
    • The referenced column name for this relation is identifier.
  • name - a string
  • description - a string or text

Now create a OAuth2UserConset entity with the following properties:

  • id - the primary key, an integer, auto increment
  • user - a many-to-one relationship with the App\Entity\User entity
  • client - a many-to-one relationship with the League\Bundle\OAuth2ServerBundle\Model\Client entity
    • The referenced column name for this relation is identifier.
  • created - a datetime
  • expires - a datetime
  • scopes - a simple array

Override the bundle AccessToken entity

For this tutorial, we're going to include some custom fields in our access token JWTs.

To do this, we need to override the AccessToken entity provided by the bundle.

Create a new entity called AccessToken in the src/Entity directory with the following implementation:

<?php

namespace App\Entity;

use DateTimeImmutable;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\Traits\AccessTokenTrait;
use League\OAuth2\Server\Entities\Traits\EntityTrait;
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;

final class AccessToken implements AccessTokenEntityInterface
{
    use AccessTokenTrait;
    use EntityTrait;
    use TokenEntityTrait;

    private function convertToJWT()
    {
        $this->initJwtConfiguration();

        return $this->jwtConfiguration->builder()
            ->permittedFor($this->getClient()->getIdentifier())
            ->identifiedBy($this->getIdentifier())
            ->issuedAt(new DateTimeImmutable())
            ->canOnlyBeUsedAfter(new DateTimeImmutable())
            ->expiresAt($this->getExpiryDateTime())
            ->relatedTo((string) $this->getUserIdentifier())
            ->withClaim('scopes', $this->getScopes())
            ->withClaim('kid', '1')
            ->withClaim('custom', ['foo' => 'bar'])
            ->getToken($this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey());
    }
}

What we've added here over the default implementation is the "kid" and "custom" fields.

The "custom" field is just an example of how we can add arbitrary data to the JWT. The "kid" field is a little more important - we'll be using this later to identify the key used to sign the JWT when we verify it. But for the tutorial, we'll just hardcode it as "1".

Configure the bundle to use our custom AccessToken entity

Now we need to tell our application to use our custom AccessToken entity.

Open the config/services.yaml file and add the following config under the services key:

 League\Bundle\OAuth2ServerBundle\Entity\AccessToken:
     class: App\Entity\AccessToken

Add an event subscriber to resolve an authorization code request

The bundle defines certain events which we can use to hook into the OAuth2 server's request handling.

We'll use the League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent event to resolve an authorization code request.

Create a new class called AuthorizationCodeSubscriber in the src/EventSubscriber directory with the following implementation:

<?php

namespace App\EventSubscriber;

use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\FirewallMapInterface;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class AuthorizationCodeSubscriber implements EventSubscriberInterface
{
    use TargetPathTrait;

    private Security $security;
    private UrlGeneratorInterface $urlGenerator;
    private RequestStack $requestStack;
    private $firewallName;

    public function __construct(Security $security, UrlGeneratorInterface $urlGenerator, RequestStack $requestStack, FirewallMapInterface $firewallMap)
    {
        $this->security = $security;
        $this->urlGenerator = $urlGenerator;
        $this->requestStack = $requestStack;
        $this->firewallName = $firewallMap->getFirewallConfig($requestStack->getCurrentRequest())->getName();
    }

    public function onLeagueOauth2ServerEventAuthorizationRequestResolve(AuthorizationRequestResolveEvent $event): void
    {
        $request = $this->requestStack->getCurrentRequest();
        $user = $this->security->getUser();
        $this->saveTargetPath($request->getSession(), $this->firewallName, $request->getUri());
        $response = new RedirectResponse($this->urlGenerator->generate('app_login'), 307);
        if ($user instanceof UserInterface) {
            if ($request->getSession()->get('consent_granted') !== null) {
                $event->resolveAuthorization($request->getSession()->get('consent_granted'));
                $request->getSession()->remove('consent_granted');
                return;
            }
            $response = new RedirectResponse($this->urlGenerator->generate('app_consent', $request->query->all()), 307);
        }
        $event->setResponse($response);
    }

    public static function getSubscribedEvents(): array
    {
        return [
            'league.oauth2_server.event.authorization_request_resolve' => 'onLeagueOauth2ServerEventAuthorizationRequestResolve',
        ];
    }
}

Now, whenever we get a hit for an auth code request, we'll check if the user is logged in. If they are, we'll check if they have already granted consent for the client and scopes requested. If they have, we'll resolve the request with the consent value. If they haven't, we'll redirect them to the consent page.

If the user is not logged in, we'll redirect them to the login page.

Build our login form controller

Now we need to build a controller to handle the login form. We'll just be building a standard Symfony login form here.

php bin/console make:controller Login

And enable it in the app configuration.

# config/packages/security.yaml
security:
    # ...

    firewalls:
        main:
            # ...
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true

Then add the following to the src/Controller/LoginController.php file, inside your app_login route function:

#[Route('/login', name: 'app_login')]
public function index(AuthenticationUtils $authenticationUtils): Response
{
    if ($this->getUser()) {
        return $this->redirectToRoute('app_index');
    }
    $error = $authenticationUtils->getLastAuthenticationError();
    $lastUsername = $authenticationUtils->getLastUsername();
    return $this->render('login/index.html.twig', [
        'controller_name' => 'LoginController',
        'error' => $error,
        'last_username' => $lastUsername,
    ]);
}

We'll add another endpoint inside this controller to handle the consent form a bit later.

How you style your login form is up to you; see my sample app for an example. Don't forget to add the CSRF token to your form!

<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

Create the sample API and JWKS endpoints

Next step is to create and Index controller.

The Index controller is just a convenient place we're going to stick a logged-in homepage, a sample API endpoint which will be restricted to users with a certain OAuth2 scope, and another API endpoint which will be available publicly. This last one will expose something called a JSON Web Key Set (JWKS) which we'll use to verify the JWTs we get back from the OAuth2 server.

This is not something we need to do strictly for OAuth2 purposes, but it shows how we could use this system to issue ID tokens or other tokens containing information we want the client to be able to read and verify.

php bin/console make:controller Index

Then add the following to the src/Controller/IndexController.php file:

#[Route('/api/test', name: 'app_api_test')]
public function apiTest(): Response
{
    /** @var User $user */
    $user = $this->getUser();
    return $this->json([
        'message' => 'You successfully authenticated!',
        'email' => $user->getEmail(),
    ]);
}

This is the endpoint we'll call from our client app with our access token.

Next, add another endpoint to read our server's public key and encode the information about it into a JSON response to create a JWKS.

#[Route('.well-known/jwks.json', name: 'app_jwks', methods: ['GET'])]
public function jwks(): Response
{
    // Load the public key from the filesystem and use OpenSSL to parse it.
    $kernelDirectory = $this->getParameter('kernel.project_dir');
    $publicKey = openssl_pkey_get_public(file_get_contents($kernelDirectory . '/var/keys/public.key'));
    $details = openssl_pkey_get_details($publicKey);
    $jwks = [
        'keys' => [
            [
                'kty' => 'RSA',
                'alg' => 'RS256',
                'use' => 'sig',
                'kid' => '1',
                'n' => strtr(rtrim(base64_encode($details['rsa']['n']), '='), '+/', '-_'),
                'e' => strtr(rtrim(base64_encode($details['rsa']['e']), '='), '+/', '-_'),
            ],
        ],
    ];
    return $this->json($jwks);
}

Create the consent page controller

Now we need to create a controller endpoint to handle the consent page.

For the purposes of the tutorial, we're just going to stick all our logic for this inside a new endpoint in the existing LoginController (since the consent page is part of the authorization flow).

There's a fair bit we want to handle here:

  • Get the client_id from the request query and check it's valid
  • Find the client in the database
  • Find the corresponding client profile in the database, so we can display the app name / description / whatever
  • Get the scopes from the request query and check they're valid scopes for the client
  • Check if the user has already granted consent for this client and scopes
  • If they have, redirect them back to the OAuth2 server with the consent value
  • If they haven't, display the consent page
    • If the user has consented to some but not all the scopes requested, display the consent page with the consented scopes listed
  • If the user submits the consent page, save their consents and redirect them back to the OAuth2 server with the consent value

In the real world, we would factor this out in to some smaller services and aim to keep our controller as slim as possible.

But for our tutorial, in your src/Controller/LoginController.php file, add the following function for the app_consent route:

#[Route('/consent', name: 'app_consent', methods: ['GET', 'POST'])]
public function consent(Request $request): Response
{
    $clientId = $request->query->get('client_id');
    if (!$clientId || !ctype_alnum($clientId) || !$this->getUser()) {
        return $this->redirectToRoute('app_index');
    }
    $appClient = $this->em->getRepository(Client::class)->findOneBy(['identifier' => $clientId]);
    if (!$appClient) {
        return $this->redirectToRoute('app_index');
    }
    $appProfile = $this->em->getRepository(OAuth2ClientProfile::class)->findOneBy(['client' => $appClient]);
    $appName = $appProfile->getName();

    // Get the client scopes
    $requestedScopes = explode(' ', $request->query->get('scope'));
    // Get the client scopes in the database
    $clientScopes = $appClient->getScopes();

    // Check all requested scopes are in the client scopes
    if (count(array_diff($requestedScopes, $clientScopes)) > 0) {
        return $this->redirectToRoute('app_index');
    }

    // Check if the user has already consented to the scopes
    /** @var User $user */
    $user = $this->getUser();
    $userConsents = $user->getOAuth2UserConsents()->filter(
        fn (OAuth2UserConsent $consent) => $consent->getClient() === $appClient
    )->first() ?: null;
    $userScopes = $userConsents?->getScopes() ?? [];
    $hasExistingScopes = count($userScopes) > 0;

    // If user has already consented to the scopes, give consent
    if (count(array_diff($requestedScopes, $userScopes)) === 0) {
        $request->getSession()->set('consent_granted', true);
        return $this->redirectToRoute('oauth2_authorize', $request->query->all());
    }

    // Remove the scopes to which the user has already consented
    $requestedScopes = array_diff($requestedScopes, $userScopes);

    // Map the requested scopes to scope names
    $scopeNames = [
        'profile' => 'Your profile',
        'email' => 'Your email address',
        'blog_read' => 'Your blog posts (read)',
        'blog_write' => 'Your blog posts (write)',
    ];

    // Get all the scope names in the requested scopes.
    $requestedScopeNames = array_map(fn($scope) => $scopeNames[$scope], $requestedScopes);
    $existingScopes = array_map(fn($scope) => $scopeNames[$scope], $userScopes);

    if ($request->isMethod('POST')) {
        if ($request->request->get('consent') === 'yes') {
            $request->getSession()->set('consent_granted', true);
            // Add the requested scopes to the user's scopes
            $consents = $userConsents ?? new OAuth2UserConsent();;
            $consents->setScopes(array_merge($requestedScopes, $userScopes));
            $consents->setClient($appClient);
            $consents->setCreated(new \DateTimeImmutable());
            $consents->setExpires(new \DateTimeImmutable('+30 days'));
            $consents->setIpAddress($request->getClientIp());
            $user->addOAuth2UserConsent($consents);
            $this->em->getManager()->persist($consents);
            $this->em->getManager()->flush();
        }
        if ($request->request->get('consent') === 'no') {
            $request->getSession()->set('consent_granted', false);
        }
        return $this->redirectToRoute('oauth2_authorize', $request->query->all());
    }
    return $this->render('login/consent.html.twig', [
        'app_name' => $appName,
        'scopes' => $requestedScopeNames,
        'has_existing_scopes' => $hasExistingScopes,
        'existing_scopes' => $existingScopes,
    ]);
}

And create the consent.html.twig template inside the templates/login directory. The template should contain a form to submit consent value and show any existing consents as well as the list of requested consents.

A full example can be found in the sample app on my GitHub.

Configure the firewall

We just need to make a few adjustments to our security.yaml file to make sure the endpoints we've created are exposed or protected as appropriate.

firewalls:
  api:
    pattern: ^/api
    security: true
    stateless: true
    oauth2: true
# ...
access_control:
  - { path: ^/authorize, roles: PUBLIC_ACCESS }
  - { path: ^/login, role: PUBLIC_ACCESS }
  - { path: ^/token, role: PUBLIC_ACCESS }
  - { path: ^/.well-known, roles: PUBLIC_ACCESS }
  - { path: ^/api/test, role: ROLE_OAUTH2_EMAIL }
  - { path: ^/, role: ROLE_USER }

Our client application

Now that we have our OAuth2 server, we can create a client application to test it out.

This can take any form you like, for my sample I've built a simple, single file script which you can find in the client subdirectory of the project.

When a user wants to log in via OAuth2, they are first redirected to the /authorize endpoint of the server, with the following request parameters:

  • client_id - The client identifier
  • redirect_uri - The URI to redirect the user to after they have logged in / given consent
  • response_type - The response type, which should be code
  • scope - The scopes to request, which for my sample app are profile email blog_read blog_write.

The client will also have a /callback endpoint which is what we'll use as the redirect_uri for our test client. This endpoint will receive the code parameter which we can use to request an access token.

We do this by calling the /token endpoint of the server, with the following request parameters:

  • client_id - The client identifier
  • client_secret - The client secret
  • redirect_uri - The URI to redirect the user to after they have logged in / given consent
  • grant_type - The grant type, which should be authorization_code
  • code - The code received from the /authorize endpoint

The server will then return an access token, which we can use to make requests to the /api/test endpoint.

In my sample client on the GitHub repo, I've also obtained the JWKS we exposed from the auth server's /.well-known/jwks.json endpoint and used it in conjunction with a JWT parsing library to verify the authenticity of the returned token.

Create a client on the OAuth2 server

Before we can use our client application, we need to create a client on the OAuth2 server. We will also need to create a user account to log in with.

In my full sample, I've provided a bootstrap command in the server application to do this.

php bin/console app:bootstrap

This will create a client with the following details:

  • Client ID: testclient
  • Client Secret: testpass
  • Redirect URI: http://localhost:8080/callback
  • Scopes: profile email blog_read blog_write
  • Grants: authorization_code refresh_token

It will also create a user with the following details:

The League OAuth2 Server bundle also provides some console commands to help you create, update and delete clients, which you can read about in their documentation.

  • php bin/console league:oauth2-server:create-client
  • php bin/console league:oauth2-server:update-client
  • php bin/console league:oauth2-server:delete-client
  • php bin/console league:oauth2-server:list-clients

Try it out

Now we can try out our OAuth2 server and client application.

Instructions for spinning up the server and client can be found in the README of the sample project on the GitHub repo, or if you've built your own project following this tutorial, you should run your server app either through the Symfony built-in web server or a local web server like Apache or Nginx, and run the client script either using the PHP built-in web server or your local web server.

# From project directory
symfony server:start -d --port=8000
# From client/ directory
php -S localhost:8080 app.php

First we want to hit our client app in the browser, by visiting http://localhost:8080.

Screenshot showing OAuth2 client home page

...which will take us to the server login page, because we are not already logged in.

Screenshot showing OAuth2 server login page

...we log in with the user we created earlier.

Screenshot showing OAuth2 server login page with credentials

...and we are redirected to the consent page

Screenshot showing OAuth2 server consent page

...where we can see the scopes we've requested, and any existing scopes we've already given consent for. We accept the consent and are redirected back to the client app.

Screenshot showing OAuth2 client home page with access token

...and now we can call the /api/test endpoint of the server, which will return the user's profile.

Screenshot showing OAuth2 client API result page

Wrapping up

My sample app is missing a few key pieces you would want to include in a real-world OAuth2 server.

  • A way for new users to register.
  • A way for users to reset their password.
  • A way for users to manage and revoke existing consents.

These pieces are all trivial enough for anyone familiar with Symfony to implement, so I've left them out of the tutorial.

In addition, although the sample client app stores the access token to make API requests, it doesn't have any logic to refresh the token when it expires. Our OAuth2 server, however, does include a refresh token grant type, so you could easily add this to your client app.

This was quite a long post to write, hope you enjoyed it and found it useful!


Comments

Add a comment

All comments are pre-moderated and will not be published until approval.

Your name
CommentYou can write in _italics_ or **bold** like this.

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK