Sign-in Plugins
A sign-in plugin puts a button on the login page and, after the external provider redirects back, creates a normal panel session. Password login, API keys, and two-factor settings are unchanged. The official examples are discord-oauth and oidc-sso.
Prerequisites
Section titled “Prerequisites”Declare all four of these, or the pieces that depend on them silently do less:
| Permission | Unlocks |
|---|---|
routes.public |
authorize and callback routes that have no panel session yet |
auth.users |
ctx.auth.findUser, ctx.auth.createUser |
auth.sessions |
ctx.auth.createSession |
roles.assign |
Role listing and assignment, if you mirror groups |
auth.sessions and roles.assign are marked critical in the consent dialog.
Say what you use them for in permissionDescriptions.
1. Declare the button
Section titled “1. Declare the button”"authProviders": [ { "id": "discord", "label": "Discord", "authorizePath": "authorize" }]While the plugin is enabled, each entry becomes a
“Continue with {label}” button on the login page (auth namespace key
sso.continueWithProvider). The button links to
/api/plugins/<name>/<authorizePath>. authorizePath defaults to
authorize. id is 1–32 characters, label is 1–64, and you may declare at
most 8 providers. Enable the plugin from Admin → Extensions → Plugins and
the buttons appear — there is no separate sign-in settings screen.
The list is public (GET /api/auth/oauth-providers) so the login page can
render it before anyone has a session. It returns
{ success: true, providers: [{ plugin, id, label, authorizeUrl }] }, and only
enabled plugins are listed because a disabled plugin’s authorize route
answers 503.
2. Register public routes
Section titled “2. Register public routes”async onLoad(ctx) { ctx.registerRoute({ method: 'GET', url: '/authorize', config: { auth: 'optional' }, handler: async (request, reply) => { const userId = ctx.getUserId(request); // set when an already-signed-in user is linking // redirect to the provider return reply.redirect(authorizeUrl); }, });
ctx.registerRoute({ method: 'GET', url: '/callback', config: { auth: 'public' }, handler: async (request, reply) => { // exchange the code, then find or create the user, then: await ctx.auth.createSession(user.id, { reply, rememberMe: true, ipAddress: request.ip, userAgent: request.headers['user-agent'], }); return reply.redirect('/'); }, });}optional on authorize lets a signed-in user start a link flow and an
anonymous user start a login flow. public on callback is required because
the provider redirects a browser that may have no panel cookie yet.
Both modes are downgraded to required unless routes.public is currently
granted — see Plugin Backend for the full
auth-mode rules and what happens when a grant is revoked.
3. Use the auth bridge
Section titled “3. Use the auth bridge”ctx.auth is present on a normal panel. Every method checks the live grant
and throws if it is missing.
const existing = await ctx.auth.findUser({ email }); // or { userId } or { username }// PluginAuthUser: { id, email, username, name, image, emailVerified, banned, lockedUntil }
const created = await ctx.auth.createUser({ email, username, // 1–32 of [a-zA-Z0-9._-] name, emailVerified: true, image: null,});
const session = await ctx.auth.createSession(created.id, { reply, rememberMe: true });// session is { token, expiresAt }; passing reply sets the session cookiecreateUser creates an account with no password. Those users come back
through your plugin. Duplicate email or username throws.
createSession writes a real session cookie, the same one password login
sets. rememberMe defaults to true; pass rememberMe: false for a
browser-session cookie. Refuse banned users and users with a lockedUntil in
the future before you call it.
On failure, redirect to:
/login?oauthError=<code>&providerLabel=<label>The login page shows a failure notice for that query shape. Use a short
stable oauthError code (access_denied, email_unverified), not a sentence.
4. Mirror roles
Section titled “4. Mirror roles”Role changes are audited. Pass reason so the audit log says why.
const roles = await ctx.auth.listRoles(); // { id, name, description }[]const mine = await ctx.auth.listUserRoles(userId);await ctx.auth.assignRoles(userId, ['role_id'], { reason: 'discord guild role match' });await ctx.auth.removeRoles(userId, ['role_id'], { reason: 'discord guild role removed' });This is the only supported way for a plugin to change roles. user.write
cannot.
5. Show link state on the profile
Section titled “5. Show link state on the profile”Mount a card on the profile-connections slot so a signed-in user can link
and unlink. See Frontend. discord-oauth is the
reference implementation.
Store the link
Section titled “Store the link”Keep the external account id in a dedicated collection, not in a user
column. You cannot add columns. discord-oauth and oidc-sso both set
"storageEngine": "dedicated" and key documents by the panel user id plus
the provider subject.
Related
Section titled “Related”- Plugin Backend — routes, auth modes, and the grant model
- Plugin Manifest — declaring
authProvidersand permissions - API reference — the REST endpoints session and API-key auth apply to
