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.
320 lines
14 KiB
320 lines
14 KiB
import { missingSettings, strategyDebug } from '../../../helpers/authDebug.ts'
|
|
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
|
|
*
|
|
* GitHub speaks OAuth 2.0 and not OpenID Connect: there is no ID token, and therefore nothing to
|
|
* verify signatures 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 here, so this module is written with `fetch`
|
|
* 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`).
|
|
*
|
|
* 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;
|
|
* - 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
|
|
conf: Record<string, any>
|
|
/** Set by `models/authentication.ts` right after construction. */
|
|
module?: string
|
|
|
|
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 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) {
|
|
strategyDebug(
|
|
this,
|
|
`is not configured: ${missingSettings({ 'Client ID': clientId, 'Client Secret': 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?:\/\//, '')
|
|
return enterprise
|
|
? { web: `https://${enterprise}`, api: `https://${enterprise}/api/v3` }
|
|
: { web: 'https://github.com', api: 'https://api.github.com' }
|
|
}
|
|
|
|
/** The headers GitHub asks every client to send, as this user. */
|
|
private apiHeaders(accessToken: string): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: 'application/vnd.github+json',
|
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
'User-Agent': 'Wiki.js'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `fetch`, with an unreachable GitHub 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(`GitHub strategy ${this.strategyId} could not reach ${url}: ${err.message}`)
|
|
throw new Error('ERR_PROVIDER_REQUEST_FAILED')
|
|
}
|
|
}
|
|
|
|
/** A GitHub API call as this user. */
|
|
private async api(path: string, accessToken: string): Promise<any> {
|
|
const resp = await this.reach(`${this.hosts.api}${path}`, {
|
|
headers: this.apiHeaders(accessToken)
|
|
})
|
|
if (!resp.ok) {
|
|
// -> The body as well as the status: GitHub's error payload names the scope that was not
|
|
// granted or the resource that is not visible, 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()
|
|
}
|
|
|
|
/**
|
|
* Whether this account is a member of the organization the strategy requires.
|
|
*
|
|
* `GET /orgs/{org}/members/{username}` answers from the point of view of whoever is asking, and the
|
|
* token asking here belongs to the person signing in — so a member checking themselves gets 204. A
|
|
* non-member gets a 302 to `/orgs/{org}/public_members/{username}`, which `fetch` follows on its
|
|
* own (same origin, so the Authorization header survives it). The consequence worth knowing: the
|
|
* question quietly becomes "is a PUBLIC member" whenever the token cannot see private membership —
|
|
* an organization with OAuth app access restrictions that has not approved this app — which is why
|
|
* the setting's hint asks for either a public membership or an approved app.
|
|
*
|
|
* **Only 404 is a refusal.** Every other answer is a failure to find out: a token revoked between
|
|
* the exchange and here, an abuse-detection 403, GitHub being down. Reporting those as "you are not
|
|
* a member of this organization" sends a legitimate member away with an answer that is wrong,
|
|
* unactionable, and indistinguishable in the log from a genuine refusal.
|
|
*
|
|
* @throws `ERR_PROVIDER_REQUEST_FAILED` when membership could not be determined
|
|
*/
|
|
private async isOrgMember(org: string, login: string, accessToken: string): Promise<boolean> {
|
|
const path = `/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(login)}`
|
|
const resp = await this.reach(`${this.hosts.api}${path}`, {
|
|
headers: this.apiHeaders(accessToken)
|
|
})
|
|
if (resp.status === 204) {
|
|
strategyDebug(this, `${login} is a member of ${org}`)
|
|
return true
|
|
}
|
|
if (resp.status === 404) {
|
|
strategyDebug(
|
|
this,
|
|
`${login} is not a member of ${org} as far as this token can see — a private membership needs the OAuth app approved by the organization`
|
|
)
|
|
return false
|
|
}
|
|
WIKI.logger.warn(
|
|
`GitHub strategy ${this.strategyId} could not check membership of ${org} for ${login}: the API answered ${resp.status}.`
|
|
)
|
|
throw new Error('ERR_PROVIDER_REQUEST_FAILED')
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
strategyDebug(this, `${names.length} team(s) in ${org}: ${names.join(', ') || 'none'}`)
|
|
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', 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',
|
|
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')
|
|
}
|
|
// -> `Accept: application/json`, or GitHub answers this one in form encoding
|
|
const tokenResp = await this.reach(`${this.hosts.web}/login/oauth/access_token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'Wiki.js'
|
|
},
|
|
body: JSON.stringify({
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
redirect_uri: redirectUri,
|
|
code
|
|
})
|
|
})
|
|
/*
|
|
GitHub reports a refused exchange as 200 with an `error` field rather than as a status, so the
|
|
body has to be read either way — and a body that is not JSON at all is something else answering
|
|
on GitHub's behalf, a proxy or a captive portal, which is a failed exchange and not a parse
|
|
error to hand to the person logging 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 ${this.hosts.web}?`
|
|
)
|
|
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
|
|
}
|
|
if (!tokenResp.ok || token.error || !token.access_token) {
|
|
// -> GitHub's own account of the refusal, which names the cause: `bad_verification_code` for a
|
|
// code already spent, `incorrect_client_credentials` for a Client Secret that has been reset
|
|
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 account = await this.api('/user', token.access_token)
|
|
if (!account?.id) {
|
|
strategyDebug(this, 'the API answered with no account for this token')
|
|
throw new Error('ERR_NO_PROVIDER_ACCOUNT')
|
|
}
|
|
|
|
/*
|
|
The primary verified address, which is the only one that says anything: `account.email` is
|
|
whatever the profile shows publicly, is frequently null, and is never checked by GitHub.
|
|
*/
|
|
const emails: any[] = await this.api('/user/emails', token.access_token)
|
|
const email = emails?.find((entry) => entry.primary && entry.verified)?.email
|
|
if (!email) {
|
|
// -> Counts rather than the addresses themselves, which are not needed to tell the two cases
|
|
// apart: no addresses at all is the `user:email` scope missing, and addresses with no
|
|
// verified primary among them is an account that has to confirm one at GitHub first
|
|
strategyDebug(
|
|
this,
|
|
`${account.login} has no verified primary address (${emails?.length ?? 0} address(es) readable, ${emails?.filter((entry) => entry.verified).length ?? 0} verified)`
|
|
)
|
|
throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER')
|
|
}
|
|
|
|
if (organization) {
|
|
if (!(await this.isOrgMember(organization, account.login, token.access_token))) {
|
|
throw new Error('ERR_ACCOUNT_NOT_ALLOWED')
|
|
}
|
|
}
|
|
|
|
const groups =
|
|
this.conf.mapGroups === true
|
|
? await this.teamsIn(organization, token.access_token)
|
|
: undefined
|
|
strategyDebug(
|
|
this,
|
|
`${account.login} (${account.id}) signs in as <${email}>${groups ? `, on ${groups.length} team(s)` : ', groups not mapped'}`
|
|
)
|
|
|
|
return {
|
|
id: String(account.id),
|
|
email,
|
|
name: account.name || account.login,
|
|
...(groups
|
|
? {
|
|
groups,
|
|
groupsExclusive: this.conf.unassignMissingGroups === true
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
}
|