Third-Party Integration Guide (Auth Server)¶
Overview¶
This guide is for developers of an external application ("the client") that needs to authenticate users against, and read authorization data from, an authorization server built on Pramnos Framework. It covers the full lifecycle: discovery → registration → the OAuth2/OIDC login flow → reading permissions → declaring your capabilities → cache invalidation.
The design follows an Atlassian-style model:
- Runtime is standard OAuth2 / OIDC. You get lightweight, audience-scoped tokens carrying identity only — never permissions (so tokens stay small and change instantly).
- Authorization is fetched, then cached. Your app reads a user's effective permissions from a server-to-server endpoint and caches them, invalidating on a webhook.
Endpoints below use the paths the server advertises by default. Always read the discovery document first rather than hard-coding paths.
1. Discovery¶
Fetch the server's metadata:
GET /.well-known/openid-configuration
GET /.well-known/oauth-authorization-server
GET /.well-known/oauth-protected-resource
The two halves of discovery¶
The first two say where the authorization server is. The third — RFC 9728, Protected Resource Metadata — says where the resource is and which authorization servers it trusts:
{
"resource": "https://example.com",
"authorization_servers": ["https://example.com"],
"scopes_supported": ["profile", "email", "phone", "address", "user", "openid", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://example.com/docs"
}
Both halves exist so a client can start from either end and find the other. With only the first, a client has to be told the rest out of band — configuration somebody types into a file, gets wrong, and has no way to verify.
It is also where an MCP client starts. The Model Context Protocol's authorization flow is: call
a protected endpoint, be refused with 401 and a WWW-Authenticate header naming this document,
read it, find the authorization server, then run the ordinary authorization-code-with-PKCE exchange
in §3. See the MCP guide.
Three things about that document are deliberate. scopes_supported is read from the server's own
scope table rather than written out, so it cannot drift from what the server will actually grant.
bearer_methods_supported is ["header"] and only that — RFC 6750 also permits a token in a form
body and a query string, and the query form puts a credential in every access log and Referer
between you and here. And the identifiers carry no trailing slash, because resource is
compared as a string when a token's audience is checked, and https://example.com and
https://example.com/ are the same address and different strings.
The document lists the real endpoints, e.g.:
| Key | Default |
|---|---|
authorization_endpoint |
/oauth/authorize |
token_endpoint |
/oauth/token |
userinfo_endpoint |
/oauth/userinfo |
device_authorization_endpoint |
/oauth/deviceauthorization |
jwks_uri |
/.well-known/jwks.json |
Validate ID tokens against the keys in jwks_uri.
If discovery answers 404¶
These paths are fixed by specification, so they cannot be reached through the
framework's controller/action URL shape — the web server has to be told about
them. init writes the rules when the authserver feature is enabled:
RewriteRule ^\.well-known/openid-configuration$ index.php?r=Discovery/configuration [L]
RewriteRule ^\.well-known/openid_configuration$ index.php?r=Discovery/configuration [L]
RewriteRule ^\.well-known/jwks\.json$ index.php?r=Discovery/jwks [L]
RewriteRule ^\.well-known/oauth-authorization-server$ index.php?r=Discovery/oauth2Metadata [L]
RewriteRule ^\.well-known/health$ index.php?r=Discovery/health [L]
Two things about that block are worth knowing before you edit it.
Order matters. The catch-all rule below them matches every path, and
mod_rewrite runs rules in order — a discovery rule moved beneath the catch-all
never fires. On a SPA project the failure is worse than a 404: the shell
fallback answers with the application's HTML and a 200, so a client sees
malformed JSON rather than a missing endpoint.
The underscore spelling is deliberate. openid_configuration appears in no
specification and in a good number of clients. Answering it costs one line.
A project scaffolded before these rules existed keeps its own .htaccess —
version control does not update it for you. Add the block by hand, above the
catch-all.
If a discovery response will not parse¶
Before 2026-08-26 it would not. Every endpoint here answered with valid JSON and then a complete HTML page appended to it:
The actions echoed their body and returned. An echo writes to the output stream
and leaves the framework to render the page it was going to render anyway, so the
response was the document and the site's home page. JSON.parse fails on it;
curl | head does not, which is why it survived.
Fixed by answering with the framework's raw document instead of echoing, so the
body is the JSON and nothing follows it. All six were affected —
openid-configuration, jwks.json, oauth-authorization-server,
.well-known/health, /Discovery/serverConfig and the project-level /config.
Nothing to change in a client. If you built a workaround — reading up to the first
} at column 0, or a regex — you can drop it, and you should: the shape it relies
on is no longer there.
A summary built for a person¶
The two documents above are built for a client library. When you want the one a developer reads while integrating — the URLs, the grants that work here, the scopes that exist, whether the device flow is on — ask for:
It is not a standards document and a client should not depend on it; read
/.well-known/openid-configuration for that. This is the page to paste into a
ticket. Every list in it is read from whatever actually decides it, so it cannot
drift out of agreement with the server the way a hand-written integration note
does.
Client credentials, and the account behind the token¶
A client_credentials token has no end user — it represents your application. The
server still needs an account to hang it on, because usertokens.userid is a
foreign key, so each application gets one system account, created on first use
and reused afterwards.
You never see it directly, but it explains two things you will see:
sub is that account, not a person, and username is a generated sys_* name.
It is usertype 1 — below every administrative threshold — so a token issued to an
application can never be mistaken for one issued to an operator.
And the account is never userid 0 or 1. Those are the framework's guest and system
rows, so an application whose systemuser column holds either has a gap rather than
an account — and a token stored under one of them would sit beneath an identity shared
with every other application in the same state, which makes "what has this account been
doing" unanswerable. The check is > 1, in both places that could produce such an id:
the column when it is read, and the row creation when it answers.
Every refusal along the way returns no account rather than raising, and the token insert
then fails on its foreign key. That is deliberate: a token for a client that cannot be
resolved is not stored under a user invented for it. If a client_credentials grant
answers with a server error rather than a token, this is one of the things to check —
the log carries Could not create a system user for application <id> or Could not
resolve a system user for client <id>.
If you need a token that acts as a particular person without that person signing in, that is the JWT bearer grant (RFC 7523 §2.1) rather than this one — it must be enabled per client, because its holder can obtain a token for any user.
Signing out¶
Two endpoints, because there are two situations.
/oauth/logout is for your backend. It revokes the token family: the
access token you present and the refresh token issued with it, linked through
usertokens.parentToken. A token issued to another device belongs to another
family and is untouched — that is what separates this from "sign out of
everything".
POST /oauth/logout
Authorization: Bearer <access_token>
logoutwebsession=1 # optional — end the browser session as well
{ "success": true, "user_id": 42, "tokens_revoked": 2 }
Without logoutwebsession=1 the browser session is left alone. That is usually
what a backend wants and rarely what a "sign out everywhere" button wants.
An unknown token still answers {"success": true}, in the spirit of RFC 7009: an
endpoint that distinguished a real token from an invented one would tell an
attacker which of their guesses exist.
/login/logout is for a browser. It reads the session cookie, needs no
header, and redirects afterwards. ?local=1 clears the session and leaves the
tokens valid — for "sign out of this browser" without breaking a running mobile
app.
Is the server up?¶
{
"status": "healthy",
"timestamp": "2026-08-25T14:46:08+00:00",
"components": { "database": "ok", "signing_keys": "ok", "session": "ok" }
}
503 when anything is wrong. The components map lists every check the server
has registered — including signing_keys, which is the one that catches a server
answering pages normally and refusing every token. See
Health checks for what each check means and how to add
one.
If a bearer token reads as no token¶
Apache does not hand the Authorization header to PHP-FPM or CGI unless it is
copied into the environment first:
init writes this for every project, not only authorization servers — any
REST API authenticated with Authorization: Bearer … needs it. Without it the
request arrives anonymous, which reads as a rejected credential; the time then
goes into the token, and the token was never the problem.
Which scopes you may ask for¶
The list in scopes_supported is the list. Ask for one that is not in it and the
token endpoint answers invalid_scope:
{
"error": "invalid_scope",
"error_description": "The requested scope is invalid, unknown, or malformed",
"hint": "Check the `profile` scope"
}
Before 2026-08-26 that happened for scopes that were in it. The token endpoint validated against four identifiers of its own —
read,write,admin,user— while discovery published the framework's scope registry. Of twelve advertised scopes, eleven were refused,openidamong them: OpenID Connect could not be used at all against a server whose own discovery document said it could. Both sides read from the registry now, and the four older identifiers are still accepted.
2. Registering your application¶
An administrator registers your application on the server and gives you a client_id and client_secret, plus your registered redirect URI(s).
Applications marked trusted (internal/first-party) skip the user consent screen; untrusted (third-party) applications always show consent and receive only the scopes the user approves.
Deleting a client revokes its tokens¶
applications.appid cascades to usertokens.applicationid. Delete an OAuth client and every token
it issued goes with it.
It used to be ON DELETE SET NULL, which left the tokens in place with a null applicationid —
indistinguishable from a token that was never issued through OAuth at all. Each one silently changed
category and carried on authenticating. On one installation, 507 of 522 tokens had a null
applicationid and thirteen of those were still active and unexpired: thirteen live credentials
belonging to clients that had been deleted.
Deleting a client is the one action an operator takes precisely to stop it having access, so that is what it does now.
Tokens already detached are left alone. The old rule destroyed the reference rather than recording it anywhere, so nothing separates "issued by a client that is gone" from "never an OAuth token" — and a sweep would have to guess, which here means revoking working credentials.
3. Logging a user in — Authorization Code + PKCE¶
Use the Authorization Code flow with PKCE (recommended for all clients).
Step 1 — redirect the user to the authorization endpoint:
GET /oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example.com/callback
&scope=openid profile email
&state=RANDOM_STATE
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256
The user authenticates (password + optional 2FA/passkey) and, for untrusted
clients, approves the requested scopes. The server redirects back to your
redirect_uri with ?code=…&state=….
Step 2 — exchange the code for tokens:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=THE_CODE
&redirect_uri=https://yourapp.example.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=THE_ORIGINAL_VERIFIER
You receive an access_token (and an id_token when openid was requested).
Fetch profile claims from GET /oauth/userinfo with the access token.
Your client secret is required, on every grant¶
If a secret is registered for your client, the token endpoint will not
authenticate you without it. This holds for every grant — authorization_code,
refresh_token, password and client_credentials — and for both ways of
presenting it, HTTP Basic or form field. An empty client_secret= counts as
absent.
The client_id alone is never enough. It is a public identifier: it travels in
redirect URLs and ships inside every SPA bundle and mobile binary, so anything
it could unlock on its own would be unlocked for everybody who has ever seen a
login link.
Copy it when you are given it¶
The secret is shown once — on the screen that creates the registration, and again if you rotate it. It is stored hashed, the same way a password is, so nobody can read it back to you afterwards: not the operator, not a support request, not a database query. Lose it and the only route forward is rotating to a new one.
Public clients¶
A public client is one that cannot keep a secret: a single-page app or a mobile binary, where whatever you ship inside it every user of it has. Register it with Client Type unticked and it is issued no secret and asked for none.
A public client authenticates its authorization code with PKCE instead, which is
why the code_challenge above is not optional advice. It cannot use
client_credentials, which authenticates the application itself and has nothing
but a secret to do it with — the token endpoint refuses that combination.
If your client runs on a server you control, leave it confidential. The secret is worth having.
Lightweight tokens¶
Access/ID tokens carry identity claims only — user id, basic attributes, audience, optionally roles. They do not carry permissions. Do not try to derive what a user may do from the token; fetch it (next section) and cache it.
4. Reading a user's permissions¶
Your application server (never the browser) calls the internal permissions endpoint using client-credentials (RFC 7523 JWT assertion):
GET /api/internal/permissions?user_id={id}&client_id={your_client_id}
Authorization: Bearer {client_credentials_access_token}
Response — the effective permission tree scoped to your application
(app_id = your app OR global):
{
"resources": {
"invoices": {
"read": { "grant": "allow", "conditions": null },
"write": { "grant": "allow", "conditions": { "location_id": [1, 2] } }
}
}
}
grantis the resolved allow/deny after the server applies deny-over-allow.conditionsis ABAC context your app evaluates against the current request (e.g. only allowwritewhen the request'slocation_id∈ [1,2]). Anullmeans unconditional.
Cache this response per user. Do not call the endpoint on every request.
5. Declaring your capabilities (manifest)¶
So the server knows which resources/scopes and ABAC condition keys your app understands, push a capabilities manifest (typically from CI/CD):
PUT /api/internal/clients/{client_id}/capabilities
Authorization: Bearer {client_credentials_access_token}
Content-Type: application/json
{
"resources": {
"invoices": {
"description": "Customer invoices",
"scopes": { "read": "View invoices", "write": "Edit invoices" }
}
},
"conditions": {
"location_id": { "value_type": "int[]", "description": "Restrict to locations" }
}
}
The server computes an MD5 of the manifest and short-circuits if it is
unchanged. On change it upserts resources/scopes/conditions; anything absent
from the new manifest is soft-deleted (is_active = false), never hard
deleted. Send the stored manifest_hash to get a no-op 304 when nothing
changed.
Authenticate with HTTP Basic (client_id:client_secret) or with client_id and
client_secret as form fields. Either is fine — RFC 6749 §2.3.1 allows both.
POST is accepted as well as PUT, for a CI runner that has no PUT. The
operation is idempotent either way.
The response reports what it did, and the counts are worth checking in CI:
A manifest that synced zero was possible before 2026-08-26, and reported success. The normaliser dropped the map keys, so every entry arrived unnamed and every loop skipped it —
200 {"status":"synced","resources":0,…}. Scopes were worse:{"read": "View invoices"}was read as a scope named "View invoices", so the server stored a permission keyed on prose and a client asking forreadmatched nothing. Both shapes are accepted now — the keyed map above, and a list whose entries carry their ownname/key.And Basic auth was refused where Apache runs as a module. It decodes the header into
PHP_AUTH_USERand does not pass the raw one on, so the extractor found nothing and answeredinvalid_client— which reads as a wrong secret. If you worked around it by moving to form fields, Basic works now.If your pipeline has been reporting success, check the counts: a manifest may have been accepted and stored as nothing.
Seeing what a client declared¶
An administrator opens the client's own page — /admin/Applications/view/{appid} —
and reads its declared resources, the scopes on each, and the condition keys, with
the manifest's hash and when it last arrived.
That page is the answer to the question a grant raises: a permission names a resource, so "which names does this client actually publish" has to be visible before anybody can write one. It was not, until 2026-08-26 — the write side existed alone, so a server accepted manifests and could show nobody what was in them.
Anything the client has stopped declaring is listed struck through rather than removed. A grant may still refer to it, and that is exactly what somebody is looking for when a permission has quietly stopped working.
A project that published the applications views before that date needs to
republish applications/view to get the section:
6. Instant invalidation — webhooks¶
When an administrator changes a user's permissions, the server queues a
permissions_changed webhook to your registered webhook URL:
On receipt, drop that user's cached permissions so the next request re-fetches
from /api/internal/permissions. Webhook deliveries are HMAC-SHA256 signed and
retried — verify the signature before acting.
This is what makes lightweight tokens safe: permissions change instantly without re-issuing or bloating tokens.
Registering an endpoint¶
Authenticate with your client credentials — the same pair you use at the token endpoint, as a Basic header or in the body:
POST /Webhook/register
Authorization: Basic base64(client_id:client_secret)
endpoint_url=https://your-app.example.com/hooks/auth
&webhook_type=token_revoked
{
"webhook_type": "token_revoked",
"endpoint_url": "https://your-app.example.com/hooks/auth",
"secret": "…64 hex characters…",
"signature": "X-Webhook-Signature: sha256=HMAC-SHA256(secret, body)"
}
Store the secret. It is returned once, by this call, and never shown again — an endpoint that hands out its own signing secret to anyone who can reach it is not signing anything. Lost it? Register the same type again; that replaces the URL and issues a new secret.
appid comes from your credentials and is never read from the request, so an
application can only ever see and change its own endpoints.
| Route | Does |
|---|---|
POST /Webhook/register |
Register or replace an endpoint for one event type |
GET /Webhook/list |
Your endpoints — without the secrets |
GET /Webhook/stats |
Delivery counts by status |
POST /Webhook/test |
Queue a test event through the real pipeline |
POST /Webhook/delete |
Remove one endpoint |
The endpoint URL must be https://. The event describes a person and is signed
with a shared secret; over plaintext both are readable by anything on the path,
which makes the signature decorative.
Event types: user_deauthorized, token_revoked, gdpr_request,
user_profile_changed, device_deauthorized, account_deleted, scope_changed,
permissions_changed. One endpoint per type per application.
POST /Webhook/test queues an event for the endpoint you named and no other.
That matters because a real event does the opposite: token_revoked concerns
every application holding a token for that user, so the queue fans it out to
every endpoint subscribed to the type. A test ping is not a real event — it is
traffic you asked to have sent — so it stops at your own URL. If you need the
fan-out behaviour from your own code, call queueEvent() without the last
argument:
$service = new \Pramnos\Auth\WebhookService($db);
$service->queueEvent('token_revoked', $userid, ['token_id' => 42]); // every subscriber
$service->queueEvent('token_revoked', $userid, [...], null, null, $id); // one endpoint
The endpoint id is still matched against the event type and is_active, so
naming one that does not subscribe queues nothing rather than queueing the wrong
event.
Verifying a delivery¶
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $yourSecret);
// hash_equals, not ===: this compares against attacker-supplied input.
if (!hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
Pramnos\Auth\WebhookService::verifySignature() does the same thing if you are
receiving on this framework.
If nothing arrives¶
Deliveries are made by the auth:webhook-deliver schedule, every five
minutes — not at the moment the event happens. Check that the scheduler is
running on the server:
GET /Webhook/stats is the other half of that answer: a pending count that only
grows means events are being queued and nothing is sending them.
What a 401 from the webhook endpoints tells you¶
Both failures are invalid_client with status 401, and the difference between them is the whole
diagnostic:
You sent nothing the endpoint could read — no Authorization: Basic, no client_id/client_secret
in the body. Check how your client is attaching them.
You sent credentials and they did not authenticate. There is no description on purpose: saying whether the id or the secret was wrong would let a client id be confirmed by trying it, the same reason a sign-in form does not say which half failed. Check the id and the secret together, and that the application is still registered and enabled.
So: a description means "nothing arrived", and no description means "what arrived was wrong".
7. Putting it together¶
Discovery → Register → Authorize (PKCE) → Token (identity only)
→ Fetch /api/internal/permissions → cache
→ on permissions_changed webhook → invalidate cache
The effective access your app enforces is:
The server returns the RBAC∩ABAC set; any licensing/entitlement gate is applied by your application on top.
What the site tells a machine that arrives uninvited¶
Two generated files, alongside the .well-known documents above.
/robots.txt¶
Generated rather than shipped as a file, because the line that matters is derived from the installation's own URL — a static file in a scaffold is a static file with somebody else's domain in it.
It names twelve AI crawlers one by one: GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot,
Claude-User, Claude-SearchBot, PerplexityBot, Perplexity-User, Google-Extended,
Applebot-Extended, CCBot, meta-externalagent. Every one reads robots.txt and honours it.
Absence is not neutrality. With nothing said each crawler decides for itself, and they decide
differently — Google-Extended opts a site out of model training while leaving Search untouched,
which is a distinction a site cannot express by staying silent.
// app/settings.php — one setting flips all twelve
'ai_crawler_policy' => 'disallow', // default: allow
The default is allow, because a framework must not choose a site's licensing posture. What it must
do is make the choice visible and settable.
The side-effect paths — /admin/, /oauth/, /account/, /devpanel/, /adminer — are
Disallowed rather than left to noindex. noindex keeps a page out of an index after it has
been fetched; on an authentication server every one of those either costs a session, sends mail, or
answers differently per visitor.
/llms.txt¶
The GEO counterpart of a sitemap: a short markdown document saying what this site is and where the things worth reading are. A crawler follows links; a model arriving cold guesses, and guessing is how a site gets described wrongly and confidently.
It is deliberately short — the format's premise is that it fits in a context window beside the question somebody actually asked.
And it is where the MCP endpoint is announced. A model reading it learns the site has tools it
can call and where to authenticate for them. Without it the endpoint is a service nobody discovers.
It appears only once something has actually been offered through PublicRegistry, because an
endpoint serving an empty list is not worth pointing anybody at.
Both paths are scaffolded into a generated project's rewrite rules alongside the .well-known ones.