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