mirror of https://github.com/requarks/wiki
parent
d668ba70e3
commit
0052ec618c
@ -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'
|
||||
Loading…
Reference in new issue