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.

GrantForClient secret
Authorization codeServer-rendered web applicationsRequired
Authorization code + PKCESPAs, mobile applicationsNot used
Client credentialsService to service, no userRequired
PasswordTrusted first-party applications onlyOptional
Device codeTVs, IoT, anything without a keyboardNot used
Refresh tokenRenewing an access tokenDepends 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

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
}
AlgorithmRS256 (RS384/512 and ES256/384/512 also accepted)
Key size2048 bits minimum
Lifetimeexp − iat ≤ 300 seconds
jtiUnique per client until the assertion expires. Reusing one is treated as a replay and refused.
audThis server's token endpoint, or its issuer
Your keyThe public key registered for your application, or your jwks_uri — then put the key's kid in the JWT header
Refused401 invalid_client, with the reason in error_description

JWT bearer grant (RFC 7523 §2.1)

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.
  • sub is 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.

EndpointMethodUse
/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_uri must 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_hint for the signed-in user, the user's access and refresh tokens for your client are revoked too, unless you send local=1. The hint may have expired; it must have been signed here. A bare client_id revokes 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.

ScopeGrants
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 typeConfidential: 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 URIsExact matches. Protocol, host, port and path all count. Required for a public client, or one with no secret: without one, /oauth/authorize refuses it.
ScopesThe 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 lifetimeHow long its access and refresh tokens last; empty uses the server's (1 hour and 1 month)
TrustedA 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 state on 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 401 rather 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 check iss, aud and exp while 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

OAuth 2.0 — RFC 6749 PKCE — RFC 7636 Token revocation — RFC 7009 Token introspection — RFC 7662 Device grant — RFC 8628 JWT client assertion — RFC 7523 JWT bearer grant — RFC 7523 §2.1 Server metadata — RFC 8414 OpenID Connect Core WebAuthn / passkeys

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"
}
CodeMeansUsually
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 error before 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 retry invalid_grant — it will not become valid.
  • Show the user something they can act on. "invalid_client" is not that.