refactor: add oauth auth modules + fix user/group admin UI

scarlett
NGPixel 1 month ago
parent 14e1efae41
commit ff4a5bc6e6
No known key found for this signature in database

@ -1,5 +1,41 @@
import { nanoid } from 'nanoid'
import { limitAuthAttempts } from '../helpers/rateLimit.ts' import { limitAuthAttempts } from '../helpers/rateLimit.ts'
import type { FastifyInstance } from 'fastify' import type { FastifyInstance, FastifyRequest } from 'fastify'
/**
* How long a redirect login may take before its callback is refused.
*
* Long enough for somebody to be asked for a password and a second factor at the provider, short
* enough that a `state` left lying around in a URL somewhere is no longer worth anything.
*/
const AUTH_FLOW_MINUTES = 15
/**
* Where a provider sends the browser back, as an absolute URL.
*
* Built from the request rather than stored, so an instance reachable on more than one hostname keeps
* working but it has to match what the administrator registered with the provider, which is why the
* admin area shows this exact shape on the strategy's page.
*/
function callbackUrl(req: FastifyRequest, strategyId: string): string {
return `${req.protocol}://${req.host}/_api/auth/${strategyId}/callback`
}
/**
* The login screen, carrying what went wrong.
*
* A redirect login fails at the provider or on the way back, where there is no request left to answer
* with an error so the browser is sent to the login screen with a code it can put in front of the
* user, and `redirect` is preserved so that a successful second attempt still lands where the first
* one was going.
*/
function loginErrorUrl(redirect: string, code: string): string {
const params = new URLSearchParams({ error: code })
if (redirect && redirect !== '/') {
params.set('redirect', redirect)
}
return `/login?${params.toString()}`
}
/** /**
* Authentication API Routes * Authentication API Routes
@ -538,6 +574,179 @@ async function routes(app: FastifyInstance) {
} }
) )
/**
* START A REDIRECT LOGIN
*/
app.get<{
Params: { strategyId: string }
Querystring: { siteId?: string; redirect?: string }
}>(
'/auth/:strategyId/authorize',
{
config: {
publicAccess: true
},
schema: {
summary: 'Start a login at an identity provider',
description:
'Answers with a redirect to the provider, for a strategy whose module signs users in there rather than through a form — OpenID Connect, Google, GitHub. The `state`, `nonce` and PKCE verifier that tie the answer back to this browser are generated here and kept on the session; the browser is never trusted with any of them.\n\nOpened by following the link, not by fetching it: what comes back is a page at the provider.',
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
querystring: {
type: 'object',
properties: {
siteId: { type: 'string', format: 'uuid' },
redirect: {
type: 'string',
maxLength: 255,
description:
'Where to send the user once they are logged in. A path on this wiki; anything else is ignored.'
}
}
},
response: {
302: { description: 'Redirect to the identity provider', type: 'null' }
}
}
},
async (req, reply) => {
const strategy = await WIKI.models.authentication.getStrategyById(req.params.strategyId)
const instance = WIKI.auth.strategies[req.params.strategyId] as any
if (!strategy?.isEnabled || typeof instance?.authorizationUrl !== 'function') {
return reply.notFound('There is no such login provider.')
}
const siteId = req.query.siteId ?? WIKI.sitesMappings[req.hostname] ?? ''
const flow = {
strategyId: strategy.id,
siteId,
state: nanoid(32),
nonce: nanoid(32),
codeVerifier: nanoid(64),
// -> Only a path on this wiki: an open redirect is how a login page is turned into a lure
redirect: (req.query.redirect ?? '').startsWith('/') ? req.query.redirect! : '/',
startedAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' })
}
req.session.authFlow = flow
try {
const url = await instance.authorizationUrl({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier
})
WIKI.models.flags.authDebug(
`Redirecting to ${strategy.module} provider for strategy ${strategy.id} from ${req.ip}`
)
return reply.redirect(url)
} catch (err: any) {
WIKI.logger.warn(`Could not start a login at ${strategy.module}: ${err.message}`)
return reply.redirect(loginErrorUrl(flow.redirect, err.message))
}
}
)
/**
* FINISH A REDIRECT LOGIN
*/
app.get<{
Params: { strategyId: string }
Querystring: { code?: string; state?: string; error?: string; error_description?: string }
}>(
'/auth/:strategyId/callback',
{
config: {
publicAccess: true
},
// -> A callback is a password check by another name: whatever it carries decides who is logged in
onRequest: limitAuthAttempts,
schema: {
summary: 'Finish a login at an identity provider',
description:
"Where the provider sends the browser back. The answer is only accepted if it matches the flow this session started — same strategy, same `state`, and within the time a login takes — after which the module turns the code into an account and the session is established. Ends in a redirect either way: to where the login was heading, or to the login screen carrying an error code.\n\nThis is the URL an administrator registers with the provider; it is shown on the strategy's own page in the admin area.",
tags: ['Authentication'],
params: {
type: 'object',
properties: {
strategyId: { type: 'string', format: 'uuid' }
},
required: ['strategyId']
},
response: {
302: { description: 'Redirect back into the wiki', type: 'null' }
}
}
},
async (req, reply) => {
const flow = req.session.authFlow
const redirect = flow?.redirect ?? '/'
/*
Everything about the answer is checked against the flow this session started. A callback that
arrives with no flow behind it, for another strategy, with a different `state`, or long after
the login began is not this session's login and is refused without the code being spent.
*/
if (
!flow ||
flow.strategyId !== req.params.strategyId ||
!req.query.state ||
req.query.state !== flow.state ||
Temporal.Instant.compare(
Temporal.Instant.from(flow.startedAt).add({ minutes: AUTH_FLOW_MINUTES }),
Temporal.Now.instant()
) < 0
) {
WIKI.models.flags.authDebug(
`Callback for strategy ${req.params.strategyId} from ${req.ip} did not match this session's login`
)
req.session.authFlow = undefined
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_EXPIRED'))
}
// -> Spent, whatever happens next: one callback per login
req.session.authFlow = undefined
if (req.query.error) {
WIKI.models.flags.authDebug(
`Provider refused the login for strategy ${flow.strategyId}: ${req.query.error} ${req.query.error_description ?? ''}`
)
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
}
const strategy = await WIKI.models.authentication.getStrategyById(flow.strategyId)
const instance = WIKI.auth.strategies[flow.strategyId] as any
if (!strategy?.isEnabled || typeof instance?.profile !== 'function') {
return reply.redirect(loginErrorUrl(redirect, 'ERR_LOGIN_FAILED'))
}
try {
const profile = await instance.profile({
redirectUri: callbackUrl(req, strategy.id),
state: flow.state,
nonce: flow.nonce,
codeVerifier: flow.codeVerifier,
currentUrl: `${callbackUrl(req, strategy.id)}?${new URLSearchParams(req.query as Record<string, string>).toString()}`,
code: req.query.code
})
const result = await WIKI.models.users.loginWithProvider(
{ siteId: flow.siteId, strategy, profile, ip: req.ip },
req
)
return reply.redirect(result.redirect || redirect)
} catch (err: any) {
WIKI.models.flags.authDebug(
`Login through ${strategy.module} strategy ${strategy.id} failed: ${err.message}`
)
return reply.redirect(loginErrorUrl(redirect, err.message))
}
}
)
/** /**
* LOGOUT * LOGOUT
*/ */

