mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
352 lines
15 KiB
352 lines
15 KiB
import { missingSettings, strategyDebug } from '../../../helpers/authDebug.ts'
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
`is not configured: ${missingSettings({ 'Client ID': clientId, 'Client Secret': 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}.`
|
|
)
|
|
// -> The body as well as the status, under the flag: Discord's error payload names the scope
|
|
// that was not granted or the intent the bot is missing, which the status alone does not
|
|
strategyDebug(
|
|
this,
|
|
`GET ${path} answered ${resp.status}: ${await resp.text().catch(() => '(no body)')}`
|
|
)
|
|
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
|
|
strategyDebug(
|
|
this,
|
|
`no Bot Token is configured, so the ${roleIds.length} role(s) held can only be matched by ID: ${roleIds.join(', ') || 'none'}`
|
|
)
|
|
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 {
|
|
strategyDebug(
|
|
this,
|
|
`the token exchange answered ${tokenResp.status} with something that is not JSON — is something else answering for ${API}?`
|
|
)
|
|
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
|
|
}
|
|
if (!tokenResp.ok || token.error || !token.access_token) {
|
|
// -> Discord's own account of the refusal, which names the cause: `invalid_client` for a Client
|
|
// Secret that has been reset, `invalid_grant` for a Redirect URI it does not have registered
|
|
strategyDebug(
|
|
this,
|
|
`the token exchange answered ${tokenResp.status}: ${[token.error, token.error_description].filter(Boolean).join(': ') || 'no 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) {
|
|
strategyDebug(this, 'the API answered with no account for this token')
|
|
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) {
|
|
strategyDebug(
|
|
this,
|
|
`${account.username} ${account.email ? 'has not confirmed their address with Discord' : 'gave no address — was the `email` scope granted?'}`
|
|
)
|
|
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) {
|
|
// -> 404, which Discord uses for both cases. Said as both, since a mistyped Server ID and a
|
|
// person who is not in the server are one answer here and two different things to fix
|
|
strategyDebug(
|
|
this,
|
|
`${account.username} is not in server ${serverId}, or there is no such server`
|
|
)
|
|
throw new Error('ERR_ACCOUNT_NOT_ALLOWED')
|
|
}
|
|
roleIds = Array.isArray(member.roles)
|
|
? member.roles.filter((id: unknown): id is string => typeof id === 'string')
|
|
: []
|
|
}
|
|
|
|
const groups =
|
|
this.conf.mapGroups === true ? await this.groupsFor(serverId, roleIds) : undefined
|
|
strategyDebug(
|
|
this,
|
|
`${account.username} (${account.id}) signs in as <${account.email}>${groups ? `, holding ${groups.length} mapped role(s): ${groups.join(', ') || 'none'}` : ', groups not mapped'}`
|
|
)
|
|
|
|
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),
|
|
...(groups
|
|
? {
|
|
groups,
|
|
groupsExclusive: this.conf.unassignMissingGroups === true
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
}
|