# mmo.oauth — overview for AI code generation
`mmo.oauth` (PHP namespace `MMO\oauth`) is Marc Mouret's authentication/authorization package for MMOFramework
(`mmo.core`). Despite the name, it is **not** a generic OAuth2 authorization server implementation: it is a
self-hosted client/user/role/scope model, plus one always-registered controller
(`MMO\oauth\controllers\AccessController`) that extracts "who is calling" on every single request and a
hierarchical scope system that `mmo.core`'s own `Query`/`Commit`/page-content events consult automatically. It
does borrow real OAuth2/OIDC vocabulary for two optional bridges (an external SSO "authorization code + PKCE"
flow, and a dedicated Google login), described near the end of this file.
Read this together with the package's own `docs/` scopes reference in `README.md` (scope syntax and the
built-in `data.*`/`pages.*`/`files.*`/`widgets.*` conventions) if you need to design new scopes.
## Core pieces
| Class | Role |
|---|---|
| `MMO\oauth\entities\User` (uses `tUser` → `tClient` → `tWithRoles` → `tWithScopes`) | The default end-user entity: `username`, `passhash` (never plain-text — set only via `setPassword()`), `email`, `firstname`, `lastname`, plus `roles` and its own `scopes`. Implements `iUser`/`iClient`. |
| `MMO\oauth\entities\Client` | A non-user client (machine/app credential), same `tClient`/`tWithRoles` base minus the username/password bits of `tUser`. |
| `MMO\oauth\entities\Role` (`iScopeProvider`, `tWithScopes`) | A named, reusable bag of scopes (`label`, `description`, `scopes`). Users/clients hold a list of `Role`s; their effective scopes are their own scopes merged over every role's scopes (`tWithRoles::getScopes()`). |
| `MMO\oauth\entities\AccessToken` / `RefreshToken` (both extend `classes\aToken`) | Persisted, expiring tokens tied to a `client` (an `iClient`). `toJWT()` builds a `JWT` (claims: `jti`=token id, `sub`=client id, `aud`=hostname, `iss`, `iat`, `exp`). `RefreshToken` additionally points back at the `AccessToken` it can renew. |
| `MMO\oauth\entities\CodeAuthentifier` | Config record for an external OIDC-style SSO provider (authorization endpoint, token endpoint, client id/secret, PKCE `codeVerifier`) — the "authorization code" grant, but *initiated by this app as the OAuth client*, not implemented by this app as an OAuth server. |
| `MMO\oauth\ClientRepository` (`aModule` singleton) | The lookup/creation layer in front of one or more configured user/client `iCollection`s (`collections`), plus dedicated `accessTokens`/`refreshTokens`/`roles` collections. Key methods: `getByUsername`/`getByEmail`/`getByCredentials` (verifies password), `createUser`, `createClientTokens`, `loadAccessToken`. |
| `MMO\oauth\Crypt` (`aModule` singleton) | Password hashing/policy (bcrypt via `passwordHash`/`passwordVerify`), generic HMAC/AES helpers, **and current-request token storage**: `storeToken()`/`getToken()`/`clearToken()` read/write the PHP session (`$_SESSION[$this->tokenKey]`, default key `'oauth-bearer'`). |
| `MMO\oauth\helpers\JWT` | A minimal JWT encode/decode, signed/verified with this package's own `PrivateKey`/`PublicKey` (RSA/EC via OpenSSL) — not a general-purpose JWT library, and not RFC-complete (no `aud`/`nbf` enforcement beyond what's described below). |
| `MMO\oauth\controllers\AccessController` (`aController` singleton) | **The authentication gate.** See below. |
| `MMO\oauth\google\GoogleController` | Optional, separate Google-login bridge (`GET|google`), reusing `ClientRepository`/token creation. |
## How a request actually gets authenticated
`AccessController` registers a hidden, catch-all route on itself:
```php
#[APIRoute('**', order: -100, options: ['hidden' => true])]
public function extractUser(Route $route): void {}
```
Because `**` matches literally every path, this guarantees `AccessController::onRoute()` runs as middleware on
every request, in addition to whatever controller actually serves it. `onRoute()` does, in order:
1. **Find a token.** Prefer an `Authorization: Bearer ` header — decoded and **signature-verified**
against `$this->publicKey` via `JWT::Decode($jwt, $this->publicKey)` (throws `eInvalidCredentials` if the
header is present but malformed). Otherwise, fall back to `Crypt::Shared()->getToken()`, which rebuilds a
`JWT` object straight from the PHP session's stored claims (`new JWT($_SESSION['oauth-bearer'])`) — **this
path does not re-verify a signature**, because the trust boundary for the session path is the PHP session
itself (server-side storage), not the JWT's own signature. Only the bearer-header path is cryptographically
verified end-to-end.
2. **Resolve the token to a live entity.** `ClientRepository::loadAccessToken($jwt)` looks up the JWT's `jti`
in the configured `accessTokens` collection. If found and `isValid()` (not expired), that's the token. If
expired but the JWT carried an `rti` (refresh token id) pointing at a still-valid `RefreshToken` for the
same access token, a **new** access/refresh token pair is minted transparently
(`createClientTokens($token->getClient())`). Otherwise it throws (`eExpired`/`eNotFound`), which
`onRoute()` catches, clears the session token, and redirects to login.
Note: `loadAccessToken()` does **not** check the JWT's `aud` claim against the current hostname — that
check exists in a comment in the source but is disabled.
3. **Attach the user to the route.** `$route->setAttribute('user', $token->getClient())`. This is exactly what
`\MMO\oauth\user(): ?iClient` reads (`\MMO\app()->getRoute()?->getAttribute('user')`) — call it any time
*after* this middleware has run, which is guaranteed for any request since the catch-all route always
matches first (`order: -100`).
4. **Enforce login when there's no user.** If no valid token was found **and** `AccessController::$allowAnonymous`
(a public property, default `true`) is `false`, it calls `$route->clearNonAnonymousMiddleware()` — this
strips every middleware queued for this request whose route options don't carry `'anonymous' => true`
(`Route::clearNonAnonymousMiddleware()` in `mmo.core`). If nothing is left to run afterwards, it renders the
login page (content-negotiated: an HTML login form via `renderNoToken()`, or a 401 JSON response for
non-HTML requests) and stops the route.
So gating is **global and default-open**: as long as `AccessController::$allowAnonymous` stays `true` (the
default, and what the demo config sets explicitly), no route is blocked — `\MMO\oauth\user()` will just return
`null` for anonymous visitors, and it's on your own code to check scopes/`user()` where it matters. Flip
`allowAnonymous` to `false` application-wide to require a logged-in user everywhere *except* routes explicitly
opted out.
## Protecting vs. explicitly allowing a route
The opt-out mechanism is the `anonymous` route option that `APIRoute`'s own docblock already documents as
"used by oauth":
```php
use MMO\core\attributes\APIRoute;
use MMO\core\classes\aDataController;
use MMO\core\server\Route;
class AccountController extends aDataController {
// Only reachable by a logged-in user once AccessController::$allowAnonymous is false —
// otherwise reachable by anyone, with \MMO\oauth\user() possibly returning null.
#[APIRoute('GET|account.json')]
public function routeAccount(Route $route): array {
$user = \MMO\oauth\user(); // iClient|null
if (!$user) {throw new \MMO\oauth\exceptions\eAccessDenied($this, $route, 'login required.');}
return ['username' => $user->getUsername(), 'email' => $user->getEmail()];
}
// Stays reachable even when AccessController::$allowAnonymous is false.
#[APIRoute('GET|health.json', options: ['anonymous' => true])]
public function routeHealth(Route $route): array {
return ['ok' => true];
}
}
```
Note the asymmetry: with `allowAnonymous` left at its default `true`, the `anonymous` option is a no-op (every
route is already reachable) — it only matters once you've locked the app down. There's no equivalent
"require login on this one route while the rest stays open" attribute; that granularity has to be done by hand
(check `\MMO\oauth\user()` and throw, as `routeAccount` does above), or by giving that one route its own
`allowAnonymous = false` controller.
## Scope-based authorization (mostly automatic)
Beyond "is someone logged in", `mmo.oauth` enforces a separate, hierarchical **scope** system —
`data.{repository}.{collection}.{id}#{show|edit|create|delete|search|index}`, `pages.{path}#...`,
`files.{id}#...`, `widgets.{uid}#...` (full grammar and worked examples in the package's `README.md`). Once a
user/role has scopes set (`$user->allow('data.orders#edit')` / `$role->setScope(...)`), `AccessController`
enforces them for you, with no per-route code needed, by listening to `mmo.core` events:
- `Query` (order 1000): blocks querying a collection the user has no `#show` scope for, and filters/opacifies
individual entities the user isn't allowed to see. Only a query *dispatched* is seen: `RepositoryController`'s
(its listings, and since 2026-09 `data.json?uid=`, which served any entity to anyone before) and
`StatisticController`'s are; a `$collection->find()` in code is not.
- `Commit` (order 1000): blocks creating/editing/deleting/changing-status of an entity unless the matching
scope (`#create`/`#edit`/`#delete`/status-change) is allowed; also enforces username uniqueness for new
users. A commit holds every change made since the last one, whoever made it.
- `PageContents` (order 1000): hides `mmo.client` `Content` nodes the user has no `pages.*#show` (or implicit)
scope for.
**A collection its module guards itself** — its entity class implements `MMO\oauth\interfaces\iGuarded`, as
mmo.edms' `File` and `Label` do, their rights being edms' own — is left to that module: `Query` and `Commit` ask
no `data.*` scope for it, so its users hold none for the module to save what they do. Except in the generic data
api, the routes of `AccessController::$dataControllers` (`RepositoryController`, `StatisticController`: add an
application's own), which serve any collection by its name and so still refuse it to a client without its
`data.*` scopes (`tests/controllers/TestGuarded.php`). The other entities of a commit the module sends are still
checked.
To check a scope yourself: `\MMO\oauth\user()?->isAllowed('data.orders#edit')`, or the free function
`\MMO\oauth\isUserAllowed(...$uids)` (returns `false` for a `null` user, i.e. anonymous is never allowed by
default). Inside `AccessController` (or a subclass), use `$this->isUserAllowed(...)` /
`$this->hasUserScope(...)`, which fall back to `$this->anonymousScopes` for a logged-out visitor instead of
always denying.
## Wiring it into an app
```json
{
"MMO\\core\\Application": {
"routes": {
"MMO\\core\\controllers\\RepositoryController": {},
"MMO\\oauth\\controllers\\AccessController": {}
}
},
"keys": {
"private": {"$": ["MMO\\oauth\\keys\\PrivateKey", "FromFile"], "0": "{configs}/private.key"},
"public": {"$": ["MMO\\oauth\\keys\\PublicKey", "FromFile"], "0": "{configs}/public.key"}
},
"MMO\\core\\controllers\\RepositoryController": {
"repositories": {"main": {"$": "#repo"}}
},
"MMO\\oauth\\ClientRepository": {
"accessTokens": {"$": "#repo/access"},
"refreshTokens": {"$": "#repo/refresh"},
"roles": {"$": "#repo/roles"},
"collections": {"$": "#repo"}
},
"MMO\\oauth\\controllers\\AccessController": {
"privateKey": {"$": "#keys/private"},
"publicKey": {"$": "#keys/public"},
"clients": {"$": "#MMO\\oauth\\ClientRepository"},
"allowAnonymous": true
}
}
```
(condensed from `demo/configs/config.json`, which also shows the `json`/`pdo`/`mongo` repository backends).
The RSA key pair is generated once with `openssl genrsa -out private.key 2048` /
`openssl rsa -in private.key -pubout > public.key` (see the package `README.md`); `PrivateKey`/`PublicKey` wrap
them for `Crypt::sign()`/`verify()` and `JWT::encode()`/`Decode()`.
`ClientRepository::collections` must contain at least one `iCollection` whose entity class implements `iClient`
(typically your `User` collection, registered the same way any `mmo.core` repository collection is); this is
also what `RepositoryController` exposes as an ordinary data collection, so `MMO\oauth\entities\User` shows up
as a normal repository entity for CRUD/API purposes too — see `mmo.core`'s own `docs/API.md`, which uses
`"users": "MMO\\oauth\\entities\\User"` as its own example.
## Login flow (what actually happens, not textbook OAuth2)
All under `AccessController`, base path relative to wherever the controller is routed:
- `GET|login` (HTML) — with no params, renders the login form (`login.phtml`, CSRF token from
`getCSRFToken('loginCSRF', $privateKey)`); with `username`/`password`, validates CSRF then calls
`doLogin()` (checks credentials via `ClientRepository::getByCredentials`, optionally switches account if
`switchAccount` + `sudo.switchAccount` scope, creates tokens, stores the token in the session via
`Crypt::storeToken()`) and redirects; with `token`, trusts a signed JWT passed in the URL (used by the
password-reset email link) and stores it in the session; with `code`, exchanges an external SSO
authorization code for an `id_token` against the active `CodeAuthentifier` and logs the matching (or newly
created, if `allowUserCreationWithCode`) user in.
- `GET|login.json?username=&password=` — same credential check, returns the token as JSON plus a ready-to-use
`Authorization: Bearer ...` header string (for API/CLI clients instead of a browser session).
- `logout` — clears the user and the stored session token, redirects to `{siteurl}`.
- `POST|login/forgot` / `POST|login/reset` — email a signed reset-token link, then accept a new password
against that token.
- `POST|user/create`, `PUT|user/config.json`, `GET|user/config.json`, `GET|user/scopes.json` — account
management/introspection endpoints, all going through the same scope enforcement described above.
`GoogleController::login` (`GET|google`, `options: ['anonymous' => true]`) is a separate, parallel bridge: it
exchanges a Google OAuth code (or decodes a Google-issued JWT) for the user's Google profile, then finds/creates
a local user by email and calls the same `ClientRepository::createClientTokens()` + session storage as the
password flow.
## Things worth knowing before you build on this
- **The catch-all `extractUser` route is what makes `\MMO\oauth\user()` work anywhere** — it only returns a
meaningful value once this middleware has run, which requires `AccessController` to actually be registered
in `Application`'s `routes` (see config above).
- **`src/helpers/Session.php`** (a custom encrypted `SessionHandler`) is *not* used — it's explicitly commented
`NOT USED as it does not work` in its own docblock, and `bootstrap.php` requires it only in a commented-out
line. The real session mechanism is a plain `session_start()` (called once from `bootstrap.php` if not
already active) plus `Crypt::storeToken()`/`getToken()` reading/writing `$_SESSION` directly. Don't route
around this thinking a hardened session handler is in play — it isn't currently wired up.
- The demo's own `demo/www/controllers/Index.php` imports `MMO\oauth\helpers\{Session, AuthCode}` — `AuthCode`
does not exist as a helper class (only `entities\AuthCode`/`interfaces\iAuthCode`), so this demo file looks
stale/broken. Don't use it as a reference for wiring a real app; the `demo/configs/config.json` snippet above
is the accurate one.
- Passwords are never readable: `User` only exposes a write-only `password` array property
(`#[ArrayProperty(setter: 'setPassword')]`) backed by `passhash`, hashed through `Crypt::passwordHash()`
(bcrypt). There is no code path that stores or logs a plaintext password beyond the request that sets it.
`Client` has no `password`: its `secret` is stored as given by `ClientRepository::createClient()` and checked
with `passwordVerify()` (`tClient::verifySecret()`) — hand it a hash, never the secret itself.
- The secrets are `@hidden` (mmo.core's `ArrayProperty::isHidden()`): `User::$passhash`, `Client::$secret`,
`AuthCode::$code`, and `CodeAuthentifier`'s `clientSecret` and `codeVerifier`. They are stored and loaded like
any property, and code reads them (`$user['passhash']`, `$sso['clientSecret']`), but `toArray()` never exports
them — not even named (`?fields=passhash`), nor through an entity holding the user (an edms File's `owner`, a
token's `client`) — and an API query cannot filter or sort on them (400 Bad Request). A new secret gets
`@hidden` too. (Until 2026-09 the tag was ignored: every API response holding a user carried its hash.)