@ -468,10 +468,15 @@ async function routes(app: FastifyInstance) {
if (!user) { if (!user) {
return reply.notFound('User does not exist.') return reply.notFound('User does not exist.')
} }
// -> The guest account is the only system user, and it must stay in the guests group alone: /*
// its permissions are what anonymous visitors get. The guests group and the guest account belong to each other and to nothing else the group is
if (user.isSystem) { what anonymous visitors hold, and the account is who they are. `guestMembershipViolation` is
return reply.conflict('Cannot assign a system user to a group.') the one definition of that, shared with `setUserGroups`, which is what the user editor and
provider enrolment go through.
*/
const violation = WIKI.models.groups.guestMembershipViolation(group.id, user)
if (violation) {
return reply.conflict(violation)
} }
const assigned = await WIKI.models.groups.assignUserToGroup(group.id, req.params.userId) const assigned = await WIKI.models.groups.assignUserToGroup(group.id, req.params.userId)
@ -531,7 +536,8 @@ async function routes(app: FastifyInstance) {
} }
// -> Removing the guest account from the guests group would strip anonymous visitors of the // -> Removing the guest account from the guests group would strip anonymous visitors of the
// permissions that group carries, with no way to put it back // permissions that group carries, with no way to put it back. `unassignUserFromGroup`
// refuses that pair as well; this answers it as a conflict rather than as a failure.
const user = await WIKI.models.users.getById(req.params.userId) const user = await WIKI.models.users.getById(req.params.userId)
if (user?.isSystem) { if (user?.isSystem) {
return reply.conflict('Cannot unassign a system user from a group.') return reply.conflict('Cannot unassign a system user from a group.')

@ -160,13 +160,14 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}, },
registration: { registration: {
type: 'boolean', type: 'boolean',
description: 'Stored but not enforced: self-registration is not implemented yet.' description:
'Whether an account is created for somebody signing in for the first time. Enforced for the providers that sign users in elsewhere (OpenID Connect, Google, GitHub); the local module has a registration flow of its own.'
}, },
allowedEmailRegex: { allowedEmailRegex: {
type: 'string', type: 'string',
maxLength: 255, maxLength: 255,
description: description:
'Must be a valid regular expression. Stored but not enforced, as it only applies to self-registration.' 'Must be a valid regular expression. Limits which addresses an account may be created for, and applies where registration does — a pattern that will not compile allows nobody.'
}, },
autoEnrollGroups: { autoEnrollGroups: {
type: 'array', type: 'array',

@ -1572,7 +1572,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Delete a user', summary: 'Delete a user',
description: description:
'System users cannot be deleted, nor can the last user of the root administrators group.', 'System users cannot be deleted, nor the account the caller is signed in as, nor the last user of the root administrators group. A user who has authored pages or assets cannot be deleted either — deactivate them, or reassign what they own.',
tags: ['Users'], tags: ['Users'],
params: { params: {
type: 'object', type: 'object',
@ -1596,10 +1596,20 @@ async function routes(app: FastifyInstance) {
if (!user) { if (!user) {
return reply.notFound('User does not exist.') return reply.notFound('User does not exist.')
} }
// -> The guest account is the only system user, and anonymous access is resolved through it
if (user.isSystem) { if (user.isSystem) {
return reply.conflict('Cannot delete a system user.') return reply.conflict('Cannot delete a system user.')
} }
/*
Not your own account, whatever permissions you hold: the request would end the session making
it, and an administrator who did it by accident has nothing left to undo it with. Another
administrator can which is also the answer to an account that has to go and cannot ask.
*/
if (user.id === sessionUserId(req)) {
return reply.conflict('You cannot delete your own account. Another administrator can.')
}
// -> Emptying the root administrators group would lock everyone out of system management // -> Emptying the root administrators group would lock everyone out of system management
const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId
if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) { if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) {

@ -116,9 +116,8 @@
"admin.approval.updateSuccess": "Rule updated successfully.", "admin.approval.updateSuccess": "Rule updated successfully.",
"admin.audit.title": "Audit Log", "admin.audit.title": "Audit Log",
"admin.auth.activeStrategies": "Active Strategies", "admin.auth.activeStrategies": "Active Strategies",
"admin.auth.addFailed": "Failed to add the strategy.", "admin.auth.addPending": "{strategy} added. It is created when you press Apply.",
"admin.auth.addStrategy": "Add Strategy", "admin.auth.addStrategy": "Add Strategy",
"admin.auth.addSuccess": "{strategy} has been added.",
"admin.auth.allowedEmailRegex": "Allowed Email Address Regex", "admin.auth.allowedEmailRegex": "Allowed Email Address Regex",
"admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.", "admin.auth.allowedEmailRegexHint": "(optional) Only allow users to register with an email address that matches the regex expression.",
"admin.auth.allowedWebOrigins": "Allowed Web Origins", "admin.auth.allowedWebOrigins": "Allowed Web Origins",
@ -150,6 +149,7 @@
"admin.auth.logoutUrl": "Logout URL", "admin.auth.logoutUrl": "Logout URL",
"admin.auth.noConfigOption": "This strategy has no configuration options you can modify.", "admin.auth.noConfigOption": "This strategy has no configuration options you can modify.",
"admin.auth.noModulesToAdd": "No other authentication module is installed on this server.", "admin.auth.noModulesToAdd": "No other authentication module is installed on this server.",
"admin.auth.refAfterSave": "Available once this strategy has been saved — it carries the ID the server assigns.",
"admin.auth.refreshSuccess": "List of strategies has been refreshed.", "admin.auth.refreshSuccess": "List of strategies has been refreshed.",
"admin.auth.registration": "Registration", "admin.auth.registration": "Registration",
"admin.auth.registrationHint": "Allow any user successfully authorized by the strategy to access the wiki.", "admin.auth.registrationHint": "Allow any user successfully authorized by the strategy to access the wiki.",
@ -171,6 +171,7 @@
"admin.auth.strategyStateLocked": "and cannot be disabled.", "admin.auth.strategyStateLocked": "and cannot be disabled.",
"admin.auth.subtitle": "Configure the authentication settings of your wiki", "admin.auth.subtitle": "Configure the authentication settings of your wiki",
"admin.auth.title": "Authentication", "admin.auth.title": "Authentication",
"admin.auth.unsaved": "Not saved",
"admin.auth.vendor": "Vendor", "admin.auth.vendor": "Vendor",
"admin.auth.vendorWebsite": "Website", "admin.auth.vendorWebsite": "Website",
"admin.blocks.add": "Add Block", "admin.blocks.add": "Add Block",
@ -708,7 +709,7 @@
"admin.security.corsHostnames": "Hostnames Whitelist", "admin.security.corsHostnames": "Hostnames Whitelist",
"admin.security.corsHostnamesHint": "Enter one hostname per line", "admin.security.corsHostnamesHint": "Enter one hostname per line",
"admin.security.corsMode": "CORS Mode", "admin.security.corsMode": "CORS Mode",
"admin.security.corsModeHint": "How the GraphQL server should handle preflight requests?", "admin.security.corsModeHint": "How the API server should handle preflight requests?",
"admin.security.corsRegex": "Regex Pattern", "admin.security.corsRegex": "Regex Pattern",
"admin.security.corsRegexHint": "Pattern against which the request hostname is matched.", "admin.security.corsRegexHint": "Pattern against which the request hostname is matched.",
"admin.security.disallowFloc": "Disallow Google FLoC", "admin.security.disallowFloc": "Disallow Google FLoC",
@ -1100,6 +1101,8 @@
"admin.users.deleteConfirmText": "Are you sure you want to delete user {username}?", "admin.users.deleteConfirmText": "Are you sure you want to delete user {username}?",
"admin.users.deleteConfirmTitle": "Delete User?", "admin.users.deleteConfirmTitle": "Delete User?",
"admin.users.deleteHint": "Permanently remove the user from the database. This action cannot be undone!", "admin.users.deleteHint": "Permanently remove the user from the database. This action cannot be undone!",
"admin.users.deleteSelfForbidden": "You cannot delete your own account. Another administrator can.",
"admin.users.deleteSuccess": "{username} has been deleted.",
"admin.users.displayName": "Display Name", "admin.users.displayName": "Display Name",
"admin.users.edit": "Edit User", "admin.users.edit": "Edit User",
"admin.users.email": "Email", "admin.users.email": "Email",
@ -1301,6 +1304,7 @@
"admin.webhooks.urlInvalidChars": "The URL contains invalid characters.", "admin.webhooks.urlInvalidChars": "The URL contains invalid characters.",
"admin.webhooks.urlMissing": "The URL is missing or is not valid.", "admin.webhooks.urlMissing": "The URL is missing or is not valid.",
"auth.actions.login": "Log In", "auth.actions.login": "Log In",
"auth.actions.loginWith": "Continue with {provider}",
"auth.actions.register": "Register", "auth.actions.register": "Register",
"auth.changePwd.currentPassword": "Current Password", "auth.changePwd.currentPassword": "Current Password",
"auth.changePwd.instructions": "You must choose a new password:", "auth.changePwd.instructions": "You must choose a new password:",
@ -1883,15 +1887,23 @@
"editor.unsaved.title": "Discard Unsaved Changes?", "editor.unsaved.title": "Discard Unsaved Changes?",
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?", "editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
"error.ERR_CHANGE_PASSWORD_FAILED": "The password could not be changed.", "error.ERR_CHANGE_PASSWORD_FAILED": "The password could not be changed.",
"error.ERR_EMAIL_NOT_ALLOWED": "That email address is not allowed to sign in through this provider.",
"error.ERR_EMAIL_NOT_VERIFIED": "The provider has not verified that email address.",
"error.ERR_EXPIRED_VALIDATION_TOKEN": "This request has expired. Please start over.", "error.ERR_EXPIRED_VALIDATION_TOKEN": "This request has expired. Please start over.",
"error.ERR_INACTIVE_USER": "This account is deactivated.", "error.ERR_INACTIVE_USER": "This account is deactivated.",
"error.ERR_INCORRECT_CURRENT_PASSWORD": "The current password is incorrect.", "error.ERR_INCORRECT_CURRENT_PASSWORD": "The current password is incorrect.",
"error.ERR_INVALID_STRATEGY": "This authentication method cannot be used here.", "error.ERR_INVALID_STRATEGY": "This authentication method cannot be used here.",
"error.ERR_INVALID_USER": "This account no longer exists.", "error.ERR_INVALID_USER": "This account no longer exists.",
"error.ERR_INVALID_VALIDATION_TOKEN": "This request is no longer valid. Please start over.", "error.ERR_INVALID_VALIDATION_TOKEN": "This request is no longer valid. Please start over.",
"error.ERR_LOGIN_EXPIRED": "That sign-in took too long or was started somewhere else. Please try again.",
"error.ERR_LOGIN_FAILED": "The email or password is invalid.", "error.ERR_LOGIN_FAILED": "The email or password is invalid.",
"error.ERR_LOGIN_RESTRICTED": "Password login is turned off for this account.", "error.ERR_LOGIN_RESTRICTED": "Password login is turned off for this account.",
"error.ERR_NO_AUTHORIZATION_CODE": "The provider did not return an authorization code.",
"error.ERR_NO_EMAIL_FROM_PROVIDER": "The provider did not give an email address to identify you by.",
"error.ERR_NO_ID_TOKEN": "The provider did not return an identity token.",
"error.ERR_NO_OTHER_LOGIN_METHOD": "Password login cannot be turned off: it is the only way to login to this account.", "error.ERR_NO_OTHER_LOGIN_METHOD": "Password login cannot be turned off: it is the only way to login to this account.",
"error.ERR_NO_PROVIDER_ACCOUNT": "The provider did not identify an account.",
"error.ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER": "The provider did not give a verified email address to identify you by.",
"error.ERR_PASSKEY_NOT_SETUP": "No passkey registration is in progress. Please start over.", "error.ERR_PASSKEY_NOT_SETUP": "No passkey registration is in progress. Please start over.",
"error.ERR_PASSWORD_LOGIN_NOT_APPLICABLE": "This authentication method does not use a password stored here.", "error.ERR_PASSWORD_LOGIN_NOT_APPLICABLE": "This authentication method does not use a password stored here.",
"error.ERR_PASSWORD_TOO_SHORT": "The password must be at least 8 characters long.", "error.ERR_PASSWORD_TOO_SHORT": "The password must be at least 8 characters long.",
@ -1901,12 +1913,16 @@
"error.ERR_PK_NAME_MISSING_OR_INVALID": "Passkey name is missing or invalid.", "error.ERR_PK_NAME_MISSING_OR_INVALID": "Passkey name is missing or invalid.",
"error.ERR_PK_USER_CANCELLED": "Passkey registration aborted. Make sure to remove the key from your device.", "error.ERR_PK_USER_CANCELLED": "Passkey registration aborted. Make sure to remove the key from your device.",
"error.ERR_PK_VERIFICATION_FAILED": "This passkey could not be verified.", "error.ERR_PK_VERIFICATION_FAILED": "This passkey could not be verified.",
"error.ERR_PROVIDER_REQUEST_FAILED": "The provider could not be reached. Please try again.",
"error.ERR_REGISTRATION_DISABLED": "This provider does not create new accounts. Ask an administrator to invite you first.",
"error.ERR_STRATEGY_MISCONFIGURED": "This provider is not fully configured. An administrator needs to finish setting it up.",
"error.ERR_TFA_ALREADY_ACTIVE": "2FA is already enabled on this account. Turn it off before setting it up again.", "error.ERR_TFA_ALREADY_ACTIVE": "2FA is already enabled on this account. Turn it off before setting it up again.",
"error.ERR_TFA_ENFORCED": "2FA cannot be turned off, as it is required on this account.", "error.ERR_TFA_ENFORCED": "2FA cannot be turned off, as it is required on this account.",
"error.ERR_TFA_FAILED": "The security code could not be verified.", "error.ERR_TFA_FAILED": "The security code could not be verified.",
"error.ERR_TFA_INCORRECT_TOKEN": "This security code is incorrect.", "error.ERR_TFA_INCORRECT_TOKEN": "This security code is incorrect.",
"error.ERR_TFA_INVALID_REQUEST": "Missing or incomplete security code.", "error.ERR_TFA_INVALID_REQUEST": "Missing or incomplete security code.",
"error.ERR_TFA_NOT_ACTIVE": "2FA is not enabled on this account.", "error.ERR_TFA_NOT_ACTIVE": "2FA is not enabled on this account.",
"error.ERR_TOKEN_EXCHANGE_FAILED": "The provider refused to complete the sign-in.",
"error.ERR_USER_NOT_VERIFIED": "This account has not been verified yet.", "error.ERR_USER_NOT_VERIFIED": "This account has not been verified yet.",
"fileman.7zFileType": "7zip Archive", "fileman.7zFileType": "7zip Archive",
"fileman.aacFileType": "AAC Audio File", "fileman.aacFileType": "AAC Audio File",

@ -24,6 +24,44 @@ export interface AuthModule {
refs?: Record<string, { title?: string; hint?: string; icon?: string; value: string }> refs?: Record<string, { title?: string; hint?: string; icon?: string; value: string }>
} }
/**
* What a redirect-based module is handed to build its authorization URL.
*
* The framework owns these values rather than each module inventing them: they are generated once per
* login, kept on the session, and checked when the provider comes back which is what makes the
* answer belong to the browser that started the flow. A module that has no use for `nonce` or
* `codeVerifier` (a plain OAuth2 provider) simply ignores them.
*/
export interface AuthFlow {
/** Where the provider sends the browser back. Registered with the provider by the administrator. */
redirectUri: string
state: string
nonce: string
/** PKCE verifier, whose challenge goes on the authorization request. */
codeVerifier: string
}
/** The same flow, once the provider has come back with an answer. */
export interface AuthFlowCallback extends AuthFlow {
/** The callback URL as it arrived, query string included — what an OIDC library validates against. */
currentUrl: string
/** The authorization code, for a module that reads it directly rather than through a library. */
code?: string
}
/**
* Who signed in, as a module reports them.
*
* `id` is the provider's own identifier for the account and never changes; `email` is what the user is
* matched or created by here. A module must not return an address it has not established belongs to
* the person an unverified one is how somebody signs in as somebody else.
*/
export interface ProviderProfile {
id: string
email: string
name: string
}
/** A configured instance of an authentication module. */ /** A configured instance of an authentication module. */
export interface AuthStrategy { export interface AuthStrategy {
id: string id: string

@ -1,6 +1,7 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import { and, count, eq, ilike, or, sql } from 'drizzle-orm' import { and, count, eq, ilike, or, sql } from 'drizzle-orm'
import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts' import { groups as groupsTable, userGroups, users as usersTable } from '../db/schema.ts'
import { CustomError } from '../helpers/common.ts'
import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts' import { resolvePageRule, type RulePageRef } from '../helpers/pageRules.ts'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
import type { FastifyRequest } from 'fastify' import type { FastifyRequest } from 'fastify'
@ -107,6 +108,25 @@ export interface AccessActor {
permissions: string[] permissions: string[]
} }
/**
* The page permissions a rule on the GUESTS group may grant.
*
* Reading, and saying something in a comment. Everything else writing or deleting a page, managing
* assets or comments, reviewing suggestions is an action attributable to somebody, and the guests
* group is precisely the absence of a somebody.
*
* Mirrored in `GroupEditOverlay.vue`, which offers exactly these when the guests group is open. This
* copy is the one that decides.
*/
export const GUEST_ROLES = [
'read:pages',
'read:source',
'read:history',
'read:assets',
'read:comments',
'write:comments'
]
/** /**
* Every group's rules, by group id. * Every group's rules, by group id.
* *
@ -308,12 +328,43 @@ class Groups {
async updateGroup(id: string, patch: GroupPatch): Promise<boolean> { async updateGroup(id: string, patch: GroupPatch): Promise<boolean> {
const result = await WIKI.db const result = await WIKI.db
.update(groupsTable) .update(groupsTable)
.set({ ...patch, updatedAt: sql`now()` }) .set({ ...this.clampGuestPatch(id, patch), updatedAt: sql`now()` })
.where(eq(groupsTable.id, id)) .where(eq(groupsTable.id, id))
await this.reloadCache() await this.reloadCache()
return (result.rowCount ?? 0) > 0 return (result.rowCount ?? 0) > 0
} }
/**
* Hold the guests group to what the public may be given.
*
* The guests group is every anonymous reader at once, so a rule on it is a rule about the open
* internet: writing a page, deleting one, reading its source history none of those are things to
* hand out to nobody in particular, and several of them cannot be undone. So the set is fixed here,
* beside the rules themselves, rather than only in the admin screen that edits them: what a group
* may hold is not something a browser should be the only one deciding.
*
* Roles outside the set are dropped rather than refused. An administrator saving a group edited
* before this existed or through the API gets the group they asked for minus what may not be
* granted, instead of a form that cannot be saved and does not say which rule is at fault.
*/
private clampGuestPatch(id: string, patch: GroupPatch): GroupPatch {
if (id !== WIKI.data.systemIds.guestsGroupId || !patch.rules) {
return patch
}
let dropped = 0
const rules = patch.rules.map((rule) => {
const roles = (rule.roles ?? []).filter((role) => GUEST_ROLES.includes(role))
dropped += (rule.roles ?? []).length - roles.length
return { ...rule, roles }
})
if (dropped > 0) {
WIKI.logger.warn(
`Dropped ${dropped} permission(s) from the guests group that may not be granted to it.`
)
}
return { ...patch, rules }
}
/** /**
* Delete a group. Assignments in `userGroups` are removed by the FK cascade. * Delete a group. Assignments in `userGroups` are removed by the FK cascade.
* *
@ -331,7 +382,44 @@ class Groups {
* *
* @returns False if the user was already a member * @returns False if the user was already a member
*/ */
/**
* Why this user may not be a member of this group, if they may not.
*
* The guests group and the guest account belong to each other and to nothing else:
*
* - the group IS anonymous access, so a real user in it would be granted whatever the public is
* granted regardless of their own groups, and would keep it after every other group was taken
* away from them;
* - the account IS the anonymous visitor, so putting it in another group hands that group's
* permissions to everybody who never logged in.
*
* The pair is also why neither half can be taken apart: removing the account from the group would
* leave anonymous access resolving against nothing, with no way back through the interface.
*
* One definition, used by the routes that assign a single membership and by `setUserGroups`, which
* sets them all at once.
*
* @returns The reason, or null when the membership is fine
*/
guestMembershipViolation(groupId: string, user: { isSystem?: boolean } | null): string | null {
const isGuestsGroup = groupId === WIKI.data.systemIds.guestsGroupId
// -> The guest account is the only system user; see the seeding in `models/users.ts`
if (user?.isSystem) {
return isGuestsGroup
? null
: 'The guest account cannot be a member of any group other than the guests group.'
}
return isGuestsGroup
? 'The guests group holds the guest account and nothing else — it is what anonymous visitors are.'
: null
}
async assignUserToGroup(groupId: string, userId: string): Promise<boolean> { async assignUserToGroup(groupId: string, userId: string): Promise<boolean> {
const user = await WIKI.models.users.getById(userId)
const violation = this.guestMembershipViolation(groupId, user)
if (violation) {
throw new CustomError('groupMembershipForbidden', violation)
}
const result = await WIKI.db const result = await WIKI.db
.insert(userGroups) .insert(userGroups)
.values({ userId, groupId }) .values({ userId, groupId })
@ -345,6 +433,20 @@ class Groups {
* @returns False if the user was not a member * @returns False if the user was not a member
*/ */
async unassignUserFromGroup(groupId: string, userId: string): Promise<boolean> { async unassignUserFromGroup(groupId: string, userId: string): Promise<boolean> {
/*
The one membership that cannot be taken apart: anonymous access resolves against the guests
group's rules, and the guest account is what resolves it. Removed, every anonymous visitor would
hold nothing at all and nothing in the interface puts a system user back into a group.
*/
if (groupId === WIKI.data.systemIds.guestsGroupId) {
const user = await WIKI.models.users.getById(userId)
if (user?.isSystem) {
throw new CustomError(
'groupMembershipForbidden',
'The guest account cannot be removed from the guests group.'
)
}
}
const result = await WIKI.db const result = await WIKI.db
.delete(userGroups) .delete(userGroups)
.where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId))) .where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId)))

@ -14,6 +14,7 @@ import { nanoid } from 'nanoid'
import { flatten, uniq } from 'es-toolkit/array' import { flatten, uniq } from 'es-toolkit/array'
import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts' import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts'
import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts' import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts'
import type { AuthStrategy, ProviderProfile } from './authentication.ts'
import type { SystemIds } from './types.ts' import type { SystemIds } from './types.ts'
/** The essential user fields, mirroring the `UserCore` API schema. */ /** The essential user fields, mirroring the `UserCore` API schema. */
@ -636,15 +637,37 @@ class Users {
* Replace a user's group membership with exactly the given groups. * Replace a user's group membership with exactly the given groups.
* *
* Unknown group IDs are ignored rather than failing the whole update, so that a stale client does * Unknown group IDs are ignored rather than failing the whole update, so that a stale client does
* not block an otherwise valid save. * not block an otherwise valid save. So is a membership that may not exist see
* `groups.guestMembershipViolation`: this is the one call that sets every group at once, and it is
* reached from creating a user, editing one, and enrolling one that an identity provider has just
* sent. Dropping what may not be granted keeps all three honest without any of them having to know
* about the guests group.
*/ */
async setUserGroups(userId: string, groupIds: string[]): Promise<void> { async setUserGroups(userId: string, groupIds: string[]): Promise<void> {
const user = await this.getById(userId)
const allowed = groupIds.filter(
(groupId) => !WIKI.models.groups.guestMembershipViolation(groupId, user)
)
if (allowed.length !== groupIds.length) {
WIKI.logger.warn(
`Dropped ${groupIds.length - allowed.length} group assignment(s) for user ${userId} that may not be granted.`
)
}
/*
The guest account keeps the membership it was seeded with whatever was asked for: it is the one
user whose groups are not an administrator's to set, and an empty list would otherwise leave
anonymous access resolving against no rules at all.
*/
if (user?.isSystem) {
return
}
const wanted = const wanted =
groupIds.length > 0 allowed.length > 0
? await WIKI.db ? await WIKI.db
.select({ id: groupsTable.id }) .select({ id: groupsTable.id })
.from(groupsTable) .from(groupsTable)
.where(inArray(groupsTable.id, groupIds)) .where(inArray(groupsTable.id, allowed))
: [] : []
const wantedIds = wanted.map((g: any) => g.id) const wantedIds = wanted.map((g: any) => g.id)
@ -1100,6 +1123,113 @@ class Users {
} }
} }
/**
* Log somebody in from what an identity provider said about them, creating the account if the
* strategy is set to accept new users.
*
* The email address is the identity: a provider's own `id` is recorded so that an address changing
* upstream does not orphan the account, but matching starts with the address because that is what
* an administrator invited, what a group rule was written against, and what every other strategy
* keys on. A module must therefore only ever report an address it has established belongs to the
* person see `ProviderProfile`.
*
* Registration is refused rather than silently allowed: a wiki that has not opened its doors to a
* provider gets `ERR_REGISTRATION_DISABLED` for an unknown account, and one that has can still
* limit who by, with the strategy's email allow-list pattern.
*
* @throws `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`, `ERR_INACTIVE_USER`
*/
async loginWithProvider(
{
siteId,
strategy,
profile,
ip
}: {
siteId: string
strategy: AuthStrategy
profile: ProviderProfile
ip?: string
},
req: any
): Promise<AfterLoginResult> {
const email = profile.email.toLowerCase().trim()
let user = await this.getByEmail(email)
if (!user) {
if (!strategy.registration) {
WIKI.models.flags.authDebug(
`Provider login for unknown address <${email}> refused: strategy ${strategy.id} does not accept new users`
)
throw new Error('ERR_REGISTRATION_DISABLED')
}
if (strategy.allowedEmailRegex) {
let allowed = false
try {
allowed = new RegExp(strategy.allowedEmailRegex).test(email)
} catch (err: any) {
// -> A pattern that will not compile allows nobody, rather than everybody
WIKI.logger.warn(
`Strategy ${strategy.id} has an invalid email pattern, refusing: ${err.message}`
)
}
if (!allowed) {
throw new Error('ERR_EMAIL_NOT_ALLOWED')
}
}
const userId = await this.createUser({
name: profile.name || email,
email,
// -> Nothing signs in with it: this account authenticates at the provider, and the local
// strategy's own entry is what a password would live under
password: nanoid(32),
groups: strategy.autoEnrollGroups ?? [],
isVerified: true
})
user = await this.getById(userId)
WIKI.models.flags.authDebug(
`Created user ${userId} <${email}> from ${strategy.module} strategy ${strategy.id}`
)
}
if (!user) {
throw new Error('ERR_LOGIN_FAILED')
}
if (!user.isActive) {
throw new Error('ERR_INACTIVE_USER')
}
/*
The link between this account and the provider's, written on every login: it records which
account at the provider this is, and it is what tells the profile page that this user signs in
through this strategy.
*/
const auth = (user.auth ?? {}) as Record<string, any>
auth[strategy.id] = {
...auth[strategy.id],
id: profile.id,
email
}
user.auth = auth
await WIKI.db
.update(usersTable)
.set({ auth, updatedAt: sql`now()` })
.where(eq(usersTable.id, user.id))
/*
Neither 2FA nor a password change is asked for: both are the local strategy's, and this user has
just proved who they are somewhere else where whatever second factor that provider enforces has
already been satisfied.
*/
return this.afterLoginChecks(
user,
strategy.id,
{ ip, siteId },
{ skipTFA: true, skipChangePwd: true },
req
)
}
async afterLoginChecks( async afterLoginChecks(
user: any, user: any,
strategyId: string, strategyId: string,

@ -0,0 +1,137 @@
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
/**
* GitHub
*
* GitHub speaks OAuth 2.0 and not OpenID Connect: there is no ID token, and therefore nothing to
* verify signatures on the access token is exchanged over TLS and then spent against the API, which
* answers who it belongs to. That is the whole protocol here, so this module is written with `fetch`
* and no dependency. The parts a library would otherwise be trusted with `state`, and keeping the
* client secret off the browser are done by the flow around it (`api/authentication.ts`).
*
* Two GitHub-specific things are worth the code:
*
* - the address comes from `/user/emails` rather than `/user`, because a profile's public email is
* often empty and always unverified. Only a verified primary address is accepted;
* - an organization can be required, checked against the membership API with the user's own token.
*/
export default class GitHubAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
/** Where a user signs in, and where the API lives — the two differ on Enterprise Server. */
private get hosts(): { web: string; api: string } {
const enterprise = (this.conf.enterpriseHost || '').trim().replace(/^https?:\/\//, '')
return enterprise
? { web: `https://${enterprise}`, api: `https://${enterprise}/api/v3` }
: { web: 'https://github.com', api: 'https://api.github.com' }
}
/** A GitHub API call as this user, with the headers GitHub asks every client to send. */
private async api(path: string, accessToken: string): Promise<any> {
const resp = await fetch(`${this.hosts.api}${path}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Wiki.js'
}
})
if (!resp.ok) {
throw new Error(`ERR_PROVIDER_REQUEST_FAILED`)
}
return resp.json()
}
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<string> {
if (!this.conf.clientId || !this.conf.clientSecret) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
const url = new URL(`${this.hosts.web}/login/oauth/authorize`)
url.searchParams.set('client_id', this.conf.clientId)
url.searchParams.set('redirect_uri', redirectUri)
/*
`user:email` is what makes the verified addresses readable; `read:org` is only asked for when an
organization is being enforced, since a scope nobody needs is a scope nobody should be granting.
*/
url.searchParams.set(
'scope',
this.conf.allowedOrganization ? 'read:user user:email read:org' : 'read:user user:email'
)
url.searchParams.set('state', state)
return url.toString()
}
async profile({ code, redirectUri }: AuthFlowCallback): Promise<ProviderProfile> {
if (!code) {
throw new Error('ERR_NO_AUTHORIZATION_CODE')
}
// -> `Accept: application/json`, or GitHub answers this one in form encoding
const tokenResp = await fetch(`${this.hosts.web}/login/oauth/access_token`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'Wiki.js'
},
body: JSON.stringify({
client_id: this.conf.clientId,
client_secret: this.conf.clientSecret,
redirect_uri: redirectUri,
code
})
})
const token = (await tokenResp.json()) as Record<string, any>
// -> GitHub reports a refused exchange as 200 with an `error` field, not as a status
if (!tokenResp.ok || token.error || !token.access_token) {
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
}
const account = await this.api('/user', token.access_token)
if (!account?.id) {
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
}
/*
The primary verified address, which is the only one that says anything: `account.email` is
whatever the profile shows publicly, is frequently null, and is never checked by GitHub.
*/
const emails: any[] = await this.api('/user/emails', token.access_token)
const email = emails?.find((entry) => entry.primary && entry.verified)?.email
if (!email) {
throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER')
}
if (this.conf.allowedOrganization) {
const org = this.conf.allowedOrganization.trim()
const resp = await fetch(
`${this.hosts.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(account.login)}`,
{
headers: {
Authorization: `Bearer ${token.access_token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Wiki.js'
}
}
)
// -> 204 is a member, 302 is "ask as somebody who can see", 404 is not a member
if (resp.status !== 204) {
throw new Error('ERR_LOGIN_RESTRICTED')
}
}
return {
id: String(account.id),
email,
name: account.name || account.login
}
}
}

@ -0,0 +1,44 @@
key: github
title: GitHub
description: Sign in with a GitHub account, on github.com or a GitHub Enterprise Server.
author: requarks.io
logo: https://static.requarks.io/logo/github.svg
icon: /_assets/icons/ultraviolet-github.svg
color: dark-4
vendor: 'GitHub, Inc.'
website: 'https://docs.github.com/en/apps/oauth-apps'
isAvailable: true
useForm: false
usernameType: email
props:
clientId:
type: String
title: Client ID
hint: From the OAuth app registered under Developer settings.
icon: key
order: 1
clientSecret:
type: String
title: Client Secret
hint: From the same OAuth app.
icon: password
sensitive: true
order: 2
enterpriseHost:
type: String
title: GitHub Enterprise Host
hint: (optional) Hostname of a GitHub Enterprise Server, e.g. github.example.com. Leave empty for github.com.
icon: server
order: 3
allowedOrganization:
type: String
title: Restrict to Organization
hint: (optional) Login name of a GitHub organization. Only its members may sign in — which needs the account to be a public member, or the OAuth app to be approved by the organization.
icon: user-groups
order: 4
refs:
callbackUrl:
title: Authorization Callback URL
hint: Set this as the OAuth app's callback URL on GitHub.
icon: back
value: '{host}/_api/auth/{id}/callback'

@ -0,0 +1,99 @@
import * as client from 'openid-client'
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
/** Google's issuer, from which every endpoint and signing key is discovered. */
const ISSUER = 'https://accounts.google.com'
/**
* Google
*
* Google is an OpenID Connect provider, so this is the generic flow with the issuer fixed and two
* things Google specifically needs saying about:
*
* - a Workspace domain can be required, and the claim is checked HERE as well as asked for `hd`
* on the authorization request is a hint to the account chooser, not a promise about the answer;
* - `email_verified` is honoured, because an account on this wiki is matched by email address and
* an unverified one says nothing about who holds the mailbox.
*
* Written against `openid-client` rather than by hand for the reason the generic module is: the ID
* token has to be verified, and a token nobody verified still logs somebody in.
*/
export default class GoogleAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
private config: client.Configuration | null = null
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
private async configuration(): Promise<client.Configuration> {
if (this.config) {
return this.config
}
if (!this.conf.clientId || !this.conf.clientSecret) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
this.config = await client.discovery(
new URL(ISSUER),
this.conf.clientId,
this.conf.clientSecret
)
return this.config
}
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
const config = await this.configuration()
return client
.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: 'openid profile email',
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
// -> Which accounts the chooser offers. The answer is still checked below.
...(this.conf.hostedDomain ? { hd: this.conf.hostedDomain } : {})
})
.toString()
}
async profile({
currentUrl,
state,
nonce,
codeVerifier
}: AuthFlowCallback): Promise<ProviderProfile> {
const config = await this.configuration()
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
expectedState: state,
expectedNonce: nonce,
pkceCodeVerifier: codeVerifier
})
const claims = tokens.claims() as Record<string, any> | undefined
if (!claims?.sub) {
throw new Error('ERR_NO_ID_TOKEN')
}
const email = claims.email
if (!email || typeof email !== 'string') {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
if (claims.email_verified === false && this.conf.allowUnverifiedEmail !== true) {
throw new Error('ERR_EMAIL_NOT_VERIFIED')
}
if (this.conf.hostedDomain && claims.hd !== this.conf.hostedDomain) {
throw new Error('ERR_LOGIN_RESTRICTED')
}
return {
id: claims.sub,
email,
name: (claims.name as string) || email
}
}
}

