A minimal, self-contained OpenID Connect Authorization Server built on top of a custom OAuth 2.0 implementation with Symfony 7.4 LTS.
This project is the reference implementation for the article series:
- Part 1 — OpenID Connect: A Concrete Implementation from an OAuth2 Server
- Part 2 — PHP/Symfony Technical Deep Dive (coming soon)
- Overview
- Architecture
- Prerequisites
- Installation
- Configuration
- Running the Server
- Demo Users & Client
- API Endpoints
- Complete Flow Walkthrough
- Validating the ID Token
- Running Tests
- Project Structure
This server implements the OpenID Connect Authorization Code Flow on top of a hand-rolled OAuth 2.0 foundation. FOSOAuthServerBundle was intentionally not used — it relies on AuthenticationProviderInterface, removed in Symfony 6, and is therefore incompatible with any modern Symfony version. The custom implementation also makes the OAuth2 internals explicit, which fits the pedagogical goal of the article.
The project is intentionally minimal, designed to be readable and educational rather than production-ready.
What it covers:
- Authorization Code grant (
response_type=code) - ID Token generation (JWT, RS256)
- Nonce anti-replay protection
- OIDC Discovery document (
/.well-known/openid-configuration) - JSON Web Key Set endpoint (
/oauth2/v3/certs) - UserInfo endpoint protected by Bearer token
What it deliberately omits (to keep the code focused):
- PKCE (
code_challenge/code_verifier) - Refresh tokens
- Implicit / Hybrid flows
- Dynamic client registration
- Token introspection / revocation
Stack:
| Component | Choice |
|---|---|
| Framework | Symfony 7.4 LTS |
| PHP | 8.2+ |
| Database | SQLite (via Doctrine ORM 2.x) |
| JWT library | firebase/php-jwt v6 |
| JWT algorithm | RS256 (RSA 2048-bit keys) |
| Users | In-memory (Symfony security config) |
| Tests | PHPUnit 11 + Zenstruck Foundry v2 |
Client App
│
│ 1. GET /oauth/v2/auth?response_type=code&scope=openid&nonce=...
▼
AuthorizeController ──► Consent form (Twig)
│
│ 2. POST /oauth/v2/auth (user clicks "Allow")
│ Stores AuthCode + AuthNonce in SQLite
│ Redirects to redirect_uri?code=...
▼
Client App
│
│ 3. POST /oauth/v2/token (code exchange)
▼
TokenController
├── Validates client credentials
├── Validates auth code (not expired, same client, same redirect_uri)
├── Retrieves nonce from AuthNonce table
├── Creates AccessToken in DB
├── Deletes AuthCode + AuthNonce
└── If scope=openid: generates ID Token (JWT via JwtService)
│
│ Returns: { access_token, token_type, expires_in, id_token }
▼
Client App
│
│ 4. GET /oauth/v2/user
│ Authorization: Bearer <access_token>
▼
OAuthBearerAuthenticator ──► AccessTokenRepository::findValidToken()
│
UserInfoController ──► UserInformationManager::getUserInfoResponse()
│
│ Returns: { sub, name, given_name, family_name, email, locale }
▼
Client App
Key components:
| Class | Role |
|---|---|
AuthorizeController |
Shows consent form, issues AuthCode, stores AuthNonce |
TokenController |
Validates code, issues AccessToken, generates id_token |
UserInfoController |
Returns user claims for a valid Bearer token |
ConfigurationController |
Serves /.well-known/openid-configuration |
JWKController |
Serves RSA public key in JWK format |
JwtService |
Encodes the ID Token payload with RS256 |
JwtKeyManagerService |
Loads RSA keys from disk, extracts JWK parameters (n, e) |
OAuthBearerAuthenticator |
Symfony custom authenticator — validates Bearer tokens |
UserInformationManager |
Maps a username to OIDC standard claims |
ClientManager |
Validates client credentials (client_id + client_secret) |
- PHP 8.2+ (Symfony 7.4 requires PHP 8.2 minimum)
- Composer
- OpenSSL (for RSA key generation)
- SQLite3
On Ubuntu/Debian:
sudo apt install php8.2 php8.2-cli php8.2-xml php8.2-sqlite3 php8.2-mbstring \
php8.2-curl php8.2-openssl composer sqlite3Symfony 8 note: Symfony 8 requires PHP 8.4. To upgrade later: install
php8.4-*packages, then change"7.*"to"8.*"incomposer.jsonand runcomposer update. No application code changes are needed.
# 1. Clone the repository
git clone <repo-url> openidconnect
cd openidconnect
# 2. Install dependencies
composer install
# 3. Generate RSA key pair (RS256 signing keys)
mkdir -p config/keys
openssl genrsa -out config/keys/private.pem 2048
openssl rsa -in config/keys/private.pem -pubout -out config/keys/public.pem
# 4. Create the SQLite database and run migrations
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate --no-interaction
# 5. Load the demo OAuth2 client fixture
php bin/console doctrine:fixtures:load --no-interactionAll settings live in .env. Copy it to .env.local to override locally:
APP_ENV=dev
APP_SECRET=oidc_demo_secret_change_in_prod
# SQLite database (created in var/)
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# The public base URL of this server — used as JWT issuer and in discovery doc
OAUTH_ISSUER=http://localhost:8000
# RSA key paths (relative via %kernel.project_dir%)
JWT_PRIVATE_KEY_PATH=%kernel.project_dir%/config/keys/private.pem
JWT_PUBLIC_KEY_PATH=%kernel.project_dir%/config/keys/public.pem
# Key identifier — appears in JWT header ("kid") and JWKS
JWT_KEY_ID=dev-key-001
# ID Token lifetime in seconds
ID_TOKEN_TTL=3600Security note:
config/keys/private.pemandconfig/keys/public.pemare in.gitignore. Never commit private keys. Use different key pairs per environment.
php -S localhost:8000 -t public/The server is now available at http://localhost:8000.
| Username | Password | Claims |
|---|---|---|
alice |
alice123 |
Alice Dupont, alice@example.com |
bob |
bob456 |
Bob Martin, bob@example.com |
| Parameter | Value |
|---|---|
client_id |
demo_app |
client_secret |
demo_secret |
| Allowed redirect URIs | http://localhost:8080/callback, http://localhost/callback |
| Grant types | authorization_code |
To see the registered clients:
sqlite3 var/data.db "SELECT client_id, name, redirect_uris FROM oauth_client;"Public. Returns the OIDC discovery document.
curl http://localhost:8000/.well-known/openid-configuration | jq .{
"issuer": "http://localhost:8000",
"authorization_endpoint": "http://localhost:8000/oauth/v2/auth",
"token_endpoint": "http://localhost:8000/oauth/v2/token",
"userinfo_endpoint": "http://localhost:8000/oauth/v2/user",
"jwks_uri": "http://localhost:8000/oauth2/v3/certs",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid"],
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "nonce", "name", "given_name", "family_name", "email", "locale"]
}Public. Returns the RSA public key in JWK Set format. Client applications use this to verify ID Token signatures.
curl http://localhost:8000/oauth2/v3/certs | jq .{
"keys": [{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": "dev-key-001",
"n": "sHtm6bZPiMpf...",
"e": "AQAB"
}]
}The n (modulus) and e (exponent) parameters are base64url-encoded big-endian representations of the RSA key components. Together they uniquely identify the public key.
Requires authentication (redirects to /login if not logged in). Displays the consent form.
Parameters:
| Parameter | Required | Description |
|---|---|---|
response_type |
yes | Must be code |
client_id |
yes | Registered client identifier |
redirect_uri |
yes | Must match a registered URI for this client |
scope |
no | Space-separated scopes. Use openid to get an ID Token |
nonce |
recommended | Anti-replay value; will be embedded in the ID Token |
state |
recommended | Opaque value echoed back in the redirect |
Success response: redirect to redirect_uri?code=<auth_code>&state=<state>
Error responses:
| Condition | Behaviour |
|---|---|
Unknown client_id |
400 Bad Request |
redirect_uri not registered |
400 Bad Request |
response_type != code |
Redirect to redirect_uri?error=unsupported_response_type |
| User clicks "Deny" | Redirect to redirect_uri?error=access_denied |
Public. Exchanges an authorization code for tokens.
Request (application/x-www-form-urlencoded):
| Parameter | Required | Description |
|---|---|---|
grant_type |
yes | Must be authorization_code |
code |
yes | The authorization code from the redirect |
client_id |
yes | |
client_secret |
yes | |
redirect_uri |
yes | Must match the URI used during authorization |
Success response:
{
"access_token": "a3f8c1d2...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRldi1rZXktMDAxIn0..."
}The id_token is only present when scope=openid was requested during authorization.
Error responses:
| Condition | HTTP | error |
|---|---|---|
grant_type is not authorization_code |
400 | unsupported_grant_type |
Bad client_id or client_secret |
401 | invalid_client |
Code not found, expired, wrong client, or wrong redirect_uri |
400 | invalid_grant |
Protected — requires a valid Authorization: Bearer <access_token> header.
curl http://localhost:8000/oauth/v2/user \
-H "Authorization: Bearer <access_token>"Success response:
{
"sub": "alice",
"name": "Alice Dupont",
"given_name": "Alice",
"family_name": "Dupont",
"email": "alice@example.com",
"locale": "fr-FR"
}Error response (401):
{
"error": "invalid_token",
"error_description": "Invalid or expired access token."
}This is the full Authorization Code + OIDC flow demonstrated with curl.
Open this URL in your browser:
http://localhost:8000/oauth/v2/auth
?response_type=code
&client_id=demo_app
&redirect_uri=http://localhost:8080/callback
&scope=openid
&nonce=random-nonce-value-abc123
&state=my-state-xyz
Log in as alice / alice123, then click Allow.
Your browser will be redirected to:
http://localhost:8080/callback?code=<AUTHORIZATION_CODE>&state=my-state-xyz
Copy the code value.
CODE="<paste the code here>"
curl -s -X POST http://localhost:8000/oauth/v2/token \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=demo_app" \
-d "client_secret=demo_secret" \
-d "redirect_uri=http://localhost:8080/callback" | jq .Response:
{
"access_token": "d4e8f2a1b3c9...",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRldi1rZXktMDAxIn0.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW9fYXBwIiwibm9uY2UiOiJyYW5kb20tbm9uY2UtdmFsdWUtYWJjMTIzIiwiaWF0IjoxNzE2MDAwMDAwLCJleHAiOjE3MTYwMDM2MDB9.SIGNATURE"
}The ID Token is a JWT with three parts separated by . (header.payload.signature).
Decode the payload:
ID_TOKEN="<paste id_token here>"
# Extract and decode the payload (second part)
echo $ID_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .{
"iss": "http://localhost:8000",
"sub": "alice",
"aud": "demo_app",
"nonce": "random-nonce-value-abc123",
"iat": 1716000000,
"exp": 1716003600,
"name": "Alice Dupont",
"given_name": "Alice",
"family_name": "Dupont",
"email": "alice@example.com",
"locale": "fr-FR"
}Claims explained:
| Claim | Description |
|---|---|
iss |
Issuer — the URL of this OIDC server |
sub |
Subject — stable identifier of the authenticated user |
aud |
Audience — the client_id this token was issued for |
nonce |
Anti-replay value — must match what was sent in Step 1 |
iat |
Issued-at — Unix timestamp |
exp |
Expiration — Unix timestamp (iat + ID_TOKEN_TTL) |
ACCESS_TOKEN="<paste access_token here>"
curl -s http://localhost:8000/oauth/v2/user \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq .{
"sub": "alice",
"name": "Alice Dupont",
"given_name": "Alice",
"family_name": "Dupont",
"email": "alice@example.com",
"locale": "fr-FR"
}Paste the id_token at jwt.io. In the Verify Signature section, paste the contents of config/keys/public.pem.
require 'vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$publicKey = file_get_contents('config/keys/public.pem');
$decoded = JWT::decode($idToken, new Key($publicKey, 'RS256'));
// Verify claims
assert($decoded->iss === 'http://localhost:8000');
assert($decoded->aud === 'demo_app');
assert($decoded->nonce === 'random-nonce-value-abc123'); // ← anti-replay check
assert($decoded->exp > time());A production client should fetch the public key dynamically from the JWKS URI rather than using a locally stored file:
# Fetch the JWK
curl -s http://localhost:8000/oauth2/v3/certs | jq '.keys[0]'
# The client reconstructs the RSA public key from n and e,
# then verifies the JWT signature.The test suite uses PHPUnit 11 with Zenstruck Foundry v2 for database fixtures. Tests run against an isolated SQLite database (var/data_test.db) that is automatically created and reset between each test class.
# Create the test database and run migrations
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --no-interaction --env=testphp bin/phpunittests/
└── Integration/
├── AuthorizeControllerTest.php # Input validation, consent page, error redirects
├── TokenControllerTest.php # Error cases + code mismatch + scope handling
├── UserInfoControllerTest.php # Auth enforcement + error response structure
├── ConfigurationControllerTest.php # Discovery document completeness
├── JWKControllerTest.php # JWKS key structure
└── OpenIdConnectFlowTest.php # Full end-to-end scenarios
| Test class | Scenarios covered |
|---|---|
AuthorizeControllerTest |
Unknown client_id → 400; invalid redirect_uri → 400; unsupported response_type → error redirect; consent page renders client name |
TokenControllerTest |
Unsupported grant type; invalid client; expired/unknown code; code issued for another client; redirect_uri mismatch; token without openid scope has no id_token |
UserInfoControllerTest |
Missing auth → 401; invalid token → 401; error JSON structure + WWW-Authenticate header |
ConfigurationControllerTest |
All required OIDC discovery keys present; correct endpoint URLs |
JWKControllerTest |
RSA key type, algorithm, use, kid, n, e all present and non-empty |
OpenIdConnectFlowTest |
Full flow with nonce: code → token → id_token claims → nonce lifecycle → userinfo; flow without nonce; deny → access_denied redirect |
openidconnect/
│
├── config/
│ ├── keys/
│ │ ├── private.pem # RSA private key (gitignored)
│ │ └── public.pem # RSA public key (gitignored)
│ ├── packages/
│ │ ├── doctrine.yaml # SQLite config
│ │ ├── framework.yaml # Session (mock_file in test env)
│ │ └── security.yaml # In-memory users + firewalls
│ ├── routes.yaml # All route definitions
│ └── services.yaml # DI wiring for JWT services + controllers
│
├── src/
│ ├── Controller/
│ │ ├── AuthorizeController.php # GET/POST /oauth/v2/auth
│ │ ├── TokenController.php # POST /oauth/v2/token
│ │ ├── UserInfoController.php # GET /oauth/v2/user
│ │ ├── ConfigurationController.php # GET /.well-known/openid-configuration
│ │ ├── JWKController.php # GET /oauth2/v3/certs
│ │ └── LoginController.php # GET /login (form)
│ │
│ ├── Entity/
│ │ ├── Client.php # OAuth2 client (client_id, secret, redirect_uris)
│ │ ├── AuthCode.php # Authorization code (short-lived, single-use)
│ │ ├── AccessToken.php # Bearer access token
│ │ └── AuthNonce.php # Nonce linked to an auth code (anti-replay)
│ │
│ ├── Factory/ # Zenstruck Foundry factories (used in tests)
│ │ ├── ClientFactory.php
│ │ └── AuthNonceFactory.php
│ │
│ ├── Infrastructure/Jwt/
│ │ ├── JwtService.php # Builds and signs the ID Token payload
│ │ └── JwtKeyManagerService.php # Loads RSA keys; exposes JWK parameters
│ │
│ ├── Manager/
│ │ ├── ClientManager.php # Client lookup + credential validation
│ │ └── UserInformationManager.php # Maps username → OIDC standard claims
│ │
│ ├── Repository/
│ │ ├── AccessTokenRepository.php # findValidToken() (non-expired)
│ │ ├── AuthCodeRepository.php # findValidCode() (non-expired)
│ │ ├── AuthNonceRepository.php # findByCode()
│ │ └── ClientRepository.php # findByClientId()
│ │
│ └── Security/
│ └── OAuthBearerAuthenticator.php # Symfony custom authenticator
│
├── templates/
│ ├── base.html.twig
│ ├── oauth/authorize.html.twig # Consent form (Allow / Deny)
│ └── security/login.html.twig # Login form
│
├── tests/
│ └── Integration/ # All integration tests (see above)
│
├── .env # Default environment variables
├── .env.test # Test overrides (SQLite test DB, localhost issuer)
├── composer.json
└── phpunit.xml.dist
The nonce is the key mechanism that binds the authorization request to the ID Token, preventing replay attacks.
1. Client generates a random nonce ("abc123") and sends it in the authorization request
2. AuthorizeController stores it in the `auth_nonce` table, linked to the authorization code
3. TokenController retrieves the nonce using the auth code before deleting it
4. The nonce is embedded in the ID Token payload ("nonce": "abc123")
5. After token exchange, the AuthNonce row is deleted — the nonce cannot be reused
6. Client verifies: decoded_jwt.nonce === the nonce it originally sent
If an attacker intercepts and replays an authorization code, the nonce will no longer exist in the database at token exchange time. The nonce claim will be absent from the ID Token, and the client's validation step will reject it.
The /oauth2/v3/certs endpoint exposes the RSA-2048 public key in JWK format. Client applications use it to verify ID Token signatures without needing to contact this server again.
// JwtKeyManagerService extracts key parameters using PHP's openssl extension
$keyDetails = openssl_pkey_get_details(openssl_pkey_get_public($publicKeyPem));
$jwk = [
'kty' => 'RSA',
'alg' => 'RS256',
'use' => 'sig',
'kid' => 'dev-key-001', // Matches the JWT header's "kid"
'n' => base64url_encode($keyDetails['rsa']['n']), // Modulus
'e' => base64url_encode($keyDetails['rsa']['e']), // Public exponent (65537 → "AQAB")
];The kid (Key ID) in the JWK matches the kid in the JWT header. A client with multiple keys cached can quickly identify which key to use for verification.