Writing an Auth Provider
Contents
- In one sentence
- Quick start
- plugin.json
- plugin.js hooks
- Identities, accounts and groups
- The ctx object
- Login buttons, returnTo and sign out
- Example: proxy header provider
- Example: login form provider
- Redirect (OIDC / OAuth2) providers
- Testing your provider
- Startup errors and what they mean
- Gotchas
In one sentence
A login provider answers “who is this?”; MMGIS roles, groups and grants decide what that person may do, whichever provider answered.
local (MMGIS username/password accounts) and proxy (an authenticating reverse proxy such as Cloud Single Sign-On) ship with MMGIS. A new provider is a folder under plugins/core/auth/ and is turned on by listing its id in AUTH_PROVIDERS. Nothing else in MMGIS needs to change: Configure > Users & Access, roles, mission and layer grants, groups and API tokens all work for its users.
Quick start
- Create
plugins/core/auth/<Name>/plugin.jsonandplugins/core/auth/<Name>/plugin.js(copy one of the examples). - Add the id to
.env, keepinglocalso/configureadmins can still sign in:AUTH_PROVIDERS=["local","myprovider"]. - Restart MMGIS. The log prints
AUTH_PROVIDERSand any problems yourvalidate/warningshooks report. - In Configure > Users & Access > Groups, create a group with Members from MYPROVIDER and the external group name your provider reports, then give that group a role.
- Log in through your provider and check the user in Users & Access > People (session providers) or with Check a provider user (per-request providers).
There are two kinds of provider. Pick one:
| Kind | Hook | When | Examples |
|---|---|---|---|
| Per-request | identify(req) | Something in front of MMGIS (a proxy) has already logged the user in and sends who they are on every request. | proxy, a trusted header proxy |
| Session | routes(router, ctx) + ctx.completeLogin | MMGIS shows a login page or redirects to an identity provider, then starts its own session. | a login form against a directory, OIDC, OAuth2, SAML |
plugin.json
{
"name": "MyProvider",
"display_name": "My Provider",
"type": "auth",
"provider": "myprovider",
"tier": "core",
"overridable": false,
"login_path": "/login",
"login_label": "Log in with My Provider",
"logout_url": "https://idp.example.org/logout"
}
| Field | Required | Meaning |
|---|---|---|
type | yes | Always "auth". |
provider | yes | The id used in AUTH_PROVIDERS, in group sources and in /api/auth/<id>. Lowercase letters, digits, - and _, starting with a letter. local and proxy belong to the shipped providers. |
tier, overridable | yes | Must be "core" and false. Providers can’t be overridden by external plugin containers. |
display_name | no | Shown on login buttons (Log in with <display_name>). Defaults to the folder name. |
login_path | no | Session providers: the route that starts a login, relative to /api/auth/<id>. When set, the login page, the Configure sign-in page and the in-app login modal show a button for it. |
login_url | no | Per-request providers: a login page outside MMGIS (for example the proxy’s), linked as is. Use it instead of login_path. |
login_label | no | Text of that button. |
logout_url | no | Where the main site’s Sign out sends the browser after ending the MMGIS session, e.g. your identity provider’s logout page. Per-request providers need it, because MMGIS can’t end a proxy’s login. |
dependencies | no | npm/pip packages your provider needs, e.g. { "npm": { "ldapts": "^7.0.0" } }. node scripts/resolve-plugin-deps.js collects them with every other plugin’s (see plugins/README.md, Dependencies). |
plugin.js hooks
Every export is optional.
module.exports = {
// Per-request login. Return an identity, or null for "not mine". May be async.
identify(req) {},
// Session login. Mount routes on router; they are served at /api/auth/<id>.
routes(router, ctx) {},
// Problems that refuse startup, as strings. Only called when the provider is in AUTH_PROVIDERS.
validate(env) { return []; },
// Problems logged at startup, as strings.
warnings(env) { return []; },
};
identifyruns on every HTTP request and websocket connection, for each enabled provider inAUTH_PROVIDERSorder. The first provider that returns an identity wins. A signed-in MMGIS session takes precedence over it.validate(env)andwarnings(env)getprocess.envand must return an array of strings synchronously (an empty array means no problems). Anything else, or an exception, is reported at startup under your provider id.- If
identify(req)throws, or takes longer than 5 seconds, the error is logged under your provider id and the request is treated as not identified. - Read your provider’s settings from
envand name them<PROVIDERID>_*(for exampleMYPROVIDER_URL). Document them indocs/pages/Setup/ENVs/ENVs.mdandsample.env. - Every route you mount must declare its permission, usually
ctx.authorize.public("...")for login pages; startup lists routes that don’t. See Backend Permissions. - JSON and URL-encoded bodies are already parsed into
req.body, andreq.sessionis the MMGIS session.
Identities, accounts and groups
An identity is:
{ subject, username?, email?, groups?: string[], session? }
subject(required) is the provider’s stable id for the person. MMGIS links accounts by provider + subject, never by username, so renames at the provider keep the same account (and its first username; MMGIS doesn’t rename accounts).usernamedefaults tosubject.emailis optional.groupsare the provider’s group names.
Session providers. await ctx.completeLogin(req, identity):
- finds the account linked to this provider + subject, or creates one on first login (with a random password, so it can only sign in through the provider, and the identity’s email if no other account uses it);
- throws
ctx.LinkErrorwhen the username already belongs to another account (for example a local account). Respond409witherr.message. MMGIS never takes over an existing account; - sets the person’s membership of groups whose source is this provider to exactly the reported
groups, adding and removing as needed; - starts a fresh session (new session id) and returns
{ id, username }.
Per-request providers create no account. Their users don’t appear in People and can’t hold a grant of their own; they get access through groups (matched on every request) and the Everyone / Logged-in users audiences.
Groups. A provider’s groups only matter for groups an admin has created in Users & Access with **Members from
The ctx object
routes(router, ctx) receives exactly:
| Member | What it is |
|---|---|
ctx.completeLogin(req, identity) | Links the account, syncs groups and starts the session (see above). Returns a promise. |
ctx.LinkError | The error class completeLogin throws on a username clash. |
ctx.returnTo(value) | Returns value if it’s a same-site path (/...), otherwise the site root. Use it on any “where to go after login” value. |
ctx.authorize | The permission helpers; use ctx.authorize.public("reason") on login routes. |
ctx.logger | logger(level, message, caller, req, err). |
There’s no database handle or config object; read settings from process.env.
Rate limiting is automatic. Each provider’s routes share a limit of 10 failed responses (status 400 or higher) per IP per 15 minutes, like local login. So answer bad credentials with 401, not 200; successful logins and redirects don’t count.
Login buttons, returnTo and sign out
- Buttons. With
login_pathset, the login page, Configure sign-in and the main site’s login modal link to/api/auth/<id><login_path>?returnTo=<the page the user was on>.GET /api/authreturns the enabled providers and these links. - Keeping provider tokens. To keep something for later (for example an ID token for RP-initiated logout), set it on
req.sessionaftercompleteLoginresolves and callreq.session.save(...)before redirecting. - returnTo. Read it with
ctx.returnTo(req.query.returnTo).completeLoginstarts a new session, so read anything you stored inreq.session(state, nonce, returnTo) before calling it, thenres.redirect(...). - On a site without
local. WithSITE_ACCESS=requiredand nolocal, the login page shows only the provider buttons. Without any button it shows the “not allowed” page meant for proxies. - Main site. On page load the main site asks
/api/users/login(useToken) who is signed in; provider sessions and per-request identities are reported as signed in, with no saved-login cookie. - Sign out. For session providers, Sign out ends the MMGIS session, then goes to
logout_urlif set. For per-request providers it goes tologout_url, or tells the user to sign out through their provider.
Example: proxy header provider
A proxy in front of MMGIS logs people in and sends X-Remote-User, X-Remote-Email and X-Remote-Groups (comma separated), plus a shared secret so the headers can’t be forged by clients.
plugins/core/auth/ProxyHeader/plugin.json
{
"name": "ProxyHeader",
"display_name": "Proxy",
"type": "auth",
"provider": "proxyheader",
"tier": "core",
"overridable": false,
"logout_url": "/proxy/logout"
}
plugins/core/auth/ProxyHeader/plugin.js
const crypto = require("crypto");
function secretMatches(req) {
const sent = Buffer.from(String(req.get("X-Proxy-Secret") || ""));
const expected = Buffer.from(String(process.env.PROXYHEADER_SECRET || ""));
return expected.length > 0 && sent.length === expected.length && crypto.timingSafeEqual(sent, expected);
}
module.exports = {
identify(req) {
const user = req.get("X-Remote-User");
if (!user || !secretMatches(req)) return null;
return {
subject: user,
email: req.get("X-Remote-Email") || undefined,
groups: String(req.get("X-Remote-Groups") || "").split(",").map((g) => g.trim()),
};
},
validate(env) {
const problems = [];
if (!env.PROXYHEADER_SECRET || env.PROXYHEADER_SECRET.length < 16)
problems.push("PROXYHEADER_SECRET must be set to at least 16 characters.");
return problems;
},
warnings(env) {
return env.SITE_ACCESS === "public"
? ["proxyheader is enabled on a public site, so guests are never asked to log in."]
: [];
},
};
Enable it with AUTH_PROVIDERS=["local","proxyheader"] and PROXYHEADER_SECRET=..., and make sure the proxy removes these headers from client requests.
Example: login form provider
A username/password form checked against a directory. Here the directory is a JSON file so the example runs as is; replace checkPassword with your LDAP or HTTP call.
plugins/core/auth/Directory/plugin.json
{
"name": "Directory",
"display_name": "Directory",
"type": "auth",
"provider": "directory",
"tier": "core",
"overridable": false,
"login_path": "/login"
}
plugins/core/auth/Directory/plugin.js
const fs = require("fs");
const crypto = require("crypto");
// DIRECTORY_FILE: [{ "id": "u1", "username": "alice", "password": "...", "email": "...", "groups": ["science"] }]
function checkPassword(username, password) {
const people = JSON.parse(fs.readFileSync(process.env.DIRECTORY_FILE, "utf8"));
const person = people.find((p) => p.username === username);
if (!person) return null;
const a = Buffer.from(String(password));
const b = Buffer.from(String(person.password));
return a.length === b.length && crypto.timingSafeEqual(a, b) ? person : null;
}
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const page = (returnTo, message = "") => `<!doctype html><title>Directory login</title>
<form method="post">
<p>${escape(message)}</p>
<input type="hidden" name="returnTo" value="${escape(returnTo)}">
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<button>Log in</button>
</form>`;
module.exports = {
routes(router, ctx) {
router.get("/login", ctx.authorize.public("directory login page"), (req, res) => {
res.send(page(ctx.returnTo(req.query.returnTo)));
});
router.post("/login", ctx.authorize.public("directory login"), async (req, res) => {
const returnTo = ctx.returnTo(req.body.returnTo);
const person = checkPassword(req.body.username, req.body.password);
// 401 so failed attempts count towards the rate limit
if (!person) return res.status(401).send(page(returnTo, "Invalid username or password."));
try {
await ctx.completeLogin(req, {
subject: person.id,
username: person.username,
email: person.email,
groups: person.groups,
});
res.redirect(returnTo);
} catch (err) {
if (err instanceof ctx.LinkError) return res.status(409).send(page(returnTo, err.message));
ctx.logger("error", "Directory login failed.", "Directory", req, err);
res.status(500).send(page(returnTo, "Login failed."));
}
});
},
validate(env) {
return env.DIRECTORY_FILE && fs.existsSync(env.DIRECTORY_FILE)
? []
: ["DIRECTORY_FILE must point to the directory's JSON file."];
},
};
Enable it with AUTH_PROVIDERS=["local","directory"] and DIRECTORY_FILE=/path/to/people.json. The login page and the in-app modal now show Log in with Directory.
Redirect (OIDC / OAuth2) providers
The shape is the same as the form provider, with two routes:
GET /login: createstate,nonceand a PKCE verifier, store them andctx.returnTo(req.query.returnTo)inreq.session, callreq.session.save(...), then redirect to the identity provider’s authorize URL withredirect_uriset to<site>/api/auth/<id>/callback.GET /callback: read and delete the stored values, checkstate, exchange the code at the token endpoint, verify the ID token (signature,iss,aud,exp,nonce), thenawait ctx.completeLogin(req, { subject: claims.sub, username: claims.preferred_username, email: claims.email, groups: claims.groups })and redirect to the stored returnTo.
Declare both with ctx.authorize.public(...), set "login_path": "/login", and use a maintained OIDC library rather than verifying tokens by hand. Validate the issuer URL, client id, client secret and redirect URI in validate(env).
Testing your provider
- Startup: each
validateproblem stops the server with your message;warningsare printed. - Login: session providers: start from the login page button and from the main site’s modal, and land back on the page you started from. Per-request: send the headers with
curland checkGET /api/access/me. - Accounts: a second login reuses the same account (People shows one row); a username that’s already a local account gets your 409 message.
- Groups: create a **Members from
** group, give it a role, log in and check what the user can see; remove the group at the provider and log in again. - Coexistence: local accounts still log in with your provider enabled.
- Removal: take the id out of
AUTH_PROVIDERSand restart: its routes return 404 and its sessions and tokens stop working. Its accounts, groups and grants are kept. - Websockets: scripted websocket clients must send an
Originheader, or MMGIS closes the connection (the reason is only in the server log). - Unit tests: see
tests/unit/permissions-providers.spec.jsfor loading a provider from a temporary plugins folder.
Redirect flows are easiest to test with a production build (npm run build, then NODE_ENV=production npm start). In development the map is served on a different port (PORT+1) from the API, so redirects back to / land on the API port.
Startup errors and what they mean
| Log message | Fix |
|---|---|
AUTH_PROVIDERS lists an unknown provider 'x' | The folder isn’t under plugins/core/auth/, its plugin.json has a different provider, or it failed to load (see the errors above it). |
Auth provider '…' was not loaded: auth providers must be core plugins | Move it to plugins/core/auth/. |
… must set "tier": "core" and "overridable": false | Add both fields to plugin.json. |
… needs a "provider" id | Add a lowercase provider to plugin.json. |
… is the id of the built-in … provider | Pick another id. |
… is already provided by … | Two folders use the same id. |
… its plugin.js threw … | Your plugin.js failed when loaded; the stack trace follows. |
MMGIS cannot start because of its authentication settings | Lists every validate problem; fix your provider’s ENVs. |
Gotchas
- Keep
localinAUTH_PROVIDERSunless your provider’s users include an administrator; local accounts are how/configureadmins sign in. - A trusted-header provider must check a shared secret (or be reachable only through the proxy), because anyone who can reach MMGIS can send headers.
PROXY_LEAD_GROUPandPROXY_GROUPSonly apply toproxy.- Draw files belong to a username, so keep usernames unique across providers;
completeLoginalready refuses a username that another account has.
Code: API/permissions/providers.js (loading, completeLogin, routes), API/permissions/siteAccess.js (AUTH_PROVIDERS), plugins/core/auth/Proxy/plugin.js (a per-request provider).