@ -0,0 +1,45 @@
key: google
title: Google
description: Sign in with a Google account or a Google Workspace domain.
author: requarks.io
logo: https://static.requarks.io/logo/google.svg
icon: /_assets/icons/ultraviolet-google.svg
color: red-6
vendor: 'Google LLC'
website: 'https://developers.google.com/identity/openid-connect/openid-connect'
isAvailable: true
useForm: false
usernameType: email
props:
clientId:
type: String
title: Client ID
hint: From the OAuth 2.0 Client ID created in the Google Cloud console.
icon: key
order: 1
clientSecret:
type: String
title: Client Secret
hint: From the same OAuth 2.0 Client ID.
icon: password
sensitive: true
order: 2
hostedDomain:
type: String
title: Restrict to Workspace Domain
hint: (optional) A Workspace domain, e.g. example.com. Only accounts on it may sign in — checked here as well as asked for, since the parameter alone is a hint to Google rather than a guarantee.
icon: geography
order: 3
allowUnverifiedEmail:
type: Boolean
title: Accept Unverified Addresses
hint: Off by default. A Google account whose address is unverified proves nothing about the mailbox, and an account here is matched on the address.
icon: received
default: false
order: 4
refs:
callbackUrl:
title: Authorized Redirect URI
hint: Add this to the OAuth client's authorized redirect URIs in the Google Cloud console.
icon: back
value: '{host}/_api/auth/{id}/callback'

