esanj/auth-bridge

OAuth 2.0 Bridge package for connecting to external authorization servers.

Maintainers

Package info

github.com/eSanjDev/ms-package-accounting-bridge

pkg:composer/esanj/auth-bridge

Transparency log

Statistics

Installs: 98

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-07-11 12:42 UTC

This package is auto-updated.

Last update: 2026-07-12 05:41:57 UTC


README

AuthBridge is a Laravel package that connects your app to an OAuth 2.0 authorization server. It handles the Authorization Code flow (redirect users to log in, then receive their token) and the Client Credentials flow (server‑to‑server tokens), plus RS256 JWT verification — with an event‑driven design so you decide what happens when a token arrives.

Originally built for the Esanj Accounting service, but it works with any OAuth 2.0 server. Many Esanj packages (e.g. esanj/managers) depend on it for login.

Features

  • OAuth 2.0 Authorization Code and Client Credentials grants.
  • Silent refresh — the stored access token is transparently refreshed via its refresh token before it expires, so users never notice.
  • CSRF state validation on callback (enforced in production).
  • Event‑driven: TokenReceived, TokenExchangeFailed, AuthorizationRedirecting.
  • JWT extraction & verification (RS256) against the OAuth server's public key.
  • Automatic token caching for the Client Credentials flow.
  • A facade with handy session token helpers.

Requirements

  • PHP: 8.1 – 8.4
  • Laravel: 10.x – 13.x
  • OAuth Server: any OAuth 2.0 compliant server
  • firebase/php-jwt (installed automatically) — used for JWT verification.

Installation

composer require esanj/auth-bridge

The service provider and the AuthBridge facade are auto‑discovered.

Configuration

1. Publish the config

php artisan vendor:publish --tag="esanj-auth-bridge-config"

This creates config/esanj/auth_bridge.php (merged internally under the key esanj.auth_bridge).

2. Environment variables

# OAuth client credentials (required)
ACCOUNTING_BRIDGE_CLIENT_ID=your-client-id
ACCOUNTING_BRIDGE_CLIENT_SECRET=your-client-secret

# OAuth server base URL (required)
ACCOUNTING_BRIDGE_BASE_URL=https://oauth-server.example.com

# Authorization prompt: none | consent | login
ACCOUNTING_BRIDGE_OAUTH_PROMPT=consent

# Callback URL (optional — auto-generated from APP_URL + prefix + callback path if unset)
ACCOUNTING_BRIDGE_REDIRECT_URL=https://yourapp.com/accounting/callback

# Where to send the user after a successful login
ACCOUNTING_BRIDGE_SUCCESS_REDIRECT=/dashboard

# Routing
ACCOUNTING_BRIDGE_ROUTE_PREFIX=accounting   # prefix for the two routes
ACCOUNTING_BRIDGE_PATH_REDIRECT=login        # the "start login" path
ACCOUNTING_BRIDGE_PATH_CALLBACK=callback     # the OAuth callback path
ACCOUNTING_BRIDGE_MIDDLEWARE=web             # comma-separated middleware

# JWT public key (RS256) used to verify tokens
ACCOUNTING_BRIDGE_KEY_PATH=/path/to/oauth-public.key

# Silent refresh (optional)
ACCOUNTING_BRIDGE_REFRESH_PATH=/oauth/token   # Passport uses the token endpoint with grant_type=refresh_token
ACCOUNTING_BRIDGE_REFRESH_BUFFER=60            # refresh this many seconds before the access token expires

3. Config options

Option Description
client_id / client_secret OAuth 2.0 credentials.
base_url Base URL of the OAuth server.
redirect_url Callback URL (auto‑generated from APP_URL if not set).
auth2_prompt OAuth prompt: none, consent, or login.
success_redirect Where to redirect after a successful login.
routes.prefix / routes.middleware Prefix and middleware for the package routes.
route_path.redirect / route_path.callback Paths for the redirect and callback endpoints.
public_key_path Path to the OAuth server's RS256 public key.
refresh_token_path Endpoint for the refresh‑token grant (default /oauth/token, Passport standard).
refresh_buffer_seconds Refresh the access token this many seconds before it expires (default 60).
session_state_key / session_token_key Session keys (auth_bridge_state / auth_bridge).

Routes

Method Path (default) Name Description
GET /{prefix}/{redirect}/accounting/login auth-bridge.redirect Starts the OAuth flow (redirects to the server).
GET /{prefix}/{callback}/accounting/callback auth-bridge.callback Handles the callback and stores the token.

Usage

Authorization Code flow (user login)

Step 1 — send the user to log in:

return redirect()->route('auth-bridge.redirect');

The package builds the authorization URL, stores a random state in the session, fires AuthorizationRedirecting, and redirects to the OAuth server.

Step 2 — the callback is handled for you. On return the package validates state (in production), exchanges the code for a token, stores the token in the session under auth_bridge, fires TokenReceived, and redirects to config('esanj.auth_bridge.success_redirect').

ℹ️ success_redirect and the callback URL are taken from config/env. (Passing them as query parameters to the route is not currently supported — see Notes.)

Step 3 — react to the token via the TokenReceived event (recommended).

// app/Listeners/HandleTokenReceived.php
use Esanj\AuthBridge\Events\TokenReceived;
use Esanj\AuthBridge\Contracts\ClientCredentialsServiceInterface;
use Illuminate\Support\Facades\Auth;
use App\Models\User;

