feat: add discord auth + map groups for github + google

scarlett
NGPixel 3 days ago
parent d668ba70e3
commit 0052ec618c
No known key found for this signature in database

@ -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",

@ -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<string, any>
/** 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<string, string>; expires: number } | null = null
constructor(strategyId: string, conf: Record<string, any>) {
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<Response> {
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<any | null> {
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<Map<string, string>> {
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<string, string>(
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<string[]> {
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, any>): 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<string> {
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<ProviderProfile> {
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<string, any>
try {
token = (await tokenResp.json()) as Record<string, any>
} 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
}
: {})
}
}
}

@ -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'

@ -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<string> {
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<string[]> {
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<string> {
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<ProviderProfile> {
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
}
: {})
}
}
}

@ -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

@ -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<any> {
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<string[]> {
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<string> {
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
}
: {})
}
}
}

@ -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

@ -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

@ -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

@ -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' : ''

@ -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

@ -140,13 +140,16 @@
</w-item>
<w-separator class="my-2" inset />
<w-item tag="label">
<blueprint-icon icon="shutdown" />
<blueprint-icon icon="shutdown" top />
<w-item-section>
<w-item-label>{{ t(`admin.auth.enabled`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.auth.enabledHint`) }}</w-item-label>
<w-item-label class="text-deep-orange" v-if="isBuiltInLocal" caption>{{
t(`admin.auth.enabledForced`)
}}</w-item-label>
<w-item-label class="text-deep-orange" caption>{{
t(`admin.auth.enabledSiteHint`)
}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle

Loading…
Cancel
Save