diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index ef83919b1..dc4a601ea 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -1,5 +1,41 @@ +import { nanoid } from 'nanoid' 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 @@ -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).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 */ diff --git a/backend/api/groups.ts b/backend/api/groups.ts index e199ea9af..5dc612364 100644 --- a/backend/api/groups.ts +++ b/backend/api/groups.ts @@ -468,10 +468,15 @@ async function routes(app: FastifyInstance) { if (!user) { 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. - if (user.isSystem) { - return reply.conflict('Cannot assign a system user to a group.') + /* + The guests group and the guest account belong to each other and to nothing else — the group is + what anonymous visitors hold, and the account is who they are. `guestMembershipViolation` is + 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) @@ -531,7 +536,8 @@ async function routes(app: FastifyInstance) { } // -> 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) if (user?.isSystem) { return reply.conflict('Cannot unassign a system user from a group.') diff --git a/backend/api/schemas/authentication.ts b/backend/api/schemas/authentication.ts index 6a779b3fb..77bf0007f 100644 --- a/backend/api/schemas/authentication.ts +++ b/backend/api/schemas/authentication.ts @@ -160,13 +160,14 @@ export async function registerSchemas(app: FastifyInstance): Promise { }, registration: { 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: { type: 'string', maxLength: 255, 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: { type: 'array', diff --git a/backend/api/users.ts b/backend/api/users.ts index 5fbd42e39..daea313b3 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -1572,7 +1572,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Delete a user', 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'], params: { type: 'object', @@ -1596,10 +1596,20 @@ async function routes(app: FastifyInstance) { if (!user) { 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) { 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 const rootAdminGroupId = WIKI.config.auth.rootAdminGroupId if (await WIKI.models.groups.isUserInGroup(rootAdminGroupId, user.id)) { diff --git a/backend/locales/en.json b/backend/locales/en.json index 43c6cca3f..fe6bf16db 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -116,9 +116,8 @@ "admin.approval.updateSuccess": "Rule updated successfully.", "admin.audit.title": "Audit Log", "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.addSuccess": "{strategy} has been added.", "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.allowedWebOrigins": "Allowed Web Origins", @@ -150,6 +149,7 @@ "admin.auth.logoutUrl": "Logout URL", "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.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.registration": "Registration", "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.subtitle": "Configure the authentication settings of your wiki", "admin.auth.title": "Authentication", + "admin.auth.unsaved": "Not saved", "admin.auth.vendor": "Vendor", "admin.auth.vendorWebsite": "Website", "admin.blocks.add": "Add Block", @@ -708,7 +709,7 @@ "admin.security.corsHostnames": "Hostnames Whitelist", "admin.security.corsHostnamesHint": "Enter one hostname per line", "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.corsRegexHint": "Pattern against which the request hostname is matched.", "admin.security.disallowFloc": "Disallow Google FLoC", @@ -1100,6 +1101,8 @@ "admin.users.deleteConfirmText": "Are you sure you want to delete user {username}?", "admin.users.deleteConfirmTitle": "Delete User?", "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.edit": "Edit User", "admin.users.email": "Email", @@ -1301,6 +1304,7 @@ "admin.webhooks.urlInvalidChars": "The URL contains invalid characters.", "admin.webhooks.urlMissing": "The URL is missing or is not valid.", "auth.actions.login": "Log In", + "auth.actions.loginWith": "Continue with {provider}", "auth.actions.register": "Register", "auth.changePwd.currentPassword": "Current Password", "auth.changePwd.instructions": "You must choose a new password:", @@ -1883,15 +1887,23 @@ "editor.unsaved.title": "Discard Unsaved Changes?", "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_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_INACTIVE_USER": "This account is deactivated.", "error.ERR_INCORRECT_CURRENT_PASSWORD": "The current password is incorrect.", "error.ERR_INVALID_STRATEGY": "This authentication method cannot be used here.", "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_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_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_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_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.", @@ -1901,12 +1913,16 @@ "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_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_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_INCORRECT_TOKEN": "This security code is incorrect.", "error.ERR_TFA_INVALID_REQUEST": "Missing or incomplete security code.", "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.", "fileman.7zFileType": "7zip Archive", "fileman.aacFileType": "AAC Audio File", diff --git a/backend/models/authentication.ts b/backend/models/authentication.ts index 512bcb380..1448e5279 100644 --- a/backend/models/authentication.ts +++ b/backend/models/authentication.ts @@ -24,6 +24,44 @@ export interface AuthModule { refs?: Record } +/** + * 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. */ export interface AuthStrategy { id: string diff --git a/backend/models/groups.ts b/backend/models/groups.ts index 132985028..5ab8cf508 100644 --- a/backend/models/groups.ts +++ b/backend/models/groups.ts @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid' import { and, count, eq, ilike, or, sql } from 'drizzle-orm' 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 type { SystemIds } from './types.ts' import type { FastifyRequest } from 'fastify' @@ -107,6 +108,25 @@ export interface AccessActor { 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. * @@ -308,12 +328,43 @@ class Groups { async updateGroup(id: string, patch: GroupPatch): Promise { const result = await WIKI.db .update(groupsTable) - .set({ ...patch, updatedAt: sql`now()` }) + .set({ ...this.clampGuestPatch(id, patch), updatedAt: sql`now()` }) .where(eq(groupsTable.id, id)) await this.reloadCache() 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. * @@ -331,7 +382,44 @@ class Groups { * * @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 { + 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 .insert(userGroups) .values({ userId, groupId }) @@ -345,6 +433,20 @@ class Groups { * @returns False if the user was not a member */ async unassignUserFromGroup(groupId: string, userId: string): Promise { + /* + 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 .delete(userGroups) .where(and(eq(userGroups.groupId, groupId), eq(userGroups.userId, userId))) diff --git a/backend/models/users.ts b/backend/models/users.ts index 09e473b82..2c92de967 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -14,6 +14,7 @@ import { nanoid } from 'nanoid' import { flatten, uniq } from 'es-toolkit/array' import { detectImageMime, resizeImageToSquareJpeg } from '../helpers/images.ts' import { buildTotpUri, generateTotpSecret, verifyTotpCode } from '../helpers/totp.ts' +import type { AuthStrategy, ProviderProfile } from './authentication.ts' import type { SystemIds } from './types.ts' /** 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. * * 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 { + 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 = - groupIds.length > 0 + allowed.length > 0 ? await WIKI.db .select({ id: groupsTable.id }) .from(groupsTable) - .where(inArray(groupsTable.id, groupIds)) + .where(inArray(groupsTable.id, allowed)) : [] 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 { + 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 + 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( user: any, strategyId: string, diff --git a/backend/modules/authentication/github/authentication.ts b/backend/modules/authentication/github/authentication.ts new file mode 100644 index 000000000..fed2f1219 --- /dev/null +++ b/backend/modules/authentication/github/authentication.ts @@ -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 + /** Set by `models/authentication.ts` right after construction. */ + module?: string + + constructor(strategyId: string, conf: Record) { + 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 { + 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 { + 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 { + 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 + // -> 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 + } + } +} diff --git a/backend/modules/authentication/github/definition.yml b/backend/modules/authentication/github/definition.yml new file mode 100644 index 000000000..653db2c57 --- /dev/null +++ b/backend/modules/authentication/github/definition.yml @@ -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' diff --git a/backend/modules/authentication/google/authentication.ts b/backend/modules/authentication/google/authentication.ts new file mode 100644 index 000000000..2cd892c64 --- /dev/null +++ b/backend/modules/authentication/google/authentication.ts @@ -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 + /** Set by `models/authentication.ts` right after construction. */ + module?: string + + private config: client.Configuration | null = null + + constructor(strategyId: string, conf: Record) { + this.strategyId = strategyId + this.conf = conf + } + + private async configuration(): Promise { + 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 { + 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 { + 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 | 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 + } + } +} diff --git a/backend/modules/authentication/google/definition.yml b/backend/modules/authentication/google/definition.yml new file mode 100644 index 000000000..94acb6582 --- /dev/null +++ b/backend/modules/authentication/google/definition.yml @@ -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' diff --git a/backend/modules/authentication/oidc/authentication.ts b/backend/modules/authentication/oidc/authentication.ts new file mode 100644 index 000000000..66083de94 --- /dev/null +++ b/backend/modules/authentication/oidc/authentication.ts @@ -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 + /** 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) { + 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 { + 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 { + 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 { + 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 = 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 + } +} diff --git a/backend/modules/authentication/oidc/definition.yml b/backend/modules/authentication/oidc/definition.yml new file mode 100644 index 000000000..5b3c7ee13 --- /dev/null +++ b/backend/modules/authentication/oidc/definition.yml @@ -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' diff --git a/backend/package-lock.json b/backend/package-lock.json index ede6e8b8c..891af602e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -43,6 +43,7 @@ "mime": "4.1.0", "nanoid": "5.1.11", "node-cache": "5.1.2", + "openid-client": "6.8.4", "pem-jwk": "2.0.0", "pg": "8.21.0", "poolifier": "5.3.2", @@ -5308,6 +5309,15 @@ "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": { "version": "0.3.2", "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" } }, + "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5928,6 +5947,19 @@ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "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": { "version": "0.54.0", "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", diff --git a/backend/package.json b/backend/package.json index b436dddb5..4995915d7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -69,6 +69,7 @@ "mime": "4.1.0", "nanoid": "5.1.11", "node-cache": "5.1.2", + "openid-client": "6.8.4", "pem-jwk": "2.0.0", "pg": "8.21.0", "poolifier": "5.3.2", diff --git a/backend/types/fastify.d.ts b/backend/types/fastify.d.ts index 42c205942..17fec26ef 100644 --- a/backend/types/fastify.d.ts +++ b/backend/types/fastify.d.ts @@ -43,6 +43,25 @@ declare module 'fastify' { * it — the client is never trusted with that state. */ 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` * (registration) and `api/authentication.ts` (login) and consumed by the verification that diff --git a/frontend/src/components/AuthLoginPanel.vue b/frontend/src/components/AuthLoginPanel.vue index b4429007b..3d5b5b936 100644 --- a/frontend/src/components/AuthLoginPanel.vue +++ b/frontend/src/components/AuthLoginPanel.vue @@ -4,11 +4,11 @@