@ -0,0 +1,137 @@
import * as client from 'openid-client'
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
/**
* Generic OpenID Connect / OAuth2
*
* The authorization code flow with PKCE, against any provider that speaks OpenID Connect. What makes
* it OIDC rather than bare OAuth2 is the ID token: a signed statement of who signed in, which is
* verified here against the provider's published keys issuer, audience, nonce and signature before
* anything is believed about the person behind it.
*
* That verification is why this goes through `openid-client` rather than a handful of `fetch` calls.
* The requests themselves are trivial; the checks around them are where a mistake is silent, because
* a token that is never verified still logs somebody in.
*/
export default class OidcAuthentication {
strategyId: string
conf: Record<string, any>
/** Set by `models/authentication.ts` right after construction. */
module?: string
/**
* The provider as `openid-client` sees it. Built once and kept: with discovery on it is a network
* round trip, and it is the same answer for every login until the strategy is saved again.
*/
private config: client.Configuration | null = null
constructor(strategyId: string, conf: Record<string, any>) {
this.strategyId = strategyId
this.conf = conf
}
/**
* Resolve the provider's metadata.
*
* Discovery is the path worth taking: the endpoints AND the signing keys come from the issuer
* itself, so a provider rotating either is followed without an administrator editing anything. The
* manual path exists for providers that publish no discovery document, and needs the JWKS URL for
* the same reason without keys there is nothing to check the ID token against.
*/
private async configuration(): Promise<client.Configuration> {
if (this.config) {
return this.config
}
const { clientId, clientSecret, issuer } = this.conf
if (!clientId || !clientSecret || !issuer) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
if (this.conf.useDiscovery !== false) {
this.config = await client.discovery(new URL(issuer), clientId, clientSecret)
} else {
if (!this.conf.authorizationURL || !this.conf.tokenURL || !this.conf.jwksURL) {
throw new Error('ERR_STRATEGY_MISCONFIGURED')
}
this.config = new client.Configuration(
{
issuer,
authorization_endpoint: this.conf.authorizationURL,
token_endpoint: this.conf.tokenURL,
userinfo_endpoint: this.conf.userInfoURL || undefined,
jwks_uri: this.conf.jwksURL
},
clientId,
clientSecret
)
}
return this.config
}
/** Where to send the browser to sign in. */
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
const config = await this.configuration()
return client
.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: this.conf.scopes || 'openid profile email',
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256'
})
.toString()
}
/**
* Turn the code the provider sent back into who signed in.
*
* `authorizationCodeGrant` is what does the checking: it refuses a response whose state does not
* match the one this flow started with, exchanges the code with the PKCE verifier, and validates
* the ID token's signature, issuer, audience and nonce. Everything after it is reading claims.
*/
async profile({
currentUrl,
state,
nonce,
codeVerifier
}: AuthFlowCallback): Promise<ProviderProfile> {
const config = await this.configuration()
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
expectedState: state,
expectedNonce: nonce,
pkceCodeVerifier: codeVerifier
})
const claims = tokens.claims()
if (!claims?.sub) {
throw new Error('ERR_NO_ID_TOKEN')
}
/*
The userinfo endpoint is consulted when the provider has one, because a provider is free to keep
claims out of the ID token and behind it several put the email address there only. Its answer
is merged over the token's, and `fetchUserInfo` checks that it is about the same subject.
*/
let info: Record<string, any> = claims
if (config.serverMetadata().userinfo_endpoint) {
info = {
...claims,
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
}
}
const email = info[this.conf.emailClaim || 'email']
if (!email || typeof email !== 'string') {
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
}
return {
id: claims.sub,
email,
name: (info[this.conf.displayNameClaim || 'name'] as string) || email
}
}
/** Where a logout should continue, so that the session at the provider ends too. */
logoutUrl(): string | null {
return this.conf.logoutURL || null
}
}

