Skip to content

oumarkonate/openidconnect

Repository files navigation

OpenID Connect Demo Server — Symfony 7.4

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:


Table of Contents


Overview

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

Architecture

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)

Prerequisites

  • 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 sqlite3

Symfony 8 note: Symfony 8 requires PHP 8.4. To upgrade later: install php8.4-* packages, then change "7.*" to "8.*" in composer.json and run composer update. No application code changes are needed.


Installation

# 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-interaction

Configuration

All 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=3600

Security note: config/keys/private.pem and config/keys/public.pem are in .gitignore. Never commit private keys. Use different key pairs per environment.


Running the Server

php -S localhost:8000 -t public/

The server is now available at http://localhost:8000.


Demo Users & Client

Users (in-memory, defined in config/packages/security.yaml)

Username Password Claims
alice alice123 Alice Dupont, alice@example.com
bob bob456 Bob Martin, bob@example.com

OAuth2 Client (loaded by fixture)

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;"

API Endpoints

GET /.well-known/openid-configuration

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"]
}

GET /oauth2/v3/certs

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.


GET /oauth/v2/auth

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

POST /oauth/v2/token

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

GET /oauth/v2/user

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."
}

Complete Flow Walkthrough

This is the full Authorization Code + OIDC flow demonstrated with curl.

Step 1 — Authorization Request (browser)

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.


Step 2 — Token Exchange

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"
}

Step 3 — Decode the ID Token

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)

Step 4 — Call UserInfo

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"
}

Validating the ID Token

Using jwt.io

Paste the id_token at jwt.io. In the Verify Signature section, paste the contents of config/keys/public.pem.

Using PHP

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());

Using the JWKS endpoint (as a real client would)

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.

Running Tests

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.

Setup

# Create the test database and run migrations
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --no-interaction --env=test

Run all tests

php bin/phpunit

Test structure

tests/
└── 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

Coverage by scenario

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

Project Structure

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

How the Nonce Works

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.


How the JWKS Endpoint Works

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.


Related Resources

About

Adding OpenID Connect to an existing OAuth2 server — a step-by-step Symfony 7.4 implementation with JWT RS256, JWKS, discovery endpoint and full test coverage

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors