mirror of https://github.com/requarks/wiki
parent
14e1efae41
commit
ff4a5bc6e6
@ -0,0 +1,137 @@
|
|||||||
|
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`).
|
||||||
|
*
|
||||||
|
* Two 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.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GitHub API call as this user, with the headers GitHub asks every client to send. */
|
||||||
|
private async api(path: string, accessToken: string): Promise<any> {
|
||||||
|
const resp = await fetch(`${this.hosts.api}${path}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
'User-Agent': 'Wiki.js'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`ERR_PROVIDER_REQUEST_FAILED`)
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorizationUrl({ redirectUri, state }: AuthFlow): Promise<string> {
|
||||||
|
if (!this.conf.clientId || !this.conf.clientSecret) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
const url = new URL(`${this.hosts.web}/login/oauth/authorize`)
|
||||||
|
url.searchParams.set('client_id', this.conf.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.
|
||||||
|
*/
|
||||||
|
url.searchParams.set(
|
||||||
|
'scope',
|
||||||
|
this.conf.allowedOrganization ? '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> {
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('ERR_NO_AUTHORIZATION_CODE')
|
||||||
|
}
|
||||||
|
// -> `Accept: application/json`, or GitHub answers this one in form encoding
|
||||||
|
const tokenResp = await fetch(`${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: this.conf.clientId,
|
||||||
|
client_secret: this.conf.clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const token = (await tokenResp.json()) as Record<string, any>
|
||||||
|
// -> GitHub reports a refused exchange as 200 with an `error` field, not as a status
|
||||||
|
if (!tokenResp.ok || token.error || !token.access_token) {
|
||||||
|
throw new Error('ERR_TOKEN_EXCHANGE_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await this.api('/user', token.access_token)
|
||||||
|
if (!account?.id) {
|
||||||
|
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) {
|
||||||
|
throw new Error('ERR_NO_VERIFIED_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.conf.allowedOrganization) {
|
||||||
|
const org = this.conf.allowedOrganization.trim()
|
||||||
|
const resp = await fetch(
|
||||||
|
`${this.hosts.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(account.login)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.access_token}`,
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'X-GitHub-Api-Version': '2022-11-28',
|
||||||
|
'User-Agent': 'Wiki.js'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// -> 204 is a member, 302 is "ask as somebody who can see", 404 is not a member
|
||||||
|
if (resp.status !== 204) {
|
||||||
|
throw new Error('ERR_LOGIN_RESTRICTED')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(account.id),
|
||||||
|
email,
|
||||||
|
name: account.name || account.login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
key: github
|
||||||
|
title: GitHub
|
||||||
|
description: Sign in with a GitHub account, on github.com or a GitHub Enterprise Server.
|
||||||
|
author: requarks.io
|
||||||
|
logo: https://static.requarks.io/logo/github.svg
|
||||||
|
icon: /_assets/icons/ultraviolet-github.svg
|
||||||
|
color: dark-4
|
||||||
|
vendor: 'GitHub, Inc.'
|
||||||
|
website: 'https://docs.github.com/en/apps/oauth-apps'
|
||||||
|
isAvailable: true
|
||||||
|
useForm: false
|
||||||
|
usernameType: email
|
||||||
|
props:
|
||||||
|
clientId:
|
||||||
|
type: String
|
||||||
|
title: Client ID
|
||||||
|
hint: From the OAuth app registered under Developer settings.
|
||||||
|
icon: key
|
||||||
|
order: 1
|
||||||
|
clientSecret:
|
||||||
|
type: String
|
||||||
|
title: Client Secret
|
||||||
|
hint: From the same OAuth app.
|
||||||
|
icon: password
|
||||||
|
sensitive: true
|
||||||
|
order: 2
|
||||||
|
enterpriseHost:
|
||||||
|
type: String
|
||||||
|
title: GitHub Enterprise Host
|
||||||
|
hint: (optional) Hostname of a GitHub Enterprise Server, e.g. github.example.com. Leave empty for github.com.
|
||||||
|
icon: server
|
||||||
|
order: 3
|
||||||
|
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
|
||||||
|
order: 4
|
||||||
|
refs:
|
||||||
|
callbackUrl:
|
||||||
|
title: Authorization Callback URL
|
||||||
|
hint: Set this as the OAuth app's callback URL on GitHub.
|
||||||
|
icon: back
|
||||||
|
value: '{host}/_api/auth/{id}/callback'
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
import * as client from 'openid-client'
|
||||||
|
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/** Google's issuer, from which every endpoint and signing key is discovered. */
|
||||||
|
const ISSUER = 'https://accounts.google.com'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google
|
||||||
|
*
|
||||||
|
* Google is an OpenID Connect provider, so this is the generic flow with the issuer fixed and two
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default class GoogleAuthentication {
|
||||||
|
strategyId: string
|
||||||
|
conf: Record<string, any>
|
||||||
|
/** Set by `models/authentication.ts` right after construction. */
|
||||||
|
module?: string
|
||||||
|
|
||||||
|
private config: client.Configuration | null = null
|
||||||
|
|
||||||
|
constructor(strategyId: string, conf: Record<string, any>) {
|
||||||
|
this.strategyId = strategyId
|
||||||
|
this.conf = conf
|
||||||
|
}
|
||||||
|
|
||||||
|
private async configuration(): Promise<client.Configuration> {
|
||||||
|
if (this.config) {
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
if (!this.conf.clientId || !this.conf.clientSecret) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
this.config = await client.discovery(
|
||||||
|
new URL(ISSUER),
|
||||||
|
this.conf.clientId,
|
||||||
|
this.conf.clientSecret
|
||||||
|
)
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
// -> Which accounts the chooser offers. The answer is still checked below.
|
||||||
|
...(this.conf.hostedDomain ? { hd: this.conf.hostedDomain } : {})
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async profile({
|
||||||
|
currentUrl,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
codeVerifier
|
||||||
|
}: AuthFlowCallback): Promise<ProviderProfile> {
|
||||||
|
const config = await this.configuration()
|
||||||
|
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
||||||
|
expectedState: state,
|
||||||
|
expectedNonce: nonce,
|
||||||
|
pkceCodeVerifier: codeVerifier
|
||||||
|
})
|
||||||
|
const claims = tokens.claims() as Record<string, any> | undefined
|
||||||
|
if (!claims?.sub) {
|
||||||
|
throw new Error('ERR_NO_ID_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = claims.email
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
if (claims.email_verified === false && this.conf.allowUnverifiedEmail !== true) {
|
||||||
|
throw new Error('ERR_EMAIL_NOT_VERIFIED')
|
||||||
|
}
|
||||||
|
if (this.conf.hostedDomain && claims.hd !== this.conf.hostedDomain) {
|
||||||
|
throw new Error('ERR_LOGIN_RESTRICTED')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: claims.sub,
|
||||||
|
email,
|
||||||
|
name: (claims.name as string) || email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
key: google
|
||||||
|
title: Google
|
||||||
|
description: Sign in with a Google account or a Google Workspace domain.
|
||||||
|
author: requarks.io
|
||||||
|
logo: https://static.requarks.io/logo/google.svg
|
||||||
|
icon: /_assets/icons/ultraviolet-google.svg
|
||||||
|
color: red-6
|
||||||
|
vendor: 'Google LLC'
|
||||||
|
website: 'https://developers.google.com/identity/openid-connect/openid-connect'
|
||||||
|
isAvailable: true
|
||||||
|
useForm: false
|
||||||
|
usernameType: email
|
||||||
|
props:
|
||||||
|
clientId:
|
||||||
|
type: String
|
||||||
|
title: Client ID
|
||||||
|
hint: From the OAuth 2.0 Client ID created in the Google Cloud console.
|
||||||
|
icon: key
|
||||||
|
order: 1
|
||||||
|
clientSecret:
|
||||||
|
type: String
|
||||||
|
title: Client Secret
|
||||||
|
hint: From the same OAuth 2.0 Client ID.
|
||||||
|
icon: password
|
||||||
|
sensitive: true
|
||||||
|
order: 2
|
||||||
|
hostedDomain:
|
||||||
|
type: String
|
||||||
|
title: Restrict to Workspace Domain
|
||||||
|
hint: (optional) A Workspace domain, e.g. example.com. Only accounts on it may sign in — checked here as well as asked for, since the parameter alone is a hint to Google rather than a guarantee.
|
||||||
|
icon: geography
|
||||||
|
order: 3
|
||||||
|
allowUnverifiedEmail:
|
||||||
|
type: Boolean
|
||||||
|
title: Accept Unverified Addresses
|
||||||
|
hint: Off by default. A Google account whose address is unverified proves nothing about the mailbox, and an account here is matched on the address.
|
||||||
|
icon: received
|
||||||
|
default: false
|
||||||
|
order: 4
|
||||||
|
refs:
|
||||||
|
callbackUrl:
|
||||||
|
title: Authorized Redirect URI
|
||||||
|
hint: Add this to the OAuth client's authorized redirect URIs in the Google Cloud console.
|
||||||
|
icon: back
|
||||||
|
value: '{host}/_api/auth/{id}/callback'
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
import * as client from 'openid-client'
|
||||||
|
import type { AuthFlow, AuthFlowCallback, ProviderProfile } from '../../../models/authentication.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic 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
|
||||||
|
* verified here against the provider's published keys — issuer, audience, nonce and signature — before
|
||||||
|
* anything is believed about the person behind it.
|
||||||
|
*
|
||||||
|
* That verification is why this goes through `openid-client` rather than a handful of `fetch` calls.
|
||||||
|
* The requests themselves are trivial; the checks around them are where a mistake is silent, because
|
||||||
|
* a token that is never verified still logs somebody in.
|
||||||
|
*/
|
||||||
|
export default class OidcAuthentication {
|
||||||
|
strategyId: string
|
||||||
|
conf: Record<string, any>
|
||||||
|
/** Set by `models/authentication.ts` right after construction. */
|
||||||
|
module?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider as `openid-client` sees it. Built once and kept: with discovery on it is a network
|
||||||
|
* round trip, and it is the same answer for every login until the strategy is saved again.
|
||||||
|
*/
|
||||||
|
private config: client.Configuration | null = null
|
||||||
|
|
||||||
|
constructor(strategyId: string, conf: Record<string, any>) {
|
||||||
|
this.strategyId = strategyId
|
||||||
|
this.conf = conf
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the provider's metadata.
|
||||||
|
*
|
||||||
|
* Discovery is the path worth taking: the endpoints AND the signing keys come from the issuer
|
||||||
|
* itself, so a provider rotating either is followed without an administrator editing anything. The
|
||||||
|
* manual path exists for providers that publish no discovery document, and needs the JWKS URL for
|
||||||
|
* the same reason — without keys there is nothing to check the ID token against.
|
||||||
|
*/
|
||||||
|
private async configuration(): Promise<client.Configuration> {
|
||||||
|
if (this.config) {
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
const { clientId, clientSecret, issuer } = this.conf
|
||||||
|
if (!clientId || !clientSecret || !issuer) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
if (this.conf.useDiscovery !== false) {
|
||||||
|
this.config = await client.discovery(new URL(issuer), clientId, clientSecret)
|
||||||
|
} else {
|
||||||
|
if (!this.conf.authorizationURL || !this.conf.tokenURL || !this.conf.jwksURL) {
|
||||||
|
throw new Error('ERR_STRATEGY_MISCONFIGURED')
|
||||||
|
}
|
||||||
|
this.config = new client.Configuration(
|
||||||
|
{
|
||||||
|
issuer,
|
||||||
|
authorization_endpoint: this.conf.authorizationURL,
|
||||||
|
token_endpoint: this.conf.tokenURL,
|
||||||
|
userinfo_endpoint: this.conf.userInfoURL || undefined,
|
||||||
|
jwks_uri: this.conf.jwksURL
|
||||||
|
},
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where to send the browser to sign in. */
|
||||||
|
async authorizationUrl({ redirectUri, state, nonce, codeVerifier }: AuthFlow): Promise<string> {
|
||||||
|
const config = await this.configuration()
|
||||||
|
return client
|
||||||
|
.buildAuthorizationUrl(config, {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: this.conf.scopes || 'openid profile email',
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the code the provider sent back into who signed in.
|
||||||
|
*
|
||||||
|
* `authorizationCodeGrant` is what does the checking: it refuses a response whose state does not
|
||||||
|
* match the one this flow started with, exchanges the code with the PKCE verifier, and validates
|
||||||
|
* the ID token's signature, issuer, audience and nonce. Everything after it is reading claims.
|
||||||
|
*/
|
||||||
|
async profile({
|
||||||
|
currentUrl,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
codeVerifier
|
||||||
|
}: AuthFlowCallback): Promise<ProviderProfile> {
|
||||||
|
const config = await this.configuration()
|
||||||
|
const tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
|
||||||
|
expectedState: state,
|
||||||
|
expectedNonce: nonce,
|
||||||
|
pkceCodeVerifier: codeVerifier
|
||||||
|
})
|
||||||
|
const claims = tokens.claims()
|
||||||
|
if (!claims?.sub) {
|
||||||
|
throw new Error('ERR_NO_ID_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The userinfo endpoint is consulted when the provider has one, because a provider is free to keep
|
||||||
|
claims out of the ID token and behind it — several put the email address there only. Its answer
|
||||||
|
is merged over the token's, and `fetchUserInfo` checks that it is about the same subject.
|
||||||
|
*/
|
||||||
|
let info: Record<string, any> = claims
|
||||||
|
if (config.serverMetadata().userinfo_endpoint) {
|
||||||
|
info = {
|
||||||
|
...claims,
|
||||||
|
...(await client.fetchUserInfo(config, tokens.access_token, claims.sub))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = info[this.conf.emailClaim || 'email']
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
throw new Error('ERR_NO_EMAIL_FROM_PROVIDER')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: claims.sub,
|
||||||
|
email,
|
||||||
|
name: (info[this.conf.displayNameClaim || 'name'] as string) || email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a logout should continue, so that the session at the provider ends too. */
|
||||||
|
logoutUrl(): string | null {
|
||||||
|
return this.conf.logoutURL || null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
key: oidc
|
||||||
|
title: Generic 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
|
||||||
|
icon: /_assets/icons/ultraviolet-openid.svg
|
||||||
|
color: blue-grey-8
|
||||||
|
vendor: 'OpenID Foundation'
|
||||||
|
website: 'https://openid.net/connect/'
|
||||||
|
isAvailable: true
|
||||||
|
useForm: false
|
||||||
|
usernameType: email
|
||||||
|
props:
|
||||||
|
clientId:
|
||||||
|
type: String
|
||||||
|
title: Client ID
|
||||||
|
hint: Application Client ID, as the provider issued it.
|
||||||
|
icon: key
|
||||||
|
order: 1
|
||||||
|
clientSecret:
|
||||||
|
type: String
|
||||||
|
title: Client Secret
|
||||||
|
hint: Application Client Secret, as the provider issued it.
|
||||||
|
icon: password
|
||||||
|
sensitive: true
|
||||||
|
order: 2
|
||||||
|
issuer:
|
||||||
|
type: String
|
||||||
|
title: Issuer
|
||||||
|
hint: The provider's issuer URL, e.g. https://id.example.com. Everything else is discovered from it.
|
||||||
|
icon: internet
|
||||||
|
order: 3
|
||||||
|
useDiscovery:
|
||||||
|
type: Boolean
|
||||||
|
title: Use Discovery
|
||||||
|
hint: Read the endpoints and signing keys from the issuer's /.well-known/openid-configuration. Turn off only for a provider that does not publish one, and fill in the endpoints below.
|
||||||
|
icon: rescan-document
|
||||||
|
default: true
|
||||||
|
order: 4
|
||||||
|
authorizationURL:
|
||||||
|
type: String
|
||||||
|
title: Authorization Endpoint URL
|
||||||
|
hint: Ignored while discovery is on.
|
||||||
|
icon: enter
|
||||||
|
order: 5
|
||||||
|
tokenURL:
|
||||||
|
type: String
|
||||||
|
title: Token Endpoint URL
|
||||||
|
hint: Ignored while discovery is on.
|
||||||
|
icon: exit
|
||||||
|
order: 6
|
||||||
|
userInfoURL:
|
||||||
|
type: String
|
||||||
|
title: User Info Endpoint URL
|
||||||
|
hint: Ignored while discovery is on. Optional even without it — the ID token alone can carry everything needed.
|
||||||
|
icon: contact
|
||||||
|
order: 7
|
||||||
|
jwksURL:
|
||||||
|
type: String
|
||||||
|
title: JSON Web Key Set URL
|
||||||
|
hint: Ignored while discovery is on. Where the keys that signed the ID token are published; without it the ID token cannot be verified and logins are refused.
|
||||||
|
icon: fingerprint-scan
|
||||||
|
order: 8
|
||||||
|
scopes:
|
||||||
|
type: String
|
||||||
|
title: Scopes
|
||||||
|
hint: Space-separated. `openid` is required; `email` is what an account is matched on here.
|
||||||
|
icon: rules
|
||||||
|
default: 'openid profile email'
|
||||||
|
order: 9
|
||||||
|
emailClaim:
|
||||||
|
type: String
|
||||||
|
title: Email Claim
|
||||||
|
hint: Which claim carries the email address.
|
||||||
|
icon: envelope
|
||||||
|
default: email
|
||||||
|
order: 10
|
||||||
|
displayNameClaim:
|
||||||
|
type: String
|
||||||
|
title: Display Name Claim
|
||||||
|
hint: Which claim carries the name to show. Falls back to the email address when the claim is absent.
|
||||||
|
icon: person
|
||||||
|
default: name
|
||||||
|
order: 11
|
||||||
|
logoutURL:
|
||||||
|
type: String
|
||||||
|
title: Logout URL
|
||||||
|
hint: (optional) Where to send a user after logging out, so that the session at the provider ends too.
|
||||||
|
icon: exit
|
||||||
|
order: 12
|
||||||
|
refs:
|
||||||
|
callbackUrl:
|
||||||
|
title: Authorization Callback URL
|
||||||
|
hint: Register this as the redirect URI at the provider. It is the same for every provider.
|
||||||
|
icon: back
|
||||||
|
value: '{host}/_api/auth/{id}/callback'
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<w-dialog v-model="dialogVisible" max-width="450px" @hide="onDialogHide">
|
||||||
|
<w-card style="min-width: 350px">
|
||||||
|
<w-card-section class="card-header">
|
||||||
|
<w-icon name="img:/_assets/icons/fluent-delete-bin.svg" size="sm" class="mr-2" />
|
||||||
|
<span>{{ t(`admin.users.deleteConfirmTitle`) }}</span>
|
||||||
|
</w-card-section>
|
||||||
|
<w-card-section>
|
||||||
|
<div class="text-body2">
|
||||||
|
<i18n-t keypath="admin.users.deleteConfirmText">
|
||||||
|
<template #username>
|
||||||
|
<strong>{{ props.user.name }}</strong>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
Said before the attempt rather than only when it fails: a user who has written anything
|
||||||
|
cannot be deleted at all, and finding that out from an error after confirming is finding it
|
||||||
|
out too late to have chosen deactivation instead.
|
||||||
|
-->
|
||||||
|
<div class="text-body2 mt-4">{{ t(`admin.users.deleteConfirmForeignNotice`) }}</div>
|
||||||
|
<div class="text-body2 mt-4">
|
||||||
|
<strong class="text-negative">{{ t(`admin.users.deleteHint`) }}</strong>
|
||||||
|
</div>
|
||||||
|
</w-card-section>
|
||||||
|
<w-card-actions class="card-actions">
|
||||||
|
<w-space />
|
||||||
|
<w-btn
|
||||||
|
class="acrylic-btn"
|
||||||
|
flat
|
||||||
|
:label="t(`common.actions.cancel`)"
|
||||||
|
color="grey"
|
||||||
|
padding="xs md"
|
||||||
|
@click="onDialogCancel" />
|
||||||
|
<w-btn
|
||||||
|
unelevated
|
||||||
|
:label="t(`common.actions.delete`)"
|
||||||
|
color="negative"
|
||||||
|
padding="xs md"
|
||||||
|
:loading="state.isDeleting"
|
||||||
|
@click="confirm" />
|
||||||
|
</w-card-actions>
|
||||||
|
</w-card>
|
||||||
|
</w-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||||
|
import { notify } from '@/composables/notify'
|
||||||
|
|
||||||
|
// PROPS
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
user: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// EMITS
|
||||||
|
|
||||||
|
defineEmits([...dialogComponentEmits])
|
||||||
|
|
||||||
|
// DIALOG
|
||||||
|
|
||||||
|
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
isDeleting: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
state.isDeleting = true
|
||||||
|
try {
|
||||||
|
const resp = await API_CLIENT.delete(`users/${props.user.id}`)
|
||||||
|
if (!resp?.ok) {
|
||||||
|
throw new Error((await resp.json())?.message || 'An unexpected error occured.')
|
||||||
|
}
|
||||||
|
notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: t('admin.users.deleteSuccess', { username: props.user.name })
|
||||||
|
})
|
||||||
|
onDialogOK()
|
||||||
|
} catch (err) {
|
||||||
|
/*
|
||||||
|
ky throws for statuses above 400, and this endpoint has several things to say through one: the
|
||||||
|
account owns pages, it is the last root administrator, it is a system user, it is the caller's
|
||||||
|
own. The reason is in the body, so the dialog stays open with it rather than closing on a
|
||||||
|
failure it did not report.
|
||||||
|
*/
|
||||||
|
const apiMessage = await err.response
|
||||||
|
?.json()
|
||||||
|
.then((b) => b?.message)
|
||||||
|
.catch(() => null)
|
||||||
|
notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: apiMessage || err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.isDeleting = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Loading…
Reference in new issue