@ -0,0 +1,96 @@
key: oidc
title: Generic OpenID Connect / OAuth2
description: OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol.
author: requarks.io
logo: https://static.requarks.io/logo/oidc.svg
icon: /_assets/icons/ultraviolet-openid.svg
color: blue-grey-8
vendor: 'OpenID Foundation'
website: 'https://openid.net/connect/'
isAvailable: true
useForm: false
usernameType: email
props:
clientId:
type: String
title: Client ID
hint: Application Client ID, as the provider issued it.
icon: key
order: 1
clientSecret:
type: String
title: Client Secret
hint: Application Client Secret, as the provider issued it.
icon: password
sensitive: true
order: 2
issuer:
type: String
title: Issuer
hint: The provider's issuer URL, e.g. https://id.example.com. Everything else is discovered from it.
icon: internet
order: 3
useDiscovery:
type: Boolean
title: Use Discovery
hint: Read the endpoints and signing keys from the issuer's /.well-known/openid-configuration. Turn off only for a provider that does not publish one, and fill in the endpoints below.
icon: rescan-document
default: true
order: 4
authorizationURL:
type: String
title: Authorization Endpoint URL
hint: Ignored while discovery is on.
icon: enter
order: 5
tokenURL:
type: String
title: Token Endpoint URL
hint: Ignored while discovery is on.
icon: exit
order: 6
userInfoURL:
type: String
title: User Info Endpoint URL
hint: Ignored while discovery is on. Optional even without it — the ID token alone can carry everything needed.
icon: contact
order: 7
jwksURL:
type: String
title: JSON Web Key Set URL
hint: Ignored while discovery is on. Where the keys that signed the ID token are published; without it the ID token cannot be verified and logins are refused.
icon: fingerprint-scan
order: 8
scopes:
type: String
title: Scopes
hint: Space-separated. `openid` is required; `email` is what an account is matched on here.
icon: rules
default: 'openid profile email'
order: 9
emailClaim:
type: String
title: Email Claim
hint: Which claim carries the email address.
icon: envelope
default: email
order: 10
displayNameClaim:
type: String
title: Display Name Claim
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
icon: person
default: name
order: 11
logoutURL:
type: String
title: Logout URL
hint: (optional) Where to send a user after logging out, so that the session at the provider ends too.
icon: exit
order: 12
refs:
callbackUrl:
title: Authorization Callback URL
hint: Register this as the redirect URI at the provider. It is the same for every provider.
icon: back
value: '{host}/_api/auth/{id}/callback'

