amtgard / idp-php-client
Opinionated PHP client for the Amtgard Identity Provider OAuth 2.0 and resource API
Requires
- php: ^8.2
- league/oauth2-client: ^2.7
- nyholm/psr7: ^1.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.9
- nyholm/psr7-server: ^1.1
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- slim/slim: ^4.14
- squizlabs/php_codesniffer: ^3.10
Suggests
- guzzlehttp/guzzle: PSR-18 HTTP client (recommended for most apps)
- slim/slim: Use Amtgard\IdpClient\Slim\IdpAuthController and SessionMiddleware
This package is auto-updated.
Last update: 2026-06-08 21:39:58 UTC
README
Opinionated PHP client for the Amtgard Identity Provider OAuth 2.0 authorization code + PKCE flow and resource API.
This library encodes one integration path so third-party apps stop re-implementing OAuth details incorrectly:
- Authorization code grant with PKCE (S256) — always, including confidential clients
- Scopes:
profile email(space-separated) - Resource calls:
GET /resources/userinfo,GET /resources/validate,GET /resources/jwt - Policy evaluation:
POST /api/is_authorized(backend services) - Typed results:
TokenSet,UserProfile,OrkProfile,ValidatedSession,AuthorizationCheck
Apps can wire config manually (IdpClientEnvironment) or use the on-rails factories that read standard IDP_* environment variables.
Installation
composer require amtgard/idp-php-client guzzlehttp/guzzle
Slim apps should also install Slim to use the bundled auth controller:
composer require slim/slim
Configuration (.env)
Load .env before your DI container boots (e.g. vlucas/phpdotenv in public/index.php). The on-rails factories expect these variables:
IDP_BASE_URL=https://idp.amtgard.com IDP_CLIENT_ID=my-app IDP_CLIENT_SECRET=your-confidential-client-secret IDP_REDIRECT_URI=https://my.app/oauth/callback # IDP_HTTP_USER_AGENT is optional — defaults to AmtgardIDP/1.0 # IDP_HTTP_USER_AGENT=MyApp/1.0
| Variable | Required | Example | Notes |
|---|---|---|---|
IDP_BASE_URL |
Yes | https://idp.amtgard.com |
No trailing slash |
IDP_CLIENT_ID |
Yes | my-app |
Registered with IDP maintainers |
IDP_REDIRECT_URI |
Yes | https://my.app/oauth/callback |
Must match registration exactly |
IDP_CLIENT_SECRET |
No | (secret) |
Omit for public clients (PKCE only) |
IDP_HTTP_USER_AGENT |
No | AmtgardIDP/1.0 |
Sent on every server-side IDP request (/oauth/token, /resources/*, /api/*). Override only when IDP ops instruct you to. |
Quick start (on-rails factories)
With .env populated, one factory call wires environment, OAuth flow state, and HTTP client:
use Amtgard\IdpClient\IdpClientFactory; session_start(); $idp = IdpClientFactory::fromEnvVars(); // GET /login return $idp->beginAuthorization(returnTo: '/dashboard'); // GET /oauth/callback $session = $idp->completeLogin($request); // $session->tokens, $session->profile, $session->returnTo
Factory chain:
| Factory | Builds |
|---|---|
IdpClientEnvironmentFactory::fromEnvVars() |
EnvIdpClientEnvironment from IDP_* vars (throws IdpConfigurationException if required vars missing) |
IdpClientFactory::fromEnvVars() |
Full IdpClient with SessionOAuthFlowStateStore + Guzzle |
IdpClient::completeLogin() |
Token exchange + /resources/userinfo in one call |
SessionAuthStore |
Persist AuthenticatedSession in $_SESSION (framework-agnostic) |
Session persistence (any PHP app)
use Amtgard\IdpClient\SessionAuthStore; $authStore = new SessionAuthStore(); // After callback: $authStore->store($idp->completeLogin($request)); // Later requests: if ($authStore->isAuthenticated()) { $session = $authStore->get(); $email = $session->profile->email; } // Logout: $authStore->clear();
Manual configuration (custom environments)
When you cannot use IDP_* env vars (multi-tenant config, tests, non-.env apps), implement IdpClientEnvironment yourself or use ArrayEnvironment:
use Amtgard\IdpClient\ArrayEnvironment; use Amtgard\IdpClient\IdpClientFactory; use Amtgard\IdpClient\SessionOAuthFlowStateStore; $env = new ArrayEnvironment( idpBaseUrl: 'https://idp.amtgard.com', clientId: 'my-app', clientSecret: 'your-secret', redirectUri: 'https://my.app/oauth/callback', ); $idp = IdpClientFactory::fromEnvironment($env, new SessionOAuthFlowStateStore());
Equivalent to the on-rails env factory, but explicit:
use Amtgard\IdpClient\IdpClientEnvironmentFactory; $env = IdpClientEnvironmentFactory::fromEnvVars([ 'IDP_BASE_URL' => 'https://idp.amtgard.com', 'IDP_CLIENT_ID' => 'my-app', 'IDP_REDIRECT_URI' => 'https://my.app/oauth/callback', 'IDP_CLIENT_SECRET' => 'secret', ]);
For app-specific env layout, wrap or replace EnvIdpClientEnvironment with your own class implementing IdpClientEnvironment and pass it to IdpClientFactory::fromEnvironment().
Resource API
After login, use the access token from TokenSet or AuthenticatedSession:
$token = $session->tokens->accessToken(); // Full profile (includes optional ORK link data) $profile = $idp->fetchUserProfile($token); // Session heartbeat — lighter than userinfo; returns id, email, jwt $validated = $idp->validate($token); // Fresh authorization JWT (cached server-side for validate/pubsub) $jwt = $idp->fetchJwt($token);
Backend services can evaluate IAM policies without a user bearer token:
$check = $idp->checkAuthorization( policy: $userPolicyOrnArray, requirement: 'Idp:0:0:0:0:IDP/EditClient', ); if ($check->isAuthorized) { // allow action }
checkAuthorization() posts to the public /api/is_authorized endpoint. Most OAuth client apps only need fetchUserProfile() and validate(); use policy evaluation when your service already holds a user's IAM policy JSON.
Slim 4 integration
Slim 4 apps can use the bundled Slim helpers in Amtgard\IdpClient\Slim\ — a drop-in auth controller and session middleware. Layout matches other Amtgard PHP projects: PHP-DI container.php + routes.php.
Assumptions: Slim 4, PHP-DI, vlucas/phpdotenv, guzzlehttp/guzzle, .env configured as above.
On-rails Slim setup (recommended)
config/container.php — minimal wiring with env factories:
<?php declare(strict_types=1); use Amtgard\IdpClient\IdpClient; use Amtgard\IdpClient\IdpClientFactory; use Amtgard\IdpClient\SessionAuthStore; use Amtgard\IdpClient\Slim\IdpAuthController; return [ // ... existing definitions IdpClient::class => fn () => IdpClientFactory::fromEnvVars(), SessionAuthStore::class => fn () => new SessionAuthStore(), IdpAuthController::class => function (Psr\Container\ContainerInterface $container) { $app = $container->get(Slim\App::class); return new IdpAuthController( $container->get(IdpClient::class), $container->get(SessionAuthStore::class), postLoginRoute: 'home', postLogoutRoute: 'home', routeParser: $app->getRouteCollector()->getRouteParser(), ); }, ];
config/routes.php — three routes, library session middleware:
<?php declare(strict_types=1); use Amtgard\IdpClient\Slim\IdpAuthController; use Amtgard\IdpClient\Slim\SessionMiddleware; use Slim\App; return function (App $app) { $app->get('/', fn ($req, $res) => $res)->setName('home'); $app->group('', function (Slim\Routing\RouteCollectorProxy $group) { $group->get('/login', [IdpAuthController::class, 'login'])->setName('auth.login'); $group->get('/oauth/callback', [IdpAuthController::class, 'callback'])->setName('auth.callback'); $group->get('/logout', [IdpAuthController::class, 'logout'])->setName('auth.logout'); })->add(SessionMiddleware::class); };
The library controller handles:
login—beginAuthorization()(optional?return_to=/pathstored for post-login redirect)callback—completeLogin()+SessionAuthStore::store()+ redirect toreturn_toorhomeroutelogout—SessionAuthStore::clear()+ redirect tohomeroute
Gate protected routes with SessionAuthStore::isAuthenticated() in your own middleware.
If you run multiple app instances behind a load balancer, use shared session storage (Redis, etc.) so /login and /oauth/callback share OAuth flow state — otherwise see IDP_CLIENT_FLOW_STATE_MISSING.
Manual Slim controller (reference)
If you need custom error pages, logging, or redirect logic, use the framework-agnostic helpers directly:
<?php declare(strict_types=1); namespace App\Controllers; use Amtgard\IdpClient\Exception\IdpClientException; use Amtgard\IdpClient\IdpClient; use Amtgard\IdpClient\SessionAuthStore; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\Routing\RouteContext; final class IdpAuthController { public function __construct( private readonly IdpClient $idpClient, private readonly SessionAuthStore $authStore, ) {} public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $returnTo = $request->getQueryParams()['return_to'] ?? null; return $this->idpClient->beginAuthorization( is_string($returnTo) ? $returnTo : null, ); } public function callback(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { try { $session = $this->idpClient->completeLogin($request); } catch (IdpClientException $exception) { // custom error handling / logging return $response->withStatus(400); } $this->authStore->store($session); $redirect = $session->returnTo ?? RouteContext::fromRequest($request)->getRouteParser()->urlFor('home'); return $response->withHeader('Location', $redirect)->withStatus(302); } public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $this->authStore->clear(); return $response ->withHeader('Location', RouteContext::fromRequest($request)->getRouteParser()->urlFor('home')) ->withStatus(302); } }
Register this controller in container.php instead of Amtgard\IdpClient\Slim\IdpAuthController, and add your own SessionMiddleware or use the library's Amtgard\IdpClient\Slim\SessionMiddleware.
Slim checklist
| Step | Route / class | Purpose |
|---|---|---|
.env |
IDP_* vars |
On-rails IdpClientFactory::fromEnvVars() |
| Session middleware | Amtgard\IdpClient\Slim\SessionMiddleware |
PHP session for OAuth flow + auth store |
GET /login |
IdpAuthController::login |
Redirect to IDP authorize |
GET /oauth/callback |
IdpAuthController::callback |
completeLogin() + session store |
GET /logout |
IdpAuthController::logout |
Clear SessionAuthStore |
| Protected routes | your middleware | SessionAuthStore::isAuthenticated() |
Endpoints (derived from base URL)
| Purpose | URL |
|---|---|
| Authorize | {idpBaseUrl}/oauth/authorize |
| Token | {idpBaseUrl}/oauth/token |
| Userinfo | {idpBaseUrl}/resources/userinfo |
| Validate | {idpBaseUrl}/resources/validate |
| JWT | {idpBaseUrl}/resources/jwt |
| Policy check | {idpBaseUrl}/api/is_authorized |
Error handling
All library exceptions extend IdpClientException and expose:
errorCode()— stable string for logging and docs lookupidpError()/idpErrorDescription()— raw IDP OAuth error when presentdeveloperHint()— points to the matching section below
Catch specific types where useful:
| Exception | When |
|---|---|
InvalidOAuthStateException |
Callback/state/CSRF problems before token exchange |
TokenExchangeException |
/oauth/token rejected the request |
ResourceException |
Resource/API HTTP or JSON problems (/resources/*, /api/is_authorized) |
Error code reference
Each code maps to a common client implementation mistake. Fix the root cause, then retry the flow from beginAuthorization().
IDP_CLIENT_FLOW_STATE_MISSING {#error-idp_client_flow_state_missing}
When: completeAuthorization() ran but no flow state was found in your OAuthFlowStateStore.
Common causes:
- User took too long and the session expired
beginAuthorization()was never called on this browser session- Session cookie not sent on callback (SameSite, wrong domain, HTTP vs HTTPS mismatch)
- Multiple app instances without shared session storage
Fix:
- Ensure
session_start()(or your framework session) runs before both/loginand/oauth/callback - Use the same session store for both routes
- Redirect users to
/loginagain — do not retrycompleteAuthorization()without a freshbeginAuthorization()
IDP_CLIENT_STATE_PARAM_MISSING {#error-idp_client_state_param_missing}
When: The callback request has no state query parameter.
Fix: Verify your redirect URI handler reads query parameters. Do not strip query strings at your reverse proxy.
IDP_CLIENT_STATE_MISMATCH {#error-idp_client_state_mismatch}
When: Callback state does not match the value stored at beginAuthorization().
Common causes:
- Parallel login attempts in the same session
- State stored in a different session than the callback receives
- Custom state generation overriding the library flow
Fix: Let this library manage state via OAuthFlowStateStore. Do not generate your own state for the authorize redirect.
IDP_CLIENT_AUTH_CODE_MISSING {#error-idp_client_auth_code_missing}
When: Callback has state but no code.
Common causes:
- User denied consent (
error=access_deniedshould appear instead — handle that first) - Proxy stripped query parameters
Fix: Log full callback query params. Handle error / error_description before expecting code.
IDP_CLIENT_OAUTH_CALLBACK_ERROR {#error-idp_client_oauth_callback_error}
When: IDP redirected with error in the query string (e.g. access_denied).
Fix: Show a friendly message and link back to login. Do not attempt token exchange.
IDP_CLIENT_TOKEN_INVALID_GRANT {#error-idp_client_token_invalid_grant}
When: /oauth/token returned invalid_grant.
Common causes:
- Authorization code already used (codes are single-use)
- Code expired (user waited too long)
redirect_urion token request differs from authorize request
Fix:
- Use the exact same
redirect_uristring in config for both steps - Start a fresh login — never replay an old
code
IDP_CLIENT_TOKEN_INVALID_CLIENT {#error-idp_client_token_invalid_client}
When: /oauth/token returned invalid_client.
Common causes:
- Wrong
client_idorclient_secret - Confidential client secret sent incorrectly (must be in POST body fields
client_id+client_secret) - Client not registered on the IDP
Fix: Verify credentials with IDP administrators. Match clientId() and clientSecret() to the registered client.
IDP_CLIENT_TOKEN_REDIRECT_MISMATCH {#error-idp_client_token_redirect_mismatch}
When: Token exchange failed because redirect_uri does not match registration or the authorize step.
Fix:
- Register the exact callback URL with IDP maintainers (scheme, host, path, no trailing slash drift)
- Set
redirectUri()to that exact string - League/provider must send the same value on authorize and token requests — this library does that automatically when config is correct
IDP_CLIENT_TOKEN_PKCE_FAILED {#error-idp_client_token_pkce_failed}
When: PKCE verification failed at /oauth/token.
Common causes:
code_verifiernot sent on token request- Verifier regenerated between authorize and token steps
- Wrong challenge method (must be
S256) - Base64url encoding wrong (
+,/,=padding)
Fix: Use this library end-to-end. It stores the verifier in OAuthFlowStateStore and sends it automatically. Do not hand-roll PKCE alongside this client.
IDP_CLIENT_TOKEN_EXCHANGE_FAILED {#error-idp_client_token_exchange_failed}
When: Token exchange failed with an unrecognized OAuth error.
Fix: Inspect idpError() and idpErrorDescription() on the exception. Check IDP logs. Ensure POST /oauth/token uses Content-Type: application/x-www-form-urlencoded.
IDP_CLIENT_TOKEN_REFRESH_FAILED {#error-idp_client_token_refresh_failed}
When: Refresh token grant was rejected.
Fix: Re-authenticate the user via beginAuthorization(). Store and rotate refresh tokens when the IDP issues new ones.
IDP_CLIENT_RESOURCE_UNAUTHORIZED {#error-idp_client_resource_unauthorized}
When: GET /resources/userinfo or /resources/validate returned HTTP 401.
Common causes:
- Access token expired
- Wrong token sent (ID token vs access token — use access_token from
/oauth/token) - Missing
Authorization: Bearerheader - Token for a different environment (prod token against dev IDP)
Fix:
- Send
Authorization: Bearer {access_token}— this library does this automatically - Refresh or re-login if expired
IDP_CLIENT_RESOURCE_POLICY_ERROR {#error-idp_client_resource_policy_error}
When: Resource endpoint returned HTTP 422 (malformed IAM policy on the user account).
Fix: User must contact IDP administrators — this is an account configuration issue, not a client bug.
IDP_CLIENT_RESOURCE_UNEXPECTED_STATUS {#error-idp_client_resource_unexpected_status}
When: Resource endpoint returned an unexpected HTTP status (5xx, etc.).
Fix: Retry with backoff. If persistent, check IDP status with maintainers.
IDP_CLIENT_MALFORMED_JSON {#error-idp_client_malformed_json}
When: Response body was not valid JSON.
Fix: Often indicates a proxy error page. See IDP_CLIENT_WAF_OR_HTML_RESPONSE.
IDP_CLIENT_WAF_OR_HTML_RESPONSE {#error-idp_client_waf_or_html_response}
When: POST /oauth/token or a resource endpoint returned HTML instead of JSON (often Cloudflare WAF / bot protection). Surfaces as TokenExchangeException on callback/refresh and ResourceException on resource calls.
Common causes:
- Server-side token exchange blocked or challenged by Cloudflare
- Missing or generic
User-Agenton outbound requests - Request looks like automated traffic (wrong headers, HTTP/1.0, etc.)
Fix:
- Token exchange must happen server-side (never in the browser)
- Ensure outbound calls use the library default
AmtgardIDP/1.0(do not strip or replace unless IDP ops require a custom value). The factory applieshttpUserAgent()to all server-side IDP HTTP including/oauth/token. - Ensure your hosting egress IP is not blocked; contact IDP ops if Cloudflare rules block your server
- Do not call
/oauth/tokenfrom JavaScript — WAF rules often block that pattern - All server-side IDP calls send
Accept: application/json(token exchange and resources)
IDP_CLIENT_HTTP_TRANSPORT {#error-idp_client_http_transport}
When: Underlying HTTP client threw a transport error (DNS, TLS, timeout).
Fix: Check network connectivity to idpBaseUrl(), TLS certificates, and firewall egress.
Docker Slim example
A runnable reference app lives in examples/slim-docker/. It uses every Slim accelerator (IdpClientFactory::fromEnvVars(), SessionAuthStore, SessionMiddleware, IdpAuthController) behind Docker Compose on port 38080.
cp examples/slim-docker/.env.example examples/slim-docker/.env docker compose -f examples/slim-docker/docker-compose.yml up --build -d open http://localhost:38080/
Default IDP_BASE_URL is https://idp.amtgard.com. Register your OAuth client with IDP maintainers and set credentials in examples/slim-docker/.env. See examples/slim-docker/README.md for the full route map (every IdpClient method).
Testing
composer install composer test composer stan composer test:coverage # unit test coverage report
Integration tests (Slim Docker example)
Boots the example app and exercises the full Slim stack over HTTP:
composer integration:slim
Or manually:
composer integration:slim:up
SLIM_INTEGRATION=1 composer test -- --testsuite Integration --filter SlimDocker
composer integration:slim:down
| Variable | Default |
|---|---|
SLIM_EXAMPLE_URL |
http://localhost:38080 |
IDP_BASE_URL |
https://idp.amtgard.com (for callback token-exchange test) |
Integration tests (live IDP)
Hits the production Amtgard IDP at https://idp.amtgard.com by default:
IDP_INTEGRATION=1 composer test -- --testsuite Integration
Optional env vars:
| Variable | Default |
|---|---|
IDP_BASE_URL |
https://idp.amtgard.com |
IDP_CLIENT_ID |
test_phpleague_oauth_client |
IDP_CLIENT_SECRET |
secret |
IDP_REDIRECT_URI |
https://your-app.com/callback |
IDP_INTEGRATION_ACCESS_TOKEN |
(unset — skips happy-path /resources/* bearer tests) |
IDP_INTEGRATION_POLICY |
(unset — skips authorized checkAuthorization happy path) |
IDP_INTEGRATION_REQUIREMENT |
(unset — requirement string paired with IDP_INTEGRATION_POLICY) |
Relationship to IDP server
| Concern | IDP server | This library |
|---|---|---|
| Issues tokens | Yes | Consumes tokens |
User-Agent AmtgardIDP/1.0 on ORK API |
IDP server → ORK | IDP server only |
User-Agent AmtgardIDP/1.0 on IDP HTTP |
— | Default for all OAuth client server-side IDP calls |
| OAuth authorize/token | Yes | Wraps League GenericProvider |
/resources/userinfo |
Yes | Typed UserProfile |
/resources/validate |
Yes | Typed ValidatedSession |
/resources/jwt |
Yes | JWT string |
/api/is_authorized |
Yes | Typed AuthorizationCheck |
License
Proprietary — see LICENSE.