Implementation guide
Everything needed to connect an application to Mesogeos Digital Authentication Server.
This server: https://auth.mesogeos-digital.com
Endpoints
OAuth2 core
/oauth/authorize
|
Start a flow. The user sees the consent screen here. |
/oauth/token
|
Exchange a code, a refresh token or credentials for an access token. |
/oauth/revoke
|
Invalidate an access or refresh token immediately (RFC 7009). Authenticates the client. |
/oauth/introspect
|
Ask whether a token is still good, and what it carries (RFC 7662). |
/oauth/userinfo
|
Claims about the user the token belongs to. |
/oauth/deviceauthorization
|
Begin the device flow for an input-constrained device (RFC 8628). |
/oauth/logout
|
Revoke the tokens of one session. Requires a bearer token. |
Discovery
/.well-known/openid-configuration
|
OpenID Connect discovery document. |
/.well-known/oauth-authorization-server
|
Authorization server metadata (RFC 8414). |
/.well-known/jwks.json
|
Public keys for verifying an ID token. |
/.well-known/health
|
Whether the server considers itself well. |
Session
/session/check
|
Is this session still valid? |
/session/info
|
What the server knows about the current session. |
/session/heartbeat
|
Keep a session alive while the user is active. |
/login/logout
|
Browser logout, and the OpenID Connect end_session_endpoint. |
Operations
/health/check
|
Full health report as JSON. Public, for a monitor. |
/health/status
|
The same verdict flattened to three keys. |
/Status
|
A short alias for the flattened report. |
/Config
|
This server summarised for a person, not for a library. |
Grant types
Which one you want is decided by whether your application can keep a secret. If it runs in a browser or on a phone, it cannot — use PKCE.
| Grant | For | Client secret |
|---|---|---|
| Authorization code | Server-rendered web applications | Required |
| Authorization code + PKCE | SPAs, mobile applications | Not used |
| Client credentials | Service to service, no user | Required |
| Password | Trusted first-party applications only | Optional |
| Device code | TVs, IoT, anything without a keyboard | Not used |
| Refresh token | Renewing an access token | Depends on the client |
Authorization code
Two steps: send the user away, then swap what comes back.
# 1 — send the user here
GET /oauth/authorize
?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=openid%20profile%20email
&state=OPAQUE_RANDOM_VALUE
# 2 — they come back to REDIRECT_URI?code=…&state=…
# check the state matches, then exchange the code
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=CLIENT_ID
&client_secret=CLIENT_SECRET
&code=AUTHORIZATION_CODE
&redirect_uri=REDIRECT_URI state is not optional. It is what tells you the response
belongs to the request you started, and it is the whole of your CSRF protection on this
flow. Generate it randomly, store it in the session, compare it on return.
Authorization code with PKCE (RFC 7636)
The same flow, with a one-time secret generated per attempt instead of a permanent one. Use it for anything that cannot keep a client secret — which is any code that ships to a user's device.
# 1 — before redirecting, generate a verifier and its challenge
code_verifier = base64url(random_bytes(32)) # keep this
code_challenge = base64url(sha256(code_verifier)) # send this
# 2 — authorization request carries the challenge
GET /oauth/authorize
?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=openid%20profile
&state=OPAQUE_RANDOM_VALUE
&code_challenge=CHALLENGE
&code_challenge_method=S256
# 3 — token request carries the verifier. No client secret.
POST /oauth/token
grant_type=authorization_code
&client_id=CLIENT_ID
&code=AUTHORIZATION_CODE
&redirect_uri=REDIRECT_URI
&code_verifier=CODE_VERIFIER
S256 and plain are both accepted; use S256.
plain sends the verifier itself in the first request, which defeats the point.
Client credentials
No user involved. The token represents your application.
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=CLIENT_ID &client_secret=CLIENT_SECRET &scope=SCOPES
Password
Your application collects the user's password and posts it here. That means it sees the password, and it bypasses the consent screen, two-factor step-up and passkeys. Use it only in your own first-party applications, and prefer authorization code.
POST /oauth/token grant_type=password &client_id=CLIENT_ID &client_secret=CLIENT_SECRET &username=USERNAME &password=PASSWORD &scope=SCOPES
Device code (RFC 8628)
For a device that can show a short code but cannot host a browser. The device polls while the user authorizes it from a phone.
# 1 — the device asks for a code
POST /oauth/deviceauthorization
client_id=CLIENT_ID&scope=SCOPES
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.mesogeos-digital.comdevice",
"verification_uri_complete": "https://auth.mesogeos-digital.comdevice?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}
# 2 — show user_code and verification_uri on screen, then poll
# no faster than `interval` seconds
POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&client_id=CLIENT_ID
&device_code=DEVICE_CODE
# while the user has not finished: {"error":"authorization_pending"}
# if you poll too fast: {"error":"slow_down"} → add 5s and continue
# if they refuse: {"error":"access_denied"}
# if the code ages out: {"error":"expired_token"}
A device_code yields its tokens once. Polling again after that is refused.
Token exchange
Trades a short-lived access token for one that lasts 60 days, for an application that cannot come back for a refresh. The token you send must be an active access token issued to your own client; it is revoked by the exchange. No refresh token is issued.
POST /oauth/token grant_type=urn:ietf:params:oauth:grant-type:token-exchange # exchange_token also accepted &client_id=CLIENT_ID &client_secret=CLIENT_SECRET &subject_token=ACCESS_TOKEN
The grants a client may use are set per application. A client nobody has restricted can
use authorization code, client credentials, password, device code, refresh token and token
exchange; JWT bearer is enabled for one application at a time. A grant the client may not
use answers 400 unauthorized_client.
The grant types this server supports at all are in the discovery document's
grant_types_supported.
Refresh token
POST /oauth/token grant_type=refresh_token &client_id=CLIENT_ID &client_secret=CLIENT_SECRET # omit for a PKCE client &refresh_token=REFRESH_TOKEN
Refresh on a 401, not on a timer you keep yourself: a token can be revoked
before it expires, and a clock you trust can be wrong.
JWT client assertion (RFC 7523)
A way of authenticating your client without a shared secret. You sign a short-lived JWT with your private key; the server verifies it with your public key. Nothing secret ever travels, and nothing secret is stored on both sides.
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=CLIENT_ID &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer &client_assertion=JWT_SIGNED_WITH_YOUR_PRIVATE_KEY &scope=SCOPES
The assertion
{ "alg": "RS256", "typ": "JWT" }
{
"iss": "CLIENT_ID", // you
"sub": "CLIENT_ID", // also you — this authenticates the client
"aud": "TOKEN_ENDPOINT_URL", // exactly the URL you are posting to
"jti": "UNIQUE_PER_JWT", // required; replayed jti values are refused
"iat": 1640995140,
"exp": 1640995440 // at most 300s after iat
} | Algorithm | RS256 (RS384/512 and ES256/384/512 also accepted) |
| Key size | 2048 bits minimum |
| Lifetime | exp − iat ≤ 300 seconds |
jti | Unique per client until the assertion expires. Reusing one is treated as a replay and refused. |
aud | This server's token endpoint, or its issuer |
| Your key | The public key registered for your application, or your jwks_uri — then put the key's kid in the JWT header |
| Refused | 401 invalid_client, with the reason in error_description |
JWT bearer grant (RFC 7523 §2.1)
sub: the client id in one,
a person in the other.
For a trusted backend that already knows who the user is — an internal identity provider, a service acting on someone's behalf with no browser in the picture.
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer &assertion=SIGNED_JWT &client_id=CLIENT_ID &client_secret=CLIENT_SECRET # or client_assertion, as above &scope=SCOPES
{
"iss": "CLIENT_ID", // must equal client_id
"sub": "user@example.com", // the user — email, or username as a fallback
"aud": "TOKEN_ENDPOINT_URL",
"jti": "UNIQUE_PER_JWT",
"iat": 1640995140,
"exp": 1640995440 // at most 300s after iat
} - Off by default. An administrator of this server enables it per client; it lets its holder obtain a token for any user, so it is not something to have on by accident.
- No refresh token is issued. Ask again with a new assertion.
subis looked up as an email address first, then as a username. No match means the assertion is refused — it does not create anybody.- Signed with the same key as a client assertion, with the same 300-second ceiling and
the same one-use
jti.
Logout and SSO logout
Two endpoints, because there are two situations: your backend wants to drop a token, or a person clicked "sign out" in a browser.
| Endpoint | Method | Use |
|---|---|---|
/oauth/logout |
GET, POST | Programmatic. Requires Authorization: Bearer, answers JSON. |
/login/logout |
GET | Browser. Reads the session cookie, then redirects. |
Programmatic
POST /oauth/logout
Authorization: Bearer <access_token>
# optional — also clear the browser session for this user
logoutwebsession=1
# 200
{ "success": true, "user_id": 42, "tokens_revoked": 2 }
# no token: 401
{ "error": "invalid_token" }
This revokes the token you present and the tokens that came from it — its refresh token
and what that refreshed. Tokens issued on another device are left alone. Without
logoutwebsession=1 the browser session stays; that is usually what a backend
wants, and rarely what a "sign out everywhere" button wants.
Browser (OpenID Connect RP-initiated logout)
GET /login/logout
?id_token_hint=ID_TOKEN # the ID token you received
&client_id=CLIENT_ID
&post_logout_redirect_uri=https://your-app.example.com/ # where to land afterwards
&state=OPAQUE_VALUE # returned to you unchanged
&local=1 # optional: keep tokens valid - Ends the browser session on this server.
post_logout_redirect_urimust match, character for character, one of your registered redirect URIs or your application's homepage. Anything else is not followed: the user lands on this server's sign-in page.- With a valid
id_token_hintfor the signed-in user, the user's access and refresh tokens for your client are revoked too, unless you sendlocal=1. The hint may have expired; it must have been signed here. A bareclient_idrevokes nothing. - No application is notified. There is no front-channel or back-channel logout.
Declaring your capabilities
Permission control on this server is central: the grants live here, not in your tokens. For that to work, this server has to know what your application understands — which resources it has, which scopes exist on each, and which ABAC condition keys it can evaluate. You tell it, by pushing a manifest.
Push it from CI, on deploy. Then the server's picture of your application is whatever your application last shipped, rather than something an administrator here retyped from a specification.
The request
PUT https://auth.mesogeos-digital.comapi/internal/clients/{client_id}/capabilities
Authorization: Basic base64(client_id:client_secret)
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 particular locations"
}
}
}
POST works too, for a CI runner without PUT.
Credentials may also go in the body as client_id and
client_secret. You may only push your own manifest — the
client_id in the path must match the one you authenticated as.
The response, and why to read it
{"status":"synced","resources":1,"scopes":2,"conditions":1,"deactivated":0}
Check the counts, not just the status. A manifest the server
accepted but could not read reports "synced" with zeros beside
it, and a CI job reads that as done. If the counts are zero and your manifest
is not empty, the shape is wrong.
Push the same manifest again and you get "unchanged" — the server
hashes it and does nothing. That is what makes this safe to run on every
deploy.
What happens to something you remove
It is deactivated, not deleted. A permission grant here may still refer to it, and deleting the row would leave that grant pointing at nothing. Administrators see it struck through on your application's page, which is how somebody answers "why did this permission stop working".
Then keep the cache honest
Read a user's effective grants from
GET https://auth.mesogeos-digital.comapi/internal/permissions?user_id={id}&client_id={your_client_id},
with the same Basic credentials, and cache them. Register a webhook for
permissions_changed and drop that user's cache when it arrives —
that is what lets the tokens stay small and a revocation take effect on the
next request instead of the next token.
Scopes
Ask for the least you need. Every scope you request is a line on the consent screen the user has to accept, and a permission you have to justify.
| Scope | Grants |
|---|---|
profile |
Access to basic profile information (name, picture, locale, etc.). |
email |
Access to the user's primary email address. |
phone |
Access to the user's phone number. |
address |
Access to the user's physical address. |
user |
Access to user account information. |
openid |
Required for OpenID Connect requests — enables the ID Token. |
offline_access |
Allow the application to perform actions on your behalf when you are not online (issues a refresh token). |
system:admin |
Full administrative access — grants all standard scopes. |
system:audit_read |
Read access to audit logs. |
system:health |
Access to system health and monitoring data. |
system:notifications_read |
Read user notifications. |
system:notifications_write |
Send and manage user notifications. |
Read from this server's own scope registry, so it is what the server will actually accept.
What a client registration decides
| Client type | Confidential: authenticates with its secret or a signed assertion on every call. Public: sends its client_id alone, must use PKCE, and cannot use client credentials. A secret a public client does send is still checked |
| Redirect URIs | Exact matches. Protocol, host, port and path all count. Required for a public client, or one with no secret: without one, /oauth/authorize refuses it. |
| Scopes | The ceiling — a request for more is refused with invalid_scope. openid profile email user are allowed to every client on top of it. Left empty, the client may ask for any scope this server offers |
| Token lifetime | How long its access and refresh tokens last; empty uses the server's (1 hour and 1 month) |
| Trusted | A trusted client skips the consent screen |
Security
Non-negotiable
- HTTPS everywhere. An access token on a plaintext connection is a password on a postcard.
- PKCE for anything that ships to a device. A client secret compiled into an app or served to a browser is public.
- Check
stateon return. Not checking it is the CSRF hole. - Exact redirect URIs. A wildcard is an open redirect waiting for somebody to find it.
- Never log a token. Not in an access log, not in an error report, not in a support ticket.
Worth getting right
- Refresh on
401rather than on a schedule — a token can be revoked before it expires. - Revoke on logout. An abandoned token is valid until it is not.
- Verify an ID token against
jwks_uri, and checkiss,audandexpwhile you are there. - Store tokens where other origins cannot read them, and where they do not survive longer than the session that needed them.
What this server implements
Errors
Errors come back as JSON with an error code. The codes are specified, so
branch on them rather than on the message.
{
"error": "invalid_request",
"error_description": "The request is missing the required parameter 'grant_type'.",
"error_uri": "https://tools.ietf.org/html/rfc6749#section-5.2"
} | Code | Means | Usually |
|---|---|---|
invalid_request |
The request is malformed | A required parameter is missing or sent twice |
invalid_client |
Client authentication failed | Wrong client_id, wrong secret, or a secret sent where an assertion was expected |
invalid_grant |
The grant is not usable | An expired or already-used authorization code, or a redirect_uri that differs from the one in step 1 |
unauthorized_client |
This client may not use this grant | The grant is not enabled on the client registration |
unsupported_grant_type |
The server does not do that grant | A typo in grant_type |
invalid_scope |
The scope is unknown or not permitted | A scope above the client's ceiling, or a stale name |
access_denied |
The user said no | They clicked deny on the consent screen |
authorization_pending |
Device flow: not finished yet | Expected — keep polling at the stated interval |
slow_down |
Device flow: polling too fast | Add five seconds and continue |
expired_token |
Device flow: the code aged out | Start again with a new device code |
Handling them
- Check for
errorbefore reading anything else. A 200 is not a promise. - Log the code and the description; never the token or the assertion.
- Retry network failures and
slow_down. Do not retryinvalid_grant— it will not become valid. - Show the user something they can act on. "invalid_client" is not that.