Index
# 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.)