@ -43,6 +43,7 @@
"mime": "4.1.0", "mime": "4.1.0",
"nanoid": "5.1.11", "nanoid": "5.1.11",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"openid-client": "6.8.4",
"pem-jwk": "2.0.0", "pem-jwk": "2.0.0",
"pg": "8.21.0", "pg": "8.21.0",
"poolifier": "5.3.2", "poolifier": "5.3.2",
@ -5308,6 +5309,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.2.7",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz",
"integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md4": { "node_modules/js-md4": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@ -5876,6 +5886,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "url": "https://github.com/fb55/nth-check?sponsor=1"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5928,6 +5947,19 @@
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/openid-client": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
"oauth4webapi": "^3.8.5"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/oxfmt": { "node_modules/oxfmt": {
"version": "0.54.0", "version": "0.54.0",
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz",

@ -69,6 +69,7 @@
"mime": "4.1.0", "mime": "4.1.0",
"nanoid": "5.1.11", "nanoid": "5.1.11",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"openid-client": "6.8.4",
"pem-jwk": "2.0.0", "pem-jwk": "2.0.0",
"pg": "8.21.0", "pg": "8.21.0",
"poolifier": "5.3.2", "poolifier": "5.3.2",

@ -43,6 +43,25 @@ declare module 'fastify' {
* it the client is never trusted with that state. * it the client is never trusted with that state.
*/ */
unlockedPages?: string[] unlockedPages?: string[]
/**
* The redirect login in progress, written when the browser is sent to an identity provider and
* read when it comes back. It is what ties the two halves together: an answer whose `state` is not
* the one this session sent is not this session's answer, and the PKCE verifier never leaves here.
*
* One at a time, deliberately a second attempt replaces the first rather than leaving a set of
* open states to be matched against.
*/
authFlow?: {
strategyId: string
siteId: string
state: string
nonce: string
codeVerifier: string
/** Where to send the browser once it is logged in. */
redirect: string
/** When this flow was started, as an ISO instant, so that a stale one can be refused. */
startedAt: string
}
/** /**
* The WebAuthn challenge a passkey ceremony is waiting on, written by the routes in `api/users.ts` * The WebAuthn challenge a passkey ceremony is waiting on, written by the routes in `api/users.ts`
* (registration) and `api/authentication.ts` (login) and consumed by the verification that * (registration) and `api/authentication.ts` (login) and consumed by the verification that

@ -4,11 +4,11 @@
<!-- LOGIN SCREEN --> <!-- LOGIN SCREEN -->
<!-- ----------------------------------------------------- --> <!-- ----------------------------------------------------- -->
<template v-if="state.screen === `login`"> <template v-if="state.screen === `login`">
<template v-if="state.strategies?.length > 1"> <template v-if="formStrategies.length > 1">
<p>{{ t('auth.selectAuthProvider') }}</p> <p>{{ t('auth.selectAuthProvider') }}</p>
<div class="auth-strategies mb-4"> <div class="auth-strategies mb-4">
<w-btn <w-btn
v-for="str of state.strategies" v-for="str of formStrategies"
:label="str.activeStrategy.displayName" :label="str.activeStrategy.displayName"
:icon="`img:` + str.activeStrategy.strategy.icon" :icon="`img:` + str.activeStrategy.strategy.icon"
push push
@ -82,6 +82,25 @@
icon="la:key" icon="la:key"
@click="loginWithPasskey" /> @click="loginWithPasskey" />
</template> </template>
<!--
The providers that sign a user in elsewhere. A link rather than a form submit, because what
follows is a page at the provider and not an answer to a request: pressing it hands the browser
over, and it comes back at the callback route with a session already established.
-->
<template v-if="redirectStrategies.length > 0">
<w-separator class="my-4" />
<w-btn
class="acrylic-btn w-full mb-2"
v-for="str of redirectStrategies"
:key="str.id"
flat
color="primary"
:label="t(`auth.actions.loginWith`, { provider: str.activeStrategy.displayName })"
no-caps
:icon="`img:` + str.activeStrategy.strategy.icon"
:href="authorizeUrl(str)"
type="a" />
</template>
<template v-if="selectedStrategy.activeStrategy?.strategy?.key === `local`"> <template v-if="selectedStrategy.activeStrategy?.strategy?.key === `local`">
<w-separator class="my-4" /> <w-separator class="my-4" />
<w-btn <w-btn
@ -391,6 +410,20 @@ const changePwdForm = ref(null)
// COMPUTED // COMPUTED
/*
The two kinds of strategy this screen deals with, and they are drawn nothing alike: one is a username
and a password typed here, the other is a button that leaves for the provider. Splitting them is also
what stops a provider from being picked in the selector above the form, where it would then be asked
for a password it has no use for.
*/
const formStrategies = computed(() =>
state.strategies.filter((str) => str.activeStrategy?.strategy?.useForm !== false)
)
const redirectStrategies = computed(() =>
state.strategies.filter((str) => str.activeStrategy?.strategy?.useForm === false)
)
const selectedStrategy = computed(() => { const selectedStrategy = computed(() => {
return ( return (
(state.selectedStrategyId && state.strategies.find((s) => s.id === state.selectedStrategyId)) || (state.selectedStrategyId && state.strategies.find((s) => s.id === state.selectedStrategyId)) ||
@ -516,7 +549,28 @@ async function fetchStrategies(showAll = false) {
visibleOnly: !showAll visibleOnly: !showAll
} }
}).json() }).json()
state.selectedStrategyId = state.strategies[0].id // -> The selection drives the form, so it has to be a strategy that has one
state.selectedStrategyId = formStrategies.value[0]?.id ?? null
}
/**
* Where a provider button goes: the backend builds the URL at the provider, because everything that
* ties the answer back to this browser `state`, `nonce`, the PKCE verifier is generated there and
* kept on the session.
*/
function authorizeUrl(str) {
const params = new URLSearchParams({ siteId: siteStore.id })
/*
The same cookie a form login reads on its way out: whatever sent the reader to the login screen
left where they were going in it. The provider flow cannot come back through the code above it
lands on the callback route, which redirects so the destination travels with the request and is
handed back by the callback instead.
*/
const loginRedirect = Cookies.get('loginRedirect')
if (loginRedirect) {
params.set('redirect', loginRedirect)
}
return `/_api/auth/${str.id}/authorize?${params.toString()}`
} }
async function handleLoginResponse(resp) { async function handleLoginResponse(resp) {
@ -848,5 +902,33 @@ async function finishSetupTFA() {
onMounted(async () => { onMounted(async () => {
await fetchStrategies() await fetchStrategies()
reportRedirectLoginError()
}) })
/**
* Say what went wrong on a login that happened somewhere else.
*
* A provider login fails at the callback route, which has a browser to redirect and no request to
* answer so it puts the reason in the URL and this puts it in front of the reader. Taken out of the
* address bar afterwards, so that reloading the page does not report it a second time.
*/
function reportRedirectLoginError() {
const params = new URLSearchParams(window.location.search)
const code = params.get('error')
if (!code) {
return
}
notify({
type: 'negative',
message: t('auth.errors.loginError'),
caption: localizeError(code, t)
})
params.delete('error')
const query = params.toString()
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${query ? `?${query}` : ''}`
)
}
</script> </script>

@ -246,7 +246,7 @@
map-options map-options
dense dense
:aria-label="t(`admin.groups.ruleSites`)" :aria-label="t(`admin.groups.ruleSites`)"
:options="rules" :options="ruleOptions"
placeholder="Select permissions..." placeholder="Select permissions..."
option-value="permission" option-value="permission"
option-label="title" option-label="title"
@ -748,6 +748,19 @@ const permissions = [
} }
] ]
/**
* The subset of `rules` below that the guests group may be granted. Mirrors `GUEST_ROLES` in
* `models/groups.ts`, which is the copy that decides this one only shapes what is offered.
*/
const GUEST_ROLES = [
'read:pages',
'read:source',
'read:history',
'read:assets',
'read:comments',
'write:comments'
]
const rules = [ const rules = [
{ {
permission: 'read:pages', permission: 'read:pages',
@ -888,6 +901,20 @@ const isGuestGroup = computed(() => {
return adminStore.overlayOpts.id === '10000000-0000-4000-8000-000000000001' return adminStore.overlayOpts.id === '10000000-0000-4000-8000-000000000001'
}) })
/**
* The permissions a rule may grant, which for the guests group is a short list.
*
* That group is every anonymous reader at once, so a rule on it is a rule about the open internet:
* reading, and saying something in a comment, are what the public may be given writing a page or
* deleting one is an action attributable to somebody, and there is nobody here.
*
* Only what is OFFERED. The set is enforced in `models/groups.ts`, which is what makes it true for a
* group edited through the API as well; this keeps the screen from offering what would be dropped.
*/
const ruleOptions = computed(() =>
isGuestGroup.value ? rules.filter((rule) => GUEST_ROLES.includes(rule.permission)) : rules
)
// WATCHERS // WATCHERS
watch(() => route.params.section, checkRoute) watch(() => route.params.section, checkRoute)

@ -0,0 +1,113 @@
<template>
<w-dialog v-model="dialogVisible" max-width="450px" @hide="onDialogHide">
<w-card style="min-width: 350px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-delete-bin.svg" size="sm" class="mr-2" />
<span>{{ t(`admin.users.deleteConfirmTitle`) }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">
<i18n-t keypath="admin.users.deleteConfirmText">
<template #username>
<strong>{{ props.user.name }}</strong>
</template>
</i18n-t>
</div>
<!--
Said before the attempt rather than only when it fails: a user who has written anything
cannot be deleted at all, and finding that out from an error after confirming is finding it
out too late to have chosen deactivation instead.
-->
<div class="text-body2 mt-4">{{ t(`admin.users.deleteConfirmForeignNotice`) }}</div>
<div class="text-body2 mt-4">
<strong class="text-negative">{{ t(`admin.users.deleteHint`) }}</strong>
</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.delete`)"
color="negative"
padding="xs md"
:loading="state.isDeleting"
@click="confirm" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
// PROPS
const props = defineProps({
user: {
type: Object,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
isDeleting: false
})
// METHODS
async function confirm() {
state.isDeleting = true
try {
const resp = await API_CLIENT.delete(`users/${props.user.id}`)
if (!resp?.ok) {
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.users.deleteSuccess', { username: props.user.name })
})
onDialogOK()
} catch (err) {
/*
ky throws for statuses above 400, and this endpoint has several things to say through one: the
account owns pages, it is the last root administrator, it is a system user, it is the caller's
own. The reason is in the body, so the dialog stays open with it rather than closing on a
failure it did not report.
*/
const apiMessage = await err.response
?.json()
.then((b) => b?.message)
.catch(() => null)
notify({
type: 'negative',
message: apiMessage || err.message
})
}
state.isDeleting = false
}
</script>

@ -91,6 +91,18 @@
@blur="onBlur" @blur="onBlur"
@keyup.enter="$emit('keyup:enter', $event)" /> @keyup.enter="$emit('keyup:enter', $event)" />
<!--
The mirror of the prefix above, and placed before the trailing controls rather than after
them: it belongs to the value -- the closing `/` of a regex, a unit after a number -- so it
has to sit against the text, not beyond the clear cross.
-->
<span
v-if="suffix"
aria-hidden="true"
class="shrink-0 pt-0.5 text-black/54 select-none dark:text-white/60">
{{ suffix }}
</span>
<!-- <!--
`mr-1` on the button rather than more padding on the control: the padding is what every `mr-1` on the button rather than more padding on the control: the padding is what every
trailing control shares -- the clear cross, an `append` slot -- and this is about the eye, trailing control shares -- the clear cross, an `append` slot -- and this is about the eye,
@ -168,6 +180,11 @@ const props = defineProps({
type: String, type: String,
default: null default: null
}, },
/** Static text shown after the value, e.g. the closing `/` of a pattern or a unit. */
suffix: {
type: String,
default: null
},
placeholder: { placeholder: {
type: String, type: String,
default: null default: null

@ -54,6 +54,26 @@ const classes = computed(() => [
</script> </script>
<style scoped> <style scoped>
/*
The foreground of a list that is dark whatever the app theme.
Text colour is part of "renders for a dark surface", not something each call site should have to
remember: an item label carries no colour of its own and inherits the document's, which in light
mode is black -- on a `bg-dark` card that is black on black. Only the admin sidebar was passing
`text-white` by hand, so every other dark list read as an empty panel until the theme was switched.
The dimmed labels are stated too. Their utilities are `text-black/54 dark:text-white/70`, and the
`dark:` half keys off the app theme, so in light mode a caption stayed the black one.
*/
.w-list--dark {
color: #fff;
}
.w-list--dark :deep(.w-item-label--caption),
.w-list--dark :deep(.w-item-label--header) {
color: rgb(255 255 255 / 0.7);
}
/* /*
Hover feedback for a list that is dark whatever the app theme. Hover feedback for a list that is dark whatever the app theme.

@ -41,8 +41,14 @@
</div> </div>
</div> </div>
<w-separator inset /> <w-separator inset />
<div class="grid grid-cols-12 p-4 gap-4"> <!--
<div class="col-span-12 lg:col-auto"> The same shape the storage view uses for a list beside what it selects: the list is as wide as
it needs to be and the panel takes what is left, wrapping onto its own row when there is no room
for both. A 12-column grid cannot say that -- the list is 350px, not some number of twelfths --
which is how this ended up with the panel on `col-span-full`, i.e. underneath.
-->
<div class="flex flex-wrap p-4 gap-4">
<div class="flex-none">
<w-card class="rounded bg-dark"> <w-card class="rounded bg-dark">
<w-list style="min-width: 350px" padding dark> <w-list style="min-width: 350px" padding dark>
<w-item <w-item
@ -57,6 +63,14 @@
<w-item-label>{{ str.displayName }}</w-item-label> <w-item-label>{{ str.displayName }}</w-item-label>
<w-item-label caption>{{ str.strategy.title }}</w-item-label> <w-item-label caption>{{ str.strategy.title }}</w-item-label>
</w-item-section> </w-item-section>
<!--
Its own section rather than sharing the light's: the light is `height: 100%` against
whatever contains it, and a wrapper sized to its own content is not the row.
-->
<w-item-section side v-if="str.isNew">
<!-- -> Nothing on the server answers to this one yet; Apply is what creates it -->
<w-badge color="warning" rounded>{{ t('admin.auth.unsaved') }}</w-badge>
</w-item-section>
<w-item-section side> <w-item-section side>
<status-light <status-light
:color="str.isEnabled ? `positive` : `negative`" :color="str.isEnabled ? `positive` : `negative`"
@ -65,15 +79,20 @@
</w-item> </w-item>
</w-list> </w-list>
</w-card> </w-card>
<!--
Always shown, rather than only with the experimental flag on: adding a strategy is what this
screen is for once a wiki has more than the built-in local one, and a button that is not
there cannot say that none of the installed modules is addable. The menu says it instead.
-->
<w-btn <w-btn
class="mt-2 w-full" class="mt-2 w-full"
color="primary" color="primary"
icon="la:plus" icon="la:plus"
:label="t(`admin.auth.addStrategy`)" :label="t(`admin.auth.addStrategy`)">
v-if="flagsStore.experimental">
<w-menu auto-close fit max-width="300px"> <w-menu auto-close fit max-width="300px">
<w-list separator> <w-list separator>
<!-- Only the local module ships with the wiki so far, and it is already configured --> <!-- -> The local module is filtered out: it is already configured, and a second copy
of it holds no credentials -->
<w-item v-if="availableStrategies.length < 1"> <w-item v-if="availableStrategies.length < 1">
<w-item-section> <w-item-section>
<w-item-label caption>{{ t('admin.auth.noModulesToAdd') }}</w-item-label> <w-item-label caption>{{ t('admin.auth.noModulesToAdd') }}</w-item-label>
@ -100,7 +119,8 @@
</w-menu> </w-menu>
</w-btn> </w-btn>
</div> </div>
<div class="col-span-full" v-if="state.strategy.id"> <!-- -> `min-w-0`, or a long value inside a field would push the panel wider than the row -->
<div class="min-w-0 flex-1" v-if="state.strategy.id">
<w-card class="pb-2"> <w-card class="pb-2">
<w-card-header>{{ t('admin.auth.info') }}</w-card-header> <w-card-header>{{ t('admin.auth.info') }}</w-card-header>
<w-item> <w-item>
@ -322,7 +342,21 @@
<w-item-label caption>{{ strRef.hint }}</w-item-label> <w-item-label caption>{{ strRef.hint }}</w-item-label>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-input outlined v-model="strRef.value" dense :aria-label="strRef.title" readonly /> <!--
These carry the strategy's ID, which the server assigns so until Apply has created
it there is no URL to register with the provider, and showing one built from the
placeholder ID would be showing the wrong one.
-->
<w-item-label v-if="state.strategy.isNew" caption>
{{ t('admin.auth.refAfterSave') }}
</w-item-label>
<w-input
v-else
outlined
v-model="strRef.value"
dense
:aria-label="strRef.title"
readonly />
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-card> </w-card>
@ -331,8 +365,10 @@
<!-- ----------------------- --> <!-- ----------------------- -->
<w-card class="mt-4"> <w-card class="mt-4">
<w-card-section class="text-center"> <w-card-section class="text-center">
<!-- -> `mx-auto`: `text-center` on the section does nothing for a block-level image,
which sat against the left edge of every card wider than its 300px cap -->
<img <img
class="w-full object-contain rounded" class="w-full mx-auto object-contain rounded"
:src="state.strategy.strategy.logo" :src="state.strategy.strategy.logo"
style="height: 100px; max-width: 300px" /> style="height: 100px; max-width: 300px" />
<div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div> <div class="text-subtitle2 mt-2">{{ state.strategy.strategy.title }}</div>
@ -369,6 +405,7 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive, watch } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { v4 as uuid } from 'uuid'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
@ -376,7 +413,6 @@ import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { dialog } from '@/composables/dialog' import { dialog } from '@/composables/dialog'
import { useFlagsStore } from '@/stores/flags'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
// COMPOSABLES // COMPOSABLES
@ -385,7 +421,6 @@ const dark = useDark()
// STORES // STORES
const flagsStore = useFlagsStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// I18N // I18N
@ -593,14 +628,27 @@ async function save() {
state.loading++ state.loading++
const failures = [] const failures = []
/*
A strategy that has never been saved is created here, whole: the create endpoint takes the same
fields the update one does, so a new provider arrives with its configuration rather than existing
for a moment as an empty shell. Whichever ID the server assigns is what the reload below picks up.
*/
for (const str of state.activeStrategies) { for (const str of state.activeStrategies) {
try { try {
const resp = await API_CLIENT.put(`authentication/strategies/${str.id}`, { const resp = str.isNew
json: payloadFor(str) ? await API_CLIENT.post('authentication/strategies', {
}).json() json: { module: str.module, ...payloadFor(str) }
}).json()
: await API_CLIENT.put(`authentication/strategies/${str.id}`, {
json: payloadFor(str)
}).json()
if (!resp?.ok) { if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.') throw new Error(resp?.message || 'An unexpected error occured.')
} }
if (str.isNew && resp.id) {
// -> So that the reload lands back on the strategy that was just created
state.selectedStrategy = resp.id
}
} catch (err) { } catch (err) {
failures.push({ name: str.displayName, message: await apiMessage(err) }) failures.push({ name: str.displayName, message: await apiMessage(err) })
} }
@ -624,33 +672,51 @@ async function save() {
await load() await load()
} }
async function addStrategy(mod) { /**
state.loading++ * Add a strategy to the list, without creating it.
try { *
const resp = await API_CLIENT.post('authentication/strategies', { * Nothing is sent: the new strategy is a row in this screen until Apply, like every edit made to the
json: { module: mod.key, displayName: mod.title } * ones beside it. An administrator adding a provider has a client ID and a secret to paste in first,
}).json() * and a half-configured strategy that already exists on the server is one that can be saved by
if (!resp?.ok) { * accident, reloaded into, or left behind by closing the tab.
throw new Error(resp?.message || 'An unexpected error occured.') *
} * The ID is a local placeholder; the server assigns the real one when this is created.
notify({ */
type: 'positive', function addStrategy(mod) {
message: t('admin.auth.addSuccess', { strategy: mod.title }) const strategy = {
}) id: `new:${uuid()}`,
state.selectedStrategy = resp.id isNew: true,
} catch (err) { module: mod.key,
notify({ displayName: mod.title,
type: 'negative', // -> Off until it has been configured and saved: an enabled strategy appears on login screens
message: t('admin.auth.addFailed'), isEnabled: false,
caption: await apiMessage(err) registration: false,
}) allowedEmailRegex: '',
autoEnrollGroups: [],
strategy: mod,
config: buildConfigEditor(mod.props, {})
} }
state.loading-- state.activeStrategies.push(strategy)
await load() state.selectedStrategy = strategy.id
state.strategy = strategy
notify({
type: 'positive',
message: t('admin.auth.addPending', { strategy: mod.title })
})
} }
function confirmDelete() { function confirmDelete() {
const strategy = state.strategy const strategy = state.strategy
/*
Nothing to confirm and nothing to delete for one that only ever existed here: it goes, and the
selection falls back to the first strategy the way it does after a reload.
*/
if (strategy.isNew) {
state.activeStrategies = state.activeStrategies.filter((str) => str.id !== strategy.id)
state.selectedStrategy = state.activeStrategies[0]?.id
state.strategy = state.activeStrategies[0] ?? { strategy: {} }
return
}
dialog({ dialog({
title: t('admin.auth.deleteStrategy'), title: t('admin.auth.deleteStrategy'),
message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }), message: t('admin.auth.deleteConfirm', { strategy: strategy.displayName }),

@ -114,13 +114,24 @@
:color="dark.isActive ? `indigo-4` : `indigo`" :color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`common.actions.edit`)" :label="t(`common.actions.edit`)"
no-caps /> no-caps />
<!--
Disabled rather than hidden for your own account: the row is yours and the action
exists, it is just not yours to take deleting the account you are signed in as
would end the session that was doing it. Another administrator can.
-->
<w-btn <w-btn
class="acrylic-btn" class="acrylic-btn"
v-if="!props.row.isSystem" v-if="!props.row.isSystem"
flat flat
icon="la:trash" icon="la:trash"
color="negative" color="negative"
@click="deleteUser(props.row)" /> :disable="props.row.id === userStore.id"
:aria-label="t(`admin.users.delete`)"
@click="deleteUser(props.row)">
<w-tooltip v-if="props.row.id === userStore.id">
{{ t('admin.users.deleteSelfForbidden') }}
</w-tooltip>
</w-btn>
</w-td> </w-td>
</template> </template>
</w-table> </w-table>
@ -157,6 +168,7 @@ import { relativeDate } from '@/helpers/datetime'
import { debounce } from 'es-toolkit/function' import { debounce } from 'es-toolkit/function'
import UserCreateDialog from '../components/UserCreateDialog.vue' import UserCreateDialog from '../components/UserCreateDialog.vue'
import UserDeleteDialog from '../components/UserDeleteDialog.vue'
import UserDefaultsMenu from '@/components/UserDefaultsMenu.vue' import UserDefaultsMenu from '@/components/UserDefaultsMenu.vue'
// COMPOSABLES // COMPOSABLES
@ -314,7 +326,7 @@ function createUser() {
function deleteUser(usr) { function deleteUser(usr) {
dialog({ dialog({
// component: UserDeleteDialog, component: UserDeleteDialog,
componentProps: { componentProps: {
user: usr user: usr
} }

Loading…
Cancel
Save