From 0052ec618c041799d4c76632ea1ace26c98c1931 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Tue, 8 Sep 2026 03:40:38 -0400 Subject: [PATCH] feat: add discord auth + map groups for github + google --- backend/locales/en.json | 1 + .../authentication/discord/authentication.ts | 308 ++++++++++++++++++ .../authentication/discord/definition.yml | 61 ++++ .../authentication/github/authentication.ts | 110 ++++++- .../authentication/github/definition.yml | 20 +- .../authentication/google/authentication.ts | 134 +++++++- .../authentication/google/definition.yml | 28 ++ .../authentication/oidc/authentication.ts | 2 +- .../authentication/oidc/definition.yml | 2 +- frontend/src/components/shared/WItemLabel.vue | 8 +- frontend/src/css/tailwind.css | 19 ++ frontend/src/pages/AdminAuth.vue | 5 +- 12 files changed, 674 insertions(+), 24 deletions(-) create mode 100644 backend/modules/authentication/discord/authentication.ts create mode 100644 backend/modules/authentication/discord/definition.yml diff --git a/backend/locales/en.json b/backend/locales/en.json index 0f7572e6d..2d491dcd7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -285,6 +285,7 @@ "admin.auth.enabled": "Enabled", "admin.auth.enabledForced": "This strategy cannot be disabled.", "admin.auth.enabledHint": "Should this strategy be available to sites for login.", + "admin.auth.enabledSiteHint": "Note that the strategy also needs to be enabled under the site's Login page before it appears on the login screen.", "admin.auth.enforceTfa": "Enforce Two-Factor Authentication", "admin.auth.enforceTfaHint": "Users will be required to setup 2FA the first time they login and cannot be disabled by the user.", "admin.auth.globalAdvSettings": "Global Advanced Settings", diff --git a/backend/modules/authentication/discord/authentication.ts b/backend/modules/authentication/discord/authentication.ts new file mode 100644 index 000000000..cddb6f272 --- /dev/null +++ b/backend/modules/authentication/discord/authentication.ts @@ -0,0 +1,308 @@ +import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts' + +/** Where a person signs in. Not under `/api`, unlike everything else Discord answers. */ +const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize' + +/** The pinned API version. Discord dates its breaking changes to these and leaves old ones running. */ +const API = 'https://discord.com/api/v10' + +/** + * How long a server's role list is kept before being read again. + * + * Roles are renamed rarely and logins are frequent, so this is a cache with a clock rather than an + * invalidation. A role that is *new* does not wait it out: an ID the cache cannot name is what + * `roleNames` treats as proof the list is stale, and it reads it again there and then. + */ +const ROLE_CACHE_MS = 5 * 60 * 1000 + +/** + * Discord + * + * Discord speaks OAuth 2.0 and not OpenID Connect: there is no ID token and so nothing to verify a + * signature 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, so this module is written with `fetch` and + * no dependency, like the GitHub one. `state` and keeping the client secret off the browser are the + * flow's job (`api/authentication.ts`). + * + * Three things about Discord specifically are worth the code: + * + * - **an account's email is only worth anything when `verified` is set.** Discord will hand over + * an address that has never been confirmed, and an account here is matched by address; + * - **a server can be required**, which is one call — `/users/@me/guilds/{id}/member` answers 404 + * for somebody who is not in it, and answers with their roles for somebody who is. So the + * restriction and the group mapping are the same request; + * - **roles arrive as IDs, never as names.** Nothing a user's own token can be spent on will name + * a role; only a bot in the server can read the list. Hence the optional bot token, and hence + * what the mapping falls back to without one — see `roleNames`. + */ +export default class DiscordAuthentication { + strategyId: string + conf: Record + /** Set by `models/authentication.ts` right after construction. */ + module?: string + + /** The server's roles by ID, as of `expires`. Only ever populated when a bot token is configured. */ + private roles: { names: Map; expires: number } | null = null + + constructor(strategyId: string, conf: Record) { + this.strategyId = strategyId + this.conf = conf + } + + /** + * The strategy's settings, refused if they cannot describe a login. + * + * Mapping groups without a server is the one combination worth failing over rather than working + * around: roles belong to a server, so there would be no roles to read — and answering with an + * empty group list is not the same as answering with nothing. Under `unassignMissingGroups` it is + * a statement that this person holds no roles, which would take every mapped membership away from + * everybody who logged in. A misconfiguration must not quietly empty the groups it was meant to + * fill. + * + * @throws `ERR_STRATEGY_MISCONFIGURED` + */ + private settings(): { clientId: string; clientSecret: string; serverId: string } { + const clientId = (this.conf.clientId || '').trim() + const clientSecret = this.conf.clientSecret || '' + const serverId = (this.conf.serverId || '').trim() + if (!clientId || !clientSecret) { + throw new Error('ERR_STRATEGY_MISCONFIGURED') + } + if (this.conf.mapGroups === true && !serverId) { + WIKI.logger.warn( + `Discord strategy ${this.strategyId} maps groups but has no Server ID, and a role belongs to a server.` + ) + throw new Error('ERR_STRATEGY_MISCONFIGURED') + } + return { clientId, clientSecret, serverId } + } + + /** + * `fetch`, with an unreachable Discord reported as a provider failure rather than as itself. + * + * A rejected fetch carries a message about sockets and DNS, and the callback route puts whatever it + * caught into the URL it redirects to — so left alone, "fetch failed" is what the person trying to + * log in reads. Every call this module makes goes through here for that reason. + */ + private async reach(url: string, init: RequestInit): Promise { + try { + return await fetch(url, init) + } catch (err: any) { + WIKI.logger.warn(`Discord strategy ${this.strategyId} could not reach ${url}: ${err.message}`) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + } + + /** + * A Discord API call, as whoever the authorization says — a person's access token, or the bot. + * + * @returns The parsed body, or null on 404, which is the one status this API uses to mean "no such + * thing" rather than "something went wrong" + * @throws `ERR_PROVIDER_REQUEST_FAILED` on any other unsuccessful answer + */ + private async api(path: string, authorization: string): Promise { + const resp = await this.reach(`${API}${path}`, { + headers: { + Authorization: authorization, + Accept: 'application/json', + 'User-Agent': 'Wiki.js' + } + }) + if (resp.status === 404) { + return null + } + if (!resp.ok) { + WIKI.logger.warn( + `Discord strategy ${this.strategyId} asked for ${path} and the API answered ${resp.status}.` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + return resp.json() + } + + /** + * The server's roles by ID, read with the bot token. + * + * **Without a bot token this is empty and the mapping is by role ID**, because nothing else is + * available: `/users/@me/guilds/{id}/member` names the roles a person holds as snowflakes and no + * endpoint a user token can reach turns those into names. A wiki group then has to be named as the + * ID, which the setting's hint says. + * + * With one, names are the mapping and a failure to read them fails the login. Falling back to IDs + * there would silently change what every group name matches — under `unassignMissingGroups`, into + * taking every mapped membership away — so a bot token that has stopped working is an error to + * raise and not a case to carry on through. + * + * @param refresh Read the list again even if the cached one has not expired. Passed for a role ID + * the cache cannot name, which is what a role created since it was filled looks + * like. + */ + private async roleNames(serverId: string, refresh = false): Promise> { + const botToken = this.conf.botToken || '' + if (!botToken) { + return new Map() + } + if (!refresh && this.roles && this.roles.expires > Date.now()) { + return this.roles.names + } + const roles = await this.api(`/guilds/${encodeURIComponent(serverId)}/roles`, `Bot ${botToken}`) + if (!Array.isArray(roles)) { + // -> 404: no such server, or a bot that is not in it. Either way the names are not readable + WIKI.logger.warn( + `Discord strategy ${this.strategyId} could not read the roles of server ${serverId} — is the bot a member of it?` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + const names = new Map( + roles + .filter((role: any) => typeof role?.id === 'string' && typeof role?.name === 'string') + .map((role: any) => [role.id as string, (role.name as string).trim()]) + ) + this.roles = { names, expires: Date.now() + ROLE_CACHE_MS } + return names + } + + /** + * The wiki group names this person's roles on the server stand for. + * + * `@everyone` is not among them: it is a role every member holds and Discord leaves it out of a + * member's list, which is the answer that wants — a group everybody is in is not a mapping. + */ + private async groupsFor(serverId: string, roleIds: string[]): Promise { + let names = await this.roleNames(serverId) + if (names.size < 1) { + // -> No bot token. The IDs are the whole of what Discord will say about these roles + return roleIds + } + if (roleIds.some((id) => !names.has(id))) { + names = await this.roleNames(serverId, true) + } + /* + A role still unknown after re-reading is one deleted between the two calls — it is not a role + any more, so it names no group. Dropped rather than passed on as its ID, which would only be a + group name by coincidence. + */ + return roleIds.map((id) => names.get(id)).filter((name): name is string => Boolean(name)) + } + + /** The CDN URL of the account's picture, for an account that has set one. */ + private pictureFor(account: Record): string | undefined { + if (typeof account.avatar !== 'string' || !account.avatar) { + return undefined + } + // -> An `a_` hash is an animated avatar, which is a GIF and 404s as anything else + const ext = account.avatar.startsWith('a_') ? 'gif' : 'png' + return `https://cdn.discordapp.com/avatars/${account.id}/${account.avatar}.${ext}?size=256` + } + + async authorizationUrl({ redirectUri, state }: AuthFlow): Promise { + const { clientId, serverId } = this.settings() + const url = new URL(AUTHORIZE_URL) + url.searchParams.set('client_id', clientId) + url.searchParams.set('response_type', 'code') + url.searchParams.set('redirect_uri', redirectUri) + /* + `identify email` is the address and the account; `guilds.members.read` is only asked for when a + server is being enforced, since a scope nobody needs is a scope nobody should be granting. Note + it is not `guilds`, which lists every server the person is in — this one reads their membership + of the servers this application is allowed to ask about, and nothing else. + */ + url.searchParams.set( + 'scope', + serverId ? 'identify email guilds.members.read' : 'identify email' + ) + url.searchParams.set('state', state) + // -> Skips the authorization screen for somebody who has already granted exactly these scopes. + // Discord shows it anyway when they have not, so this is a returning user's convenience + url.searchParams.set('prompt', 'none') + return url.toString() + } + + async profile({ code, redirectUri }: AuthFlowCallback): Promise { + const { clientId, clientSecret, serverId } = this.settings() + if (!code) { + throw new Error('ERR_NO_AUTHORIZATION_CODE') + } + + // -> Form encoding, which is the only thing this endpoint accepts — JSON is a 400 + const tokenResp = await this.reach(`${API}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + 'User-Agent': 'Wiki.js' + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + code + }).toString() + }) + /* + A body that is not JSON at all is something else answering on Discord's behalf — a proxy or a + captive portal — which is a failed exchange and not a parse error to hand to whoever is trying + to log in. + */ + let token: Record + try { + token = (await tokenResp.json()) as Record + } catch { + throw new Error('ERR_TOKEN_EXCHANGE_FAILED') + } + if (!tokenResp.ok || token.error || !token.access_token) { + throw new Error('ERR_TOKEN_EXCHANGE_FAILED') + } + const bearer = `Bearer ${token.access_token}` + + const account = await this.api('/users/@me', bearer) + if (!account?.id) { + throw new Error('ERR_NO_PROVIDER_ACCOUNT') + } + /* + `verified` is Discord's own statement that the address has been confirmed. An unverified one + says nothing about who holds the mailbox, and the mailbox is what an account here is matched + by, so it is refused rather than trusted. + */ + if (!account.email || account.verified !== true) { + throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER') + } + + let roleIds: string[] = [] + if (serverId) { + /* + 404 here is either "not a member" or "no such server", and Discord does not distinguish the + two — so a mistyped Server ID looks exactly like nobody being allowed in. Every other + unsuccessful answer is a failure to find out rather than a refusal, and `api` throws on it: + telling a legitimate member they are not one is an answer that is wrong, unactionable and + indistinguishable in the log from a real refusal. + */ + const member = await this.api( + `/users/@me/guilds/${encodeURIComponent(serverId)}/member`, + bearer + ) + if (!member) { + throw new Error('ERR_ACCOUNT_NOT_ALLOWED') + } + roleIds = Array.isArray(member.roles) + ? member.roles.filter((id: unknown): id is string => typeof id === 'string') + : [] + } + + return { + id: String(account.id), + email: account.email, + // -> `global_name` is the display name; `username` is the handle, and is all an account that + // has not set one has + name: account.global_name || account.username, + picture: this.pictureFor(account), + ...(this.conf.mapGroups === true + ? { + groups: await this.groupsFor(serverId, roleIds), + groupsExclusive: this.conf.unassignMissingGroups === true + } + : {}) + } + } +} diff --git a/backend/modules/authentication/discord/definition.yml b/backend/modules/authentication/discord/definition.yml new file mode 100644 index 000000000..bb16c4cc0 --- /dev/null +++ b/backend/modules/authentication/discord/definition.yml @@ -0,0 +1,61 @@ +key: discord +title: Discord +description: Sign in with a Discord account, optionally only from the members of one Discord server. +author: requarks.io +logo: https://static.requarks.io/logo/discord.svg +icon: /_assets/icons/ultraviolet-discord.svg +color: indigo-6 +isAvailable: true +useForm: false +usernameType: email +props: + clientId: + type: String + title: Client ID + hint: From the OAuth2 page of the application registered in the Discord Developer Portal. + icon: key + order: 1 + clientSecret: + type: String + title: Client Secret + hint: From the same OAuth2 page. Discord shows it once, so reset it there if it was not noted. + icon: password + sensitive: true + order: 2 + serverId: + type: String + title: Restrict to Server + hint: (optional) The ID of a Discord server — turn on Developer Mode in Discord, then right-click the server and Copy Server ID. Only its members may sign in. Required to map groups, since a role belongs to a server. + icon: server + order: 3 + mapGroups: + type: Boolean + title: Map Groups + hint: Put the user in the wiki groups their roles on that server name, on every login. Only groups that already exist here are matched — nothing is created. Needs a Server ID. + icon: user-groups + default: false + order: 4 + botToken: + type: String + title: Bot Token + hint: (optional) A bot token for an application that is a member of the server, from the Bot page of the Developer Portal. With one, roles are matched by NAME. Without one, Discord only tells this wiki a role's numeric ID and a group here has to be named as that ID to match. + icon: bot + sensitive: true + order: 5 + if: + - { key: 'mapGroups', eq: true } + unassignMissingGroups: + type: Boolean + title: Unassign from groups no longer held on the server + hint: Off adds what the roles name and takes nothing away, so a membership granted here survives. On makes the Discord server the authority instead, and a role taken away there is taken away here — bar the groups this strategy auto-enrolls into, which are granted here to everyone it lets in. + icon: unfriend + default: false + order: 6 + if: + - { key: 'mapGroups', eq: true } +refs: + callbackUrl: + title: Redirect URI + hint: Add this to the application's OAuth2 redirects in the Discord Developer Portal. + icon: back + value: '{host}/_api/auth/{id}/callback' diff --git a/backend/modules/authentication/github/authentication.ts b/backend/modules/authentication/github/authentication.ts index fffdc3222..a33b948c4 100644 --- a/backend/modules/authentication/github/authentication.ts +++ b/backend/modules/authentication/github/authentication.ts @@ -1,5 +1,8 @@ import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts' +/** How many pages of a hundred teams are read before the answer is treated as unusable. */ +const MAX_TEAM_PAGES = 10 + /** * GitHub * @@ -9,11 +12,13 @@ import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../model * 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: + * Three 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. + * - an organization can be required, checked against the membership API with the user's own token; + * - the teams within that organization can be mapped onto wiki groups, which is why the mapping is + * only offered alongside the restriction — a team is a thing inside one organization. */ export default class GitHubAuthentication { strategyId: string @@ -26,6 +31,34 @@ export default class GitHubAuthentication { this.conf = conf } + /** + * The strategy's settings, refused if they cannot describe a login. + * + * Mapping groups without an organization is the one combination worth failing over rather than + * working around: a team belongs to an organization, so there would be no teams to read — and + * answering with an empty group list is not the same as answering with nothing. Under + * `unassignMissingGroups` it is a statement that this person is on no team, which would take every + * mapped membership away from everybody who logged in. A misconfiguration must not quietly empty + * the groups it was meant to fill. + * + * @throws `ERR_STRATEGY_MISCONFIGURED` + */ + private settings(): { clientId: string; clientSecret: string; organization: string } { + const clientId = (this.conf.clientId || '').trim() + const clientSecret = this.conf.clientSecret || '' + const organization = (this.conf.allowedOrganization || '').trim() + if (!clientId || !clientSecret) { + throw new Error('ERR_STRATEGY_MISCONFIGURED') + } + if (this.conf.mapGroups === true && !organization) { + WIKI.logger.warn( + `GitHub strategy ${this.strategyId} maps groups but is not restricted to an organization, and a team belongs to one.` + ) + throw new Error('ERR_STRATEGY_MISCONFIGURED') + } + return { clientId, clientSecret, organization } + } + /** 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?:\/\//, '') @@ -106,26 +139,72 @@ export default class GitHubAuthentication { throw new Error('ERR_PROVIDER_REQUEST_FAILED') } - async authorizationUrl({ redirectUri, state }: AuthFlow): Promise { - if (!this.conf.clientId || !this.conf.clientSecret) { - throw new Error('ERR_STRATEGY_MISCONFIGURED') + /** + * The teams this account is on within the organization the strategy requires, by name. + * + * `/user/teams` is the only listing a person's own token can spend: it answers with every team + * they are on across every organization they belong to, so the answer is filtered down to the one + * organization this strategy is about — a team called `admins` in somebody else's organization is + * not a claim on a group here. + * + * **The team's name, not its slug.** They differ as soon as a name has a space or a capital in it + * (`Core Developers` against `core-developers`), and the name is the one an administrator reads + * off GitHub's own screens. Matching is case-insensitive, in `models/users.ts`. + * + * A page short of a hundred is the last one; a run past `MAX_TEAM_PAGES` is not treated as the end + * of the list but as a failure to read it, because a truncated list under `unassignMissingGroups` + * is a list that takes memberships away. + * + * @throws `ERR_PROVIDER_REQUEST_FAILED` when the teams could not be read + */ + private async teamsIn(org: string, accessToken: string): Promise { + const wanted = org.toLowerCase() + const names: string[] = [] + for (let page = 1; page <= MAX_TEAM_PAGES; page++) { + const batch = await this.api(`/user/teams?per_page=100&page=${page}`, accessToken) + if (!Array.isArray(batch)) { + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + for (const team of batch) { + if ( + typeof team?.name === 'string' && + team.name.trim().length > 0 && + typeof team.organization?.login === 'string' && + team.organization.login.toLowerCase() === wanted + ) { + names.push(team.name.trim()) + } + } + if (batch.length < 100) { + return names + } } + WIKI.logger.warn( + `GitHub strategy ${this.strategyId} stopped reading teams after ${MAX_TEAM_PAGES} pages, so the list is incomplete.` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + + async authorizationUrl({ redirectUri, state }: AuthFlow): Promise { + const { clientId, organization } = this.settings() const url = new URL(`${this.hosts.web}/login/oauth/authorize`) - url.searchParams.set('client_id', this.conf.clientId) + url.searchParams.set('client_id', 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. + It covers the teams as well as the membership, so mapping groups asks for nothing further. */ url.searchParams.set( 'scope', - this.conf.allowedOrganization ? 'read:user user:email read:org' : 'read:user user:email' + organization ? 'read:user user:email read:org' : 'read:user user:email' ) url.searchParams.set('state', state) return url.toString() } async profile({ code, redirectUri }: AuthFlowCallback): Promise { + const { clientId, clientSecret, organization } = this.settings() if (!code) { throw new Error('ERR_NO_AUTHORIZATION_CODE') } @@ -138,8 +217,8 @@ export default class GitHubAuthentication { 'User-Agent': 'Wiki.js' }, body: JSON.stringify({ - client_id: this.conf.clientId, - client_secret: this.conf.clientSecret, + client_id: clientId, + client_secret: clientSecret, redirect_uri: redirectUri, code }) @@ -175,9 +254,8 @@ export default class GitHubAuthentication { throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER') } - if (this.conf.allowedOrganization) { - const org = this.conf.allowedOrganization.trim() - if (!(await this.isOrgMember(org, account.login, token.access_token))) { + if (organization) { + if (!(await this.isOrgMember(organization, account.login, token.access_token))) { throw new Error('ERR_ACCOUNT_NOT_ALLOWED') } } @@ -185,7 +263,13 @@ export default class GitHubAuthentication { return { id: String(account.id), email, - name: account.name || account.login + name: account.name || account.login, + ...(this.conf.mapGroups === true + ? { + groups: await this.teamsIn(organization, token.access_token), + groupsExclusive: this.conf.unassignMissingGroups === true + } + : {}) } } } diff --git a/backend/modules/authentication/github/definition.yml b/backend/modules/authentication/github/definition.yml index 3dcd968b3..7682d9c2a 100644 --- a/backend/modules/authentication/github/definition.yml +++ b/backend/modules/authentication/github/definition.yml @@ -31,9 +31,25 @@ props: 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 + 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. Required to map groups, since a team belongs to an organization. + icon: team order: 4 + mapGroups: + type: Boolean + title: Map Groups + hint: Put the user in the wiki groups their teams in that organization name, on every login. Matched on the team's name as GitHub shows it, not its URL slug — only groups that already exist here are matched, and nothing is created. Needs Restrict to Organization. + icon: user-groups + default: false + order: 5 + unassignMissingGroups: + type: Boolean + title: Unassign from groups no longer held as a team + hint: Off adds what the teams name and takes nothing away, so a membership granted here survives. On makes the organization the authority instead, and a team somebody is removed from there is taken away here — bar the groups this strategy auto-enrolls into, which are granted here to everyone it lets in. + icon: unfriend + default: false + order: 6 + if: + - { key: 'mapGroups', eq: true } refs: callbackUrl: title: Authorization Callback URL diff --git a/backend/modules/authentication/google/authentication.ts b/backend/modules/authentication/google/authentication.ts index 4dc925b63..261513146 100644 --- a/backend/modules/authentication/google/authentication.ts +++ b/backend/modules/authentication/google/authentication.ts @@ -4,16 +4,38 @@ import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../model /** Google's issuer, from which every endpoint and signing key is discovered. */ const ISSUER = 'https://accounts.google.com' +/** + * Where a Workspace account's groups are read from, and what it takes to read them. + * + * Not an OpenID Connect thing at all: Google's ID token carries no groups claim and never has, so + * the only way to learn them is to spend the access token on the Cloud Identity API. `groups/-` is + * the whole directory rather than one group — the search is by member, across everything. + */ +const GROUPS_URL = 'https://cloudidentity.googleapis.com/v1/groups/-/memberships:searchDirectGroups' +const GROUPS_SCOPE = 'https://www.googleapis.com/auth/cloud-identity.groups.readonly' + +/** + * The label that distinguishes a Google Group from the other things Cloud Identity calls a group — + * security groups, dynamic groups, the identity-mapped groups other providers put there. The query + * language requires a label, so this is not a filter that could be left off. + */ +const DISCUSSION_FORUM_LABEL = 'cloudidentity.googleapis.com/groups.discussion_forum' + +/** How many pages of five hundred groups are read before the answer is treated as unusable. */ +const MAX_GROUP_PAGES = 10 + /** * Google * - * Google is an OpenID Connect provider, so this is the generic flow with the issuer fixed and two + * Google is an OpenID Connect provider, so this is the generic flow with the issuer fixed and three * 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. + * an unverified one says nothing about who holds the mailbox; + * - groups are a Workspace notion and are nowhere in the token, so mapping them is a call to a + * second API with a scope of its own. See `groupsFor`. * * 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. @@ -46,12 +68,110 @@ export default class GoogleAuthentication { return this.config } + /** + * One page of the Cloud Identity group search, as the person signing in. + * + * @throws `ERR_PROVIDER_REQUEST_FAILED` when the page could not be read. A 403 is the usual one and + * means the account may not see its own memberships — the Cloud Identity API not enabled on + * the project, or a Workspace whose group visibility is closed down — rather than that + * there are none, which is why it is not quietly read as an empty list + */ + private async groupPage(query: string, accessToken: string, pageToken?: string): Promise { + const url = new URL(GROUPS_URL) + url.searchParams.set('query', query) + url.searchParams.set('pageSize', '500') + if (pageToken) { + url.searchParams.set('pageToken', pageToken) + } + let resp: Response + try { + resp = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } + }) + } catch (err: any) { + WIKI.logger.warn( + `Google strategy ${this.strategyId} could not reach the Cloud Identity API: ${err.message}` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + if (!resp.ok) { + WIKI.logger.warn( + `Google strategy ${this.strategyId} asked the Cloud Identity API for group memberships and it answered ${resp.status}.` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + return resp.json() + } + + /** + * The wiki group names this person's Workspace groups stand for. + * + * **An account with no `hd` claim is in no Workspace, and therefore in no groups.** That is a known + * answer rather than a failed one — a personal Google account has nothing for Cloud Identity to + * search — so it is an empty list, and under `unassignMissingGroups` it correctly takes away + * memberships that nothing backs. Every other way of not getting an answer raises instead, since a + * lookup that failed must not be read as a person belonging to nothing. + * + * **Direct memberships only.** `searchDirectGroups` is what a member may run about themselves; + * `searchTransitiveGroups` would follow nesting but is limited to the Enterprise and Cloud Identity + * Premium tiers, so a group that contains another group has to be named here in its own right. + */ + private async groupsFor( + email: string, + hostedDomain: unknown, + accessToken: string + ): Promise { + if (!hostedDomain) { + return [] + } + /* + The address goes into a CEL string literal, so the two characters that could end it early are + escaped. Neither occurs in an address Google issued, which is what this one is — the escaping + is here so that stays a fact about Google rather than an assumption this code rests on. + */ + const member = email.replaceAll('\\', '\\\\').replaceAll("'", "\\'") + const query = `member_key_id == '${member}' && '${DISCUSSION_FORUM_LABEL}' in labels` + const byName = this.conf.groupIdentifier === 'name' + const names: string[] = [] + let pageToken: string | undefined + for (let page = 1; page <= MAX_GROUP_PAGES; page++) { + const body = await this.groupPage(query, accessToken, pageToken) + for (const membership of body?.memberships ?? []) { + const value = byName ? membership?.displayName : membership?.groupKey?.id + if (typeof value === 'string' && value.trim().length > 0) { + names.push(value.trim()) + } + } + pageToken = body?.nextPageToken + if (!pageToken) { + return names + } + } + /* + Not treated as the end of the list but as a failure to read it: a truncated list under + `unassignMissingGroups` is a list that takes memberships away. + */ + WIKI.logger.warn( + `Google strategy ${this.strategyId} stopped reading group memberships after ${MAX_GROUP_PAGES} pages, so the list is incomplete.` + ) + throw new Error('ERR_PROVIDER_REQUEST_FAILED') + } + async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise { const config = await this.configuration() return client .buildAuthorizationUrl(config, { redirect_uri: redirectUri, - scope: 'openid profile email', + /* + The groups scope is only asked for when groups are being mapped: it is one Google + classifies as sensitive, so an app that requests it has to be verified before anybody + outside its own organization can consent to it — which a wiki mapping its own Workspace's + groups does not hit, its OAuth app being internal to that organization. + */ + scope: + this.conf.mapGroups === true + ? `openid profile email ${GROUPS_SCOPE}` + : 'openid profile email', state, nonce, code_challenge: await client.calculatePKCECodeChallenge(codeVerifier), @@ -93,7 +213,13 @@ export default class GoogleAuthentication { return { id: claims.sub, email, - name: (claims.name as string) || email + name: (claims.name as string) || email, + ...(this.conf.mapGroups === true + ? { + groups: await this.groupsFor(email, claims.hd, tokens.access_token), + groupsExclusive: this.conf.unassignMissingGroups === true + } + : {}) } } } diff --git a/backend/modules/authentication/google/definition.yml b/backend/modules/authentication/google/definition.yml index aa1bd9dc9..9eee3282f 100644 --- a/backend/modules/authentication/google/definition.yml +++ b/backend/modules/authentication/google/definition.yml @@ -35,6 +35,34 @@ props: icon: received default: false order: 4 + mapGroups: + type: Boolean + title: Map Groups + hint: Put the user in the wiki groups their Google Workspace groups name, on every login. Only groups that already exist here are matched — nothing is created. Workspace only, and it needs the Cloud Identity API enabled on the Google Cloud project this OAuth client belongs to. + icon: user-groups + default: false + order: 5 + groupIdentifier: + type: String + title: Match Groups By + hint: Which of the two things a Workspace group has is matched against the names of the groups here — the address, engineering@example.com, or the display name, Engineering. The address is unique and survives a rename; the display name reads better, but two groups may share one and then both match. + icon: rules + enum: + - email|Group address + - name|Display name + default: email + order: 6 + if: + - { key: 'mapGroups', eq: true } + unassignMissingGroups: + type: Boolean + title: Unassign from groups no longer present in Workspace + hint: Off adds what Workspace names and takes nothing away, so a membership granted here survives. On makes Workspace the authority instead, and a group somebody is removed from there is taken away here — bar the groups this strategy auto-enrolls into, which are granted here to everyone it lets in. + icon: unfriend + default: false + order: 7 + if: + - { key: 'mapGroups', eq: true } refs: callbackUrl: title: Authorized Redirect URI diff --git a/backend/modules/authentication/oidc/authentication.ts b/backend/modules/authentication/oidc/authentication.ts index b8916f7f4..43abc7b50 100644 --- a/backend/modules/authentication/oidc/authentication.ts +++ b/backend/modules/authentication/oidc/authentication.ts @@ -2,7 +2,7 @@ import * as client from 'openid-client' import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts' /** - * Generic OpenID Connect / OAuth2 + * 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 diff --git a/backend/modules/authentication/oidc/definition.yml b/backend/modules/authentication/oidc/definition.yml index 0d9b23b15..e489e07eb 100644 --- a/backend/modules/authentication/oidc/definition.yml +++ b/backend/modules/authentication/oidc/definition.yml @@ -1,5 +1,5 @@ key: oidc -title: Generic OpenID Connect / OAuth2 +title: 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 diff --git a/frontend/src/components/shared/WItemLabel.vue b/frontend/src/components/shared/WItemLabel.vue index f96ea4e0d..55282362c 100644 --- a/frontend/src/components/shared/WItemLabel.vue +++ b/frontend/src/components/shared/WItemLabel.vue @@ -10,6 +10,10 @@ import { computed } from 'vue' /** * A line of text inside a `WItemSection`. Plain by default, `caption` for the dimmed secondary * line, `header` for a group heading between items. + * + * The dimmed colour of the latter two is a component class in `css/tailwind.css` rather than a + * utility here, so that a caller writing `text-deep-orange` on a caption -- which the admin pages do + * for a warning under a setting -- wins by layer order instead of losing to `dark:text-white/70`. */ const props = defineProps({ /** Smaller, dimmed secondary line. */ @@ -32,8 +36,8 @@ const props = defineProps({ const classes = computed(() => [ 'w-item-label', // -> 16px on every side, as the group headings between items have always been - props.header ? 'w-item-label--header p-4 text-body2 text-black/54 dark:text-white/70' : '', - props.caption && !props.header ? 'w-item-label--caption text-caption text-black/54 dark:text-white/70' : '', + props.header ? 'w-item-label--header p-4 text-body2' : '', + props.caption && !props.header ? 'w-item-label--caption text-caption' : '', !props.caption && !props.header ? 'text-body2' : '', // -> `truncate` covers the single-line case; more than one needs line-clamp Number(props.lines) === 1 ? 'truncate' : '' diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index 44acdeff2..079c0cf89 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -410,6 +410,25 @@ color: var(--color-white); } + /* + The dimmed colour of a `WItemLabel` caption or group heading, here rather than as utilities on + the component's own tag for the same reason `.w-card`'s surface is: it is a DEFAULT callers + replace. The admin pages colour a caption into a warning with `text-deep-orange` / `text-orange` + / `text-red`, and as utilities the two were the same specificity -- so the winner was their + order within the utilities layer, not which one the caller wrote. `dark:text-white/70` came + last, which is why every one of those warnings turned grey in dark mode and stayed red in light. + In this layer it loses to any utility outright, in both modes. + */ + .w-item-label--caption, + .w-item-label--header { + color: rgb(0 0 0 / 0.54); + } + + body.body--dark .w-item-label--caption, + body.body--dark .w-item-label--header { + color: rgb(255 255 255 / 0.7); + } + /* How an image in a `WAvatar` is cropped to the avatar's shape: by taking its radius and filling the box, rather than by the avatar clipping its overflow. Same approach as the avatar this diff --git a/frontend/src/pages/AdminAuth.vue b/frontend/src/pages/AdminAuth.vue index c387d7ec0..db512b549 100644 --- a/frontend/src/pages/AdminAuth.vue +++ b/frontend/src/pages/AdminAuth.vue @@ -140,13 +140,16 @@ - + {{ t(`admin.auth.enabled`) }} {{ t(`admin.auth.enabledHint`) }} {{ t(`admin.auth.enabledForced`) }} + {{ + t(`admin.auth.enabledSiteHint`) + }}