class HandleTokenReceived
{
    public function handle(TokenReceived $event): void
    {
        $token = $event->tokenData;                 // TokenData DTO
        // $event->grantType === 'authorization_code'

        $jwt = app(ClientCredentialsServiceInterface::class)->extractJwt($token->accessToken);

        $user = User::firstOrCreate(
            ['oauth_id' => $jwt->sub],
            ['email' => $jwt->email ?? null, 'name' => $jwt->name ?? null],
        );

        Auth::login($user);
    }
}

Register it (Laravel 11+ auto‑discovers listeners; otherwise add it to your EventServiceProvider).

Alternative — read the token from the session:

// Via the facade (recommended — auto-refreshes when the access token is expiring):
use Esanj\AuthBridge\Facades\AuthBridge;
$header = AuthBridge::getAuthorizationHeader(); // "Bearer xxx" or null (refreshed silently)
$access = AuthBridge::getAccessToken();          // always a still-valid access token, or null

// Raw session access (does NOT auto-refresh — may be stale/expired):
$accessToken = session('auth_bridge.access_token');
$refreshToken = session('auth_bridge.refresh_token');
$expiresAt   = session('auth_bridge.expires_at');

Silent refresh (Authorization Code flow)

When the callback stores the token, both the access token and its refresh token are kept in the session together with the access token's absolute expires_at. When the access token is expired — or within refresh_buffer_seconds of expiring — the package silently exchanges the refresh token for a new access token (POST {refresh_token_path} with grant_type=refresh_token) and rewrites the session. The user never notices.

If the refresh token is itself invalid/expired, the stored token is cleared and the accessors return null, so you can send the user back through auth-bridge.redirect to log in again.

Refresh happens automatically whenever you read the token through the facade (getValidToken(), getAccessToken(), getAuthorizationHeader(), hasToken()). To refresh transparently on every request — even for code that reads the session directly — attach the middleware:

// routes
Route::middleware(['web', 'auth-bridge.refresh'])->group(function () {
    // ... routes that rely on a fresh accounting token
});

You can also refresh explicitly:

AuthBridge::refreshAccessToken($refreshToken);  // returns a fresh TokenData

ℹ️ Only the Authorization Code flow uses refresh tokens. The Client Credentials grant never issues one — that flow already fetches a new token from the cache when the old one expires.

Client Credentials flow (server‑to‑server)

use Esanj\AuthBridge\Contracts\ClientCredentialsServiceInterface;

public function __construct(private ClientCredentialsServiceInterface $cc) {}

$token = $this->cc->getAccessToken(
    clientId: config('esanj.auth_bridge.client_id'),
    clientSecret: config('esanj.auth_bridge.client_secret'),
    scope: '*' // optional
);

$response = Http::withHeaders([
    'Authorization' => $token->getAuthorizationHeader(),
])->get('https://api.example.com/data');

// Force a refresh on the next call:
$this->cc->invalidateToken(config('esanj.auth_bridge.client_id'), '*');

Tokens are cached until ~60 seconds before expiry; failures fire TokenExchangeFailed.

JWT extraction

use Esanj\AuthBridge\Contracts\ClientCredentialsServiceInterface;
use Esanj\AuthBridge\Exceptions\ExtractJWTException;

try {
    $jwt = app(ClientCredentialsServiceInterface::class)->extractJwt($accessToken);
    $userId = $jwt->sub;
} catch (ExtractJWTException $e) {
    report($e); // invalid token, or public key missing
}

Requires the RS256 public key at config('esanj.auth_bridge.public_key_path').

Events

Event Fired when Payload
TokenReceived A token is obtained (either flow). TokenData $tokenData, string $grantType
TokenExchangeFailed A token request/exchange fails. AuthBridgeException $exception, string $grantType
AuthorizationRedirecting Just before redirecting to the OAuth server. AuthorizationRequest $request, string $authorizationUrl

Facade

use Esanj\AuthBridge\Facades\AuthBridge;

AuthBridge::buildAuthorizationUrl();                         // build the authorize URL
AuthBridge::exchangeAuthorizationCodeForAccessToken($code);  // exchange a code → TokenData
AuthBridge::refreshAccessToken($refreshToken);               // exchange a refresh token → TokenData
AuthBridge::getClientId();
AuthBridge::getBaseUrl();

// Session token helpers (all auto-refresh a stale access token):
AuthBridge::getToken();                 // array|null
AuthBridge::getAccessToken();           // string|null
AuthBridge::hasToken();                 // bool
AuthBridge::getAuthorizationHeader();   // "Bearer xxx"|null
AuthBridge::clearToken();               // forget the session token

Error handling

Exception When
InvalidStateException The OAuth state is missing or doesn't match (production).
TokenExchangeException The authorization‑code exchange fails.
TokenRequestException The client‑credentials token request fails.
ExtractJWTException JWT is invalid/expired, or the public key is missing.
AuthBridgeException Base class for all of the above (carries getContext()).
use Esanj\AuthBridge\Exceptions\TokenExchangeException;

try {
    $token = AuthBridge::exchangeAuthorizationCodeForAccessToken($code);
} catch (TokenExchangeException $e) {
    logger()->error('OAuth exchange failed', ['error' => $e->getMessage(), 'context' => $e->getContext()]);
    return redirect('/login')->with('error', 'Authentication failed');
}

Notes & limitations

  • State (CSRF) validation runs only in production (app()->isProduction()). In local/testing environments the callback skips the state check for convenience.
  • No runtime query‑parameter overrides. success_redirect and the callback URL come from config/env only; passing ?success_redirect= or ?callback_url= to the redirect route has no effect in the current version.

Documentation

For a complete, beginner‑friendly, step‑by‑step walkthrough — wiring up login, handling the token, the client‑credentials flow, JWT verification, and troubleshooting — see docs/GUIDE.md.

Credits

Developed and maintained by the Esanj Tech Team.