Implement fast OAuth2 server with PHP and Slim
We at e.GO Mobile using the OAuth2 workflow for authentification for most of the services in our big API cluster.
In most cases, API calls need to be made that run through a client application without involving a user.
This scenario requires client credentials which are usually used to produce JSON Web Tokens (JWT), which have validity for a certain period of time.
Today, I would like to show how to implement a simple API with JWT validation using the Slim framework in PHP.
What is JWT?
JSON Web Tokens (JWT) are a popular method for securely transmitting information between two parties. JWTs consist of three parts: a header, a payload, and a signature. The header contains information about the type of token and the algorithm used to sign it. The payload contains the data that is being transmitted, such as user information or authentication data. The signature is used to verify that the token has not been tampered with during transmission.
The construction of a JWT involves several steps. First, the header and payload are encoded using Base64Url encoding. The resulting strings are then concatenated with a period separator to form the unsigned JWT. Next, a secret key is used to sign the JWT using a hashing algorithm such as HMAC or RSA. The resulting signature is then appended to the JWT, separated by another period. The final JWT can be transmitted between parties, and the recipient can verify its authenticity by checking the signature using the same secret key.
JWTs have several advantages over other methods of transmitting data, such as cookies or query parameters. They are self-contained, meaning that all the information needed to authenticate a user or authorize an action is contained within the token itself. This reduces the need for server-side storage and can improve performance. Additionally, JWTs can be used across multiple domains and platforms, making them a versatile solution for modern web applications.
Requirements
For the demo we need Docker with docker-compose.
Create an .env.local
in the root folder of the project and add following content:
TGF_JWT_SECRET="test"
TGF_CLIENT_ID="foo"
TGF_CLIENT_SECRET="bar"
TGF_JWT_SECRET
will be used for the key when using HMAC_SHA256 algorithm for the hash. ALWAYS KEEP THIS PRIVATE AND NEVER SHARE WITH THE PUBLIC!
Start demo
To run the demo locally, you only need to execute
docker compose up
in your terminal application.
The API should later be available at base URL http://localhost:8080/
.
To test it out, do a request like
POST http://localhost:8080/oauth2/token
Content-type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=foo&client_secret=bar
with a HTTP client like Postman or REST Client for Visual Studio Code.
The response could look like this:
HTTP/1.1 200 OK
Host: localhost:8080
Date: Tue, 26 Mar 2024 11:02:37 GMT
Connection: close
X-Powered-By: PHP/8.3.4
Content-Type: application/json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwOi8vZXhhbXBsZS5iaXoiLCJpYXQiOjE3MTE0NTA5NTcsIm5iZiI6MTcxMTQ1MDk1NywiZXhwIjoxNzExNDU0NTU3LCJjbGllbnRfaWQiOiJmb28ifQ.lnd4Ua0YAnvUhpy12-UvTQvTcoMtRWR2SwksWEj683w",
"expires_in": 3600
}
Now take the value from access_token
and do the second test with an endpoint that requires a valid JWT:
GET http://localhost:8080/user
Authorization: Bearer eyJpc3MiOiJodHRwOi8vZXhhbXBsZS5jb20iLCJhdWQiOiJodHRwOi8vZXhhbXBsZS5iaXoiLCJpYXQiOjE3MTE0NTA5NTcsIm5iZiI6MTcxMTQ1MDk1NywiZXhwIjoxNzExNDU0NTU3LCJjbGllbnRfaWQiOiJmb28ifQ.lnd4Ua0YAnvUhpy12-UvTQvTcoMtRWR2SwksWEj683w
If you get an answer like this
{
"iss": "http:\/\/example.org",
"aud": "http:\/\/example.com",
"iat": 1711449465,
"nbf": 1711449465,
"exp": 1711453065,
"client_id": "foo"
}
everything works fine.
Explaining the code
To create a new PHP project, you can use composer, which is the most favorite package manager.
After installed, create a new project from the official Slim template:
composer create-project slim/slim-skeleton my-oauth2-server
A small disadvantage is, that the “skeleton template” produces a lot of code! As you can see in the demo I broke it down to a minimum so it is better readble.
Beside Slim, I installed firebase/php-jwt package for the encoding and decoding process via composer
.
Setup a basic app
With the AppFactory class of Slim we can simply create a new app instance that handles any request:
<?php
// ...
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
$app = AppFactory::create();
// parse JSON, XML, forms, etc. automatically
$app->addBodyParsingMiddleware();
$app->get('/', function(Request $request, Response $response) {
$response->getBody()->write('Hello, world!');
});
// ...
$app->run();
Token endpoint
In the first step we create the endpoint that generates new tokens using the client_credentials
workflow:
<?php
// ...
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// ...
define("TGF_JWT_ALGO", 'HS256');
define("TGF_JWT_LIFETIME", 3600);
// ...
$app->post('/oauth2/token', function (Request $request, Response $response) {
// such secret should be stored as environment variable for
// the current running PHP process
$jwtKey = strval(getenv("TGF_JWT_SECRET"));
// should be come from database later
$clientId = trim(strval(getenv("TGF_CLIENT_ID")));
$clientSecret = trim(strval(getenv("TGF_CLIENT_SECRET")));
$body = $request->getParsedBody();
if (is_array($body)) {
$response = $response->withStatus(200)
->withHeader('Content-Type', 'text/plain');
if (@$body['grant_type'] === "client_credentials") {
if (
@$body['client_id'] === $clientId &&
@$body['client_secret'] === $clientSecret
) {
// valid, generate token ...
$now = time();
$jwtPayload = [
// "known" fields
'iss' => 'http://example.com',
'aud' => 'http://example.com',
'iat' => $now,
'nbf' => $now,
'exp' => $now + TGF_JWT_LIFETIME,
// custom fields
'client_id' => $body['client_id']
];
$encodedJWT = JWT::encode($jwtPayload, $jwtKey, TGF_JWT_ALGO);
$response = $response->withStatus(200)
->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode([
'access_token' => $encodedJWT,
"expires_in" => TGF_JWT_LIFETIME,
]));
} else {
// invalid credentials
$response = $response->withStatus(401)
->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode([
'error' => 'invalid_client'
]));
}
} else {
// grant_type not supported
$response = $response->withStatus(400)
->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode([
'error' => 'unsupported_grant_type'
]));
}
} else {
// invalid input data
$response = $response->withStatus(400)
->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode([
'error' => 'invalid_request'
]));
}
return $response;
});
// ...
JWT Middleware
To check for valid JWT, we can define a middleware as closure:
<?php
// ...
$checkJWT = function(Request $request, RequestHandler $handler) use ($app) {
$jwtKey = strval(getenv("TGF_JWT_SECRET"));
$clientId = trim(strval(getenv("TGF_CLIENT_ID")));
$clientSecret = trim(strval(getenv("TGF_CLIENT_SECRET")));
$response = $app->getResponseFactory()->createResponse();
$autherization = trim(@$request->getHeader('Authorization')[0]);
if (stripos($autherization, 'bearer ') === 0) {
// starts with 'bearer '
$jwt = trim(substr($autherization, 7));
try {
$decodedJWT = JWT::decode($jwt, new Key($jwtKey, TGF_JWT_ALGO));
// share `$decodedJWT` for the upcoming parts
// of the current execution chain
$request = $request->withAttribute("CURRENT_JWT", $decodedJWT);
return $handler->handle($request);
} catch (Exception $ex) {
// parsing failed
}
}
// we have no valid JWT
$response->withStatus(400)
->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode([
'error' => 'invalid_grant'
]));
return $response;
};
// ...
This instance in $checkJWT
can we later reuse in every of our endpoints that require a valid token:
<?php
// ...
$app->get('/user', function (Request $request, Response $response) {
// this comes from the middleware
// after successful decoding
$jwt = $request->getAttribute("CURRENT_JWT");
$response = $response->withStatus(200)
->withHeader('Content-Type', 'application/json');
// this is only to show that anything worked fine
$response->getBody()->write(json_encode($jwt));
return $response;
})->add($checkJWT);
// ...
Remarks
To set up Slim in a way that it can handle every request by the index.php, all requests must be redirected through a rewrite, no matter what URL the client submits.
A corresponding .htaccess for Apache e.g. could look like this, for example:
Options All -Indexes
<Files .htaccess>
order allow,deny
deny from all
</Files>
<IfModule mod_rewrite.c>
# Redirect to the public folder
RewriteEngine On
# RewriteBase /
RewriteRule ^$ public/ [L]
RewriteRule (.*) public/$1 [L]
# Redirect to HTTPS
# RewriteEngine On
# RewriteCond %{HTTPS} off
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</IfModule>
Conclusion
Slim is a very nice alternative to Laravel if you want to create fast microservices.
The API is very similar to that of Express.js & Co.
Happy coding! 🎉