New OpenID and OAuth2 drivers (#8660)

* Moved over oauth impl to new interface

* Fixed most build issues and started addind schema to auth drivers

* Finished up OAuth2 and OpenID drivers

* Removed unused migration and utils

* Fixed minor todos

* Removed old oauth flow

* Changed oauth flow to re-use refresh token

* Added new oauth frontend

* Added font awesome social icons

* Updated authentication documentation

* Update api/src/auth/drivers/oauth2.ts

* Tested implementation and fixed incorrect validation

* Updated docs

* Improved OAuth error handling and re-enabled creating users with provider/identifier

* Removed Session config from docs

* Update app/src/components/v-icon/v-icon.vue

* Removed oauth need to define default roleID

* Added FormatTitle to SSO links

* Prevent local auth without password

* Store OAuth access token in session data

* Update docs/guides/api-config.md

* Fixed copy and removed fontawesome-vue dependency

* More docs fixes

* Crucialy importend type fiks

* Update package-lock

* Remove is-email-allowed check

In favor of more advanced version based on filtering coming later

* Fix JSON type casting

* Delete unused util

* Update type signature to include name

* Add warning when code isn't found in oauth url

and remove obsolete imports

* Auto-continue on successful SSO login

* Tweak type signature

* More type casting shenanigans

* Please the TS gods

* Check for missing token before crashing

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Aiden Foxx
2021-10-21 23:45:01 +02:00
committed by GitHub
parent 1b64b4472a
commit fa3b1171e8
36 changed files with 1747 additions and 822 deletions

View File

@@ -104,10 +104,8 @@
"execa": "^5.1.1",
"exifr": "^7.1.2",
"express": "^4.17.1",
"express-session": "^1.17.2",
"flat": "^5.0.2",
"fs-extra": "^10.0.0",
"grant": "^5.4.14",
"graphql": "^15.5.0",
"graphql-compose": "^9.0.1",
"inquirer": "^8.1.1",
@@ -130,6 +128,7 @@
"nodemailer": "^6.6.1",
"object-hash": "^2.2.0",
"openapi3-ts": "^2.0.0",
"openid-client": "^4.9.0",
"ora": "^5.4.0",
"otplib": "^12.0.1",
"pino": "6.13.3",

View File

@@ -47,8 +47,8 @@ import { track } from './utils/track';
import { validateEnv } from './utils/validate-env';
import { validateStorage } from './utils/validate-storage';
import { register as registerWebhooks } from './webhooks';
import { session } from './middleware/session';
import { flushCaches } from './cache';
import { registerAuthProviders } from './auth';
import { Url } from './utils/url';
export default async function createApp(): Promise<express.Application> {
@@ -74,6 +74,8 @@ export default async function createApp(): Promise<express.Application> {
await flushCaches();
await registerAuthProviders();
const extensionManager = getExtensionManager();
await extensionManager.initialize();
@@ -148,9 +150,6 @@ export default async function createApp(): Promise<express.Application> {
app.use(rateLimiter);
}
// We only rely on cookie-sessions in the oAuth flow where it's required
app.use(session);
app.use(authenticate);
app.use(checkIP);

View File

@@ -2,10 +2,12 @@ import getDatabase from './database';
import env from './env';
import logger from './logger';
import { AuthDriver } from './auth/auth';
import { LocalAuthDriver } from './auth/drivers/';
import { LocalAuthDriver, OAuth2AuthDriver, OpenIDAuthDriver } from './auth/drivers';
import { DEFAULT_AUTH_PROVIDER } from './constants';
import { InvalidConfigException } from './exceptions';
import { AuthDriverOptions } from './types';
import { getConfigFromEnv } from './utils/get-config-from-env';
import { getSchema } from './utils/get-schema';
import { toArray } from '@directus/shared/utils';
const providerNames = toArray(env.AUTH_PROVIDERS);
@@ -13,11 +15,6 @@ const providerNames = toArray(env.AUTH_PROVIDERS);
const providers: Map<string, AuthDriver> = new Map();
export function getAuthProvider(provider: string): AuthDriver {
// When providers haven't been registered yet
if (providerNames.length !== providers.size) {
registerProviders();
}
if (!providers.has(provider)) {
throw new InvalidConfigException('Auth provider not configured', { provider });
}
@@ -25,16 +22,12 @@ export function getAuthProvider(provider: string): AuthDriver {
return providers.get(provider)!;
}
function getProviderInstance(driver: string, config: Record<string, any>): AuthDriver | undefined {
switch (driver) {
case 'local':
return new LocalAuthDriver(getDatabase(), config);
}
}
export async function registerAuthProviders(): Promise<void> {
const options = { knex: getDatabase(), schema: await getSchema() };
const defaultProvider = getProviderInstance('local', options)!;
function registerProviders() {
// Register default provider
providers.set(DEFAULT_AUTH_PROVIDER, getProviderInstance('local', {}) as AuthDriver);
providers.set(DEFAULT_AUTH_PROVIDER, defaultProvider);
if (!env.AUTH_PROVIDERS) {
return;
@@ -56,7 +49,7 @@ function registerProviders() {
return;
}
const provider = getProviderInstance(driver, { provider: name, ...config });
const provider = getProviderInstance(driver, options, { provider: name, ...config });
if (!provider) {
logger.warn(`Invalid "${driver}" auth driver.`);
@@ -66,3 +59,20 @@ function registerProviders() {
providers.set(name, provider);
});
}
function getProviderInstance(
driver: string,
options: AuthDriverOptions,
config: Record<string, any> = {}
): AuthDriver | undefined {
switch (driver) {
case 'local':
return new LocalAuthDriver(options, config);
case 'oauth2':
return new OAuth2AuthDriver(options, config);
case 'openid':
return new OpenIDAuthDriver(options, config);
}
}

View File

@@ -1,11 +1,13 @@
import { Knex } from 'knex';
import { User, SessionData } from '../types';
import { AuthDriverOptions, SchemaOverview, User, SessionData } from '../types';
export abstract class AuthDriver {
knex: Knex;
schema: SchemaOverview;
constructor(knex: Knex, _config: Record<string, any>) {
this.knex = knex;
constructor(options: AuthDriverOptions, _config: Record<string, any>) {
this.knex = options.knex;
this.schema = options.schema;
}
/**
@@ -46,8 +48,9 @@ export abstract class AuthDriver {
* @param _sessionData Session data
* @throws InvalidCredentialsException
*/
async refresh(_user: User, _sessionData: SessionData): Promise<void> {
async refresh(_user: User, sessionData: SessionData): Promise<SessionData> {
/* Optional */
return sessionData;
}
/**

View File

@@ -1 +1,3 @@
export * from './local';
export * from './oauth2';
export * from './openid';

View File

@@ -1,6 +1,6 @@
import argon2 from 'argon2';
import { AuthDriver } from '../auth';
import { User } from '../../types';
import { User, SessionData } from '../../types';
import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions';
import { AuthenticationService } from '../../services';
import { Router } from 'express';
@@ -11,9 +11,6 @@ import ms from 'ms';
import { respond } from '../../middleware/respond';
export class LocalAuthDriver extends AuthDriver {
/**
* Get user id by email
*/
async getUserID(payload: Record<string, any>): Promise<string> {
if (!payload.email) {
throw new InvalidCredentialsException();
@@ -32,19 +29,14 @@ export class LocalAuthDriver extends AuthDriver {
return user.id;
}
/**
* Verify user password
*/
async verify(user: User, password?: string): Promise<void> {
if (!user.password || !(await argon2.verify(user.password, password as string))) {
throw new InvalidCredentialsException();
}
}
async login(user: User, payload: Record<string, any>): Promise<null> {
if (payload.password) {
await this.verify(user, payload.password);
}
async login(user: User, payload: Record<string, any>): Promise<SessionData> {
await this.verify(user, payload.password);
return null;
}
}

View File

@@ -0,0 +1,291 @@
import { Router } from 'express';
import { Issuer, Client, generators, errors } from 'openid-client';
import jwt from 'jsonwebtoken';
import ms from 'ms';
import { LocalAuthDriver } from './local';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { AuthenticationService, UsersService } from '../../services';
import { AuthDriverOptions, User, AuthData, SessionData } from '../../types';
import { InvalidCredentialsException, ServiceUnavailableException, InvalidConfigException } from '../../exceptions';
import { respond } from '../../middleware/respond';
import asyncHandler from '../../utils/async-handler';
import { Url } from '../../utils/url';
import logger from '../../logger';
export class OAuth2AuthDriver extends LocalAuthDriver {
client: Client;
redirectUrl: string;
usersService: UsersService;
config: Record<string, any>;
constructor(options: AuthDriverOptions, config: Record<string, any>) {
super(options, config);
const { authorizeUrl, accessUrl, profileUrl, clientId, clientSecret, ...additionalConfig } = config;
if (!authorizeUrl || !accessUrl || !profileUrl || !clientId || !clientSecret || !additionalConfig.provider) {
throw new InvalidConfigException('Invalid provider config', { provider: additionalConfig.provider });
}
const redirectUrl = new Url(env.PUBLIC_URL).addPath('auth', 'login', additionalConfig.provider, 'callback');
this.redirectUrl = redirectUrl.toString();
this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
this.config = additionalConfig;
const issuer = new Issuer({
authorization_endpoint: authorizeUrl,
token_endpoint: accessUrl,
userinfo_endpoint: profileUrl,
issuer: additionalConfig.provider,
});
this.client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [this.redirectUrl],
response_types: ['code'],
});
}
generateCodeVerifier(): string {
return generators.codeVerifier();
}
generateAuthUrl(codeVerifier: string): string {
try {
return this.client.authorizationUrl({
scope: this.config.scope ?? 'email',
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
access_type: 'offline',
});
} catch (e) {
throw handleError(e);
}
}
private async fetchUserId(identifier: string): Promise<string | undefined> {
const user = await this.knex
.select('id')
.from('directus_users')
.whereRaw('LOWER(??) = ?', ['email', identifier.toLowerCase()])
.orWhereRaw('LOWER(??) = ?', ['external_identifier', identifier.toLowerCase()])
.first();
return user?.id;
}
async getUserID(payload: Record<string, any>): Promise<string> {
if (!payload.code || !payload.codeVerifier) {
throw new InvalidCredentialsException();
}
let tokenSet;
let userInfo;
try {
tokenSet = await this.client.grant({
grant_type: 'authorization_code',
code: payload.code,
redirect_uri: this.redirectUrl,
code_verifier: payload.codeVerifier,
});
userInfo = await this.client.userinfo(tokenSet);
} catch (e) {
throw handleError(e);
}
const { emailKey, identifierKey, allowPublicRegistration } = this.config;
const email = userInfo[emailKey ?? 'email'] as string | undefined;
// Fallback to email if explicit identifier not found
const identifier = (userInfo[identifierKey] as string | undefined) ?? email;
if (!identifier) {
logger.warn(`Failed to find user identifier for provider "${this.config.provider}"`);
throw new InvalidCredentialsException();
}
const userId = await this.fetchUserId(identifier);
if (userId) {
// Update user refreshToken if provided
if (tokenSet.refresh_token) {
await this.usersService.updateOne(userId, {
auth_data: JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
}
return userId;
}
// Is public registration allowed?
if (!allowPublicRegistration) {
throw new InvalidCredentialsException();
}
// If email matches identifier, don't set "external_identifier"
const emailIsIdentifier = email?.toLowerCase() === identifier.toLowerCase();
await this.usersService.createOne({
provider: this.config.provider,
email: email,
external_identifier: !emailIsIdentifier ? identifier : undefined,
role: this.config.defaultRoleId,
auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
return (await this.fetchUserId(identifier)) as string;
}
async login(user: User, sessionData: SessionData): Promise<SessionData> {
return this.refresh(user, sessionData);
}
async refresh(user: User, sessionData: SessionData): Promise<SessionData> {
let authData = user.auth_data as AuthData;
if (typeof authData === 'string') {
try {
authData = JSON.parse(authData);
} catch {
logger.warn(`Session data isn't valid JSON: ${authData}`);
}
}
if (!authData?.refreshToken) {
return sessionData;
}
try {
const tokenSet = await this.client.refresh(authData.refreshToken);
return { accessToken: tokenSet.access_token };
} catch (e) {
throw handleError(e);
}
}
}
const handleError = (e: any) => {
if (e instanceof errors.OPError) {
if (e.error === 'invalid_grant') {
// Invalid token
return new InvalidCredentialsException();
}
// Server response error
return new ServiceUnavailableException('Service returned unexpected response', {
service: 'openid',
message: e.error_description,
});
} else if (e instanceof errors.RPError) {
// Internal client error
return new InvalidCredentialsException();
}
return e;
};
export function createOAuth2AuthRouter(providerName: string): Router {
const router = Router();
router.get(
'/',
(req, res) => {
const provider = getAuthProvider(providerName) as OAuth2AuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const token = jwt.sign({ verifier: codeVerifier, redirect: req.query.redirect }, env.SECRET as string, {
expiresIn: '5m',
issuer: 'directus',
});
res.cookie(`oauth2.${providerName}`, token, {
httpOnly: true,
sameSite: 'lax',
});
return res.redirect(provider.generateAuthUrl(codeVerifier));
},
respond
);
router.get(
'/callback',
asyncHandler(async (req, res, next) => {
const token = req.cookies[`oauth2.${providerName}`];
if (!token) {
throw new InvalidCredentialsException();
}
const { verifier, redirect } = jwt.verify(token, env.SECRET as string, { issuer: 'directus' }) as {
verifier: string;
redirect: string;
};
const authenticationService = new AuthenticationService({
accountability: {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
},
schema: req.schema,
});
let authResponse;
try {
res.clearCookie(`oauth2.${providerName}`);
const { code } = req.query;
if (!code) {
logger.warn(`Couldn't extract oAuth2 code from query: ${JSON.stringify(req.query)}`);
}
authResponse = await authenticationService.login(providerName, {
code: req.query.code,
codeVerifier: verifier,
});
} catch (error: any) {
logger.warn(error);
if (redirect) {
let reason = 'UNKNOWN_EXCEPTION';
if (error instanceof ServiceUnavailableException) {
reason = 'SERVICE_UNAVAILABLE';
} else if (error instanceof InvalidCredentialsException) {
reason = 'INVALID_USER';
}
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);
}
throw error;
}
const { accessToken, refreshToken, expires } = authResponse;
if (redirect) {
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
return res.redirect(redirect);
}
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
next();
}),
respond
);
return router;
}

View File

@@ -0,0 +1,286 @@
import { Router } from 'express';
import { Issuer, Client, generators, errors } from 'openid-client';
import jwt from 'jsonwebtoken';
import ms from 'ms';
import { LocalAuthDriver } from './local';
import { getAuthProvider } from '../../auth';
import env from '../../env';
import { AuthenticationService, UsersService } from '../../services';
import { AuthDriverOptions, User, AuthData, SessionData } from '../../types';
import { InvalidCredentialsException, ServiceUnavailableException, InvalidConfigException } from '../../exceptions';
import { respond } from '../../middleware/respond';
import asyncHandler from '../../utils/async-handler';
import { Url } from '../../utils/url';
import logger from '../../logger';
export class OpenIDAuthDriver extends LocalAuthDriver {
client: Promise<Client>;
redirectUrl: string;
usersService: UsersService;
config: Record<string, any>;
constructor(options: AuthDriverOptions, config: Record<string, any>) {
super(options, config);
const { issuerUrl, clientId, clientSecret, ...additionalConfig } = config;
if (!issuerUrl || !clientId || !clientSecret || !additionalConfig.provider) {
throw new InvalidConfigException('Invalid provider config', { provider: additionalConfig.provider });
}
const redirectUrl = new Url(env.PUBLIC_URL).addPath('auth', 'login', additionalConfig.provider, 'callback');
this.redirectUrl = redirectUrl.toString();
this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
this.config = additionalConfig;
this.client = new Promise((resolve, reject) => {
Issuer.discover(issuerUrl)
.then((issuer) => {
resolve(
new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [this.redirectUrl],
response_types: ['code'],
})
);
})
.catch(reject);
});
}
generateCodeVerifier(): string {
return generators.codeVerifier();
}
async generateAuthUrl(codeVerifier: string): Promise<string> {
try {
const client = await this.client;
return client.authorizationUrl({
scope: this.config.scope ?? 'openid profile email',
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
access_type: 'offline',
});
} catch (e) {
throw handleError(e);
}
}
private async fetchUserId(identifier: string): Promise<string | undefined> {
const user = await this.knex
.select('id')
.from('directus_users')
.whereRaw('LOWER(??) = ?', ['email', identifier.toLowerCase()])
.orWhereRaw('LOWER(??) = ?', ['external_identifier', identifier.toLowerCase()])
.first();
return user?.id;
}
async getUserID(payload: Record<string, any>): Promise<string> {
if (!payload.code || !payload.codeVerifier) {
throw new InvalidCredentialsException();
}
let tokenSet;
let userInfo;
try {
const client = await this.client;
tokenSet = await client.callback(
this.redirectUrl,
{ code: payload.code },
{ code_verifier: payload.codeVerifier }
);
userInfo = await client.userinfo(tokenSet);
} catch (e) {
throw handleError(e);
}
const { identifierKey, allowPublicRegistration, requireVerifiedEmail } = this.config;
const email = userInfo.email as string;
// Fallback to email if explicit identifier not found
const identifier = (userInfo[identifierKey ?? 'sub'] as string | undefined) ?? email;
if (!identifier) {
logger.warn(`Failed to find user identifier for provider "${this.config.provider}"`);
throw new InvalidCredentialsException();
}
const userId = await this.fetchUserId(identifier);
if (userId) {
// Update user refreshToken if provided
if (tokenSet.refresh_token) {
await this.usersService.updateOne(userId, {
auth_data: JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
}
return userId;
}
const isEmailVerified = !requireVerifiedEmail || userInfo.email_verified;
// Is public registration allowed?
if (!allowPublicRegistration || !isEmailVerified) {
throw new InvalidCredentialsException();
}
// If email matches identifier, don't set "external_identifier"
const emailIsIdentifier = email?.toLowerCase() === identifier.toLowerCase();
await this.usersService.createOne({
provider: this.config.provider,
first_name: userInfo.given_name,
last_name: userInfo.family_name,
email: email,
external_identifier: !emailIsIdentifier ? identifier : undefined,
role: this.config.defaultRoleId,
auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
return (await this.fetchUserId(identifier)) as string;
}
async login(user: User, sessionData: SessionData): Promise<SessionData> {
return this.refresh(user, sessionData);
}
async refresh(user: User, sessionData: SessionData): Promise<SessionData> {
let authData = user.auth_data as AuthData;
if (typeof authData === 'string') {
try {
authData = JSON.parse(authData);
} catch {
logger.warn(`Session data isn't valid JSON: ${authData}`);
}
}
if (!authData?.refreshToken) {
return sessionData;
}
try {
const client = await this.client;
const tokenSet = await client.refresh(authData.refreshToken);
return { accessToken: tokenSet.access_token };
} catch (e) {
throw handleError(e);
}
}
}
const handleError = (e: any) => {
if (e instanceof errors.OPError) {
if (e.error === 'invalid_grant') {
// Invalid token
return new InvalidCredentialsException();
}
// Server response error
return new ServiceUnavailableException('Service returned unexpected response', {
service: 'openid',
message: e.error_description,
});
} else if (e instanceof errors.RPError) {
// Internal client error
return new InvalidCredentialsException();
}
return e;
};
export function createOpenIDAuthRouter(providerName: string): Router {
const router = Router();
router.get(
'/',
asyncHandler(async (req, res) => {
const provider = getAuthProvider(providerName) as OpenIDAuthDriver;
const codeVerifier = provider.generateCodeVerifier();
const token = jwt.sign({ verifier: codeVerifier, redirect: req.query.redirect }, env.SECRET as string, {
expiresIn: '5m',
issuer: 'directus',
});
res.cookie(`openid.${providerName}`, token, {
httpOnly: true,
sameSite: 'lax',
});
return res.redirect(await provider.generateAuthUrl(codeVerifier));
}),
respond
);
router.get(
'/callback',
asyncHandler(async (req, res, next) => {
const token = req.cookies[`openid.${providerName}`];
const { verifier, redirect } = jwt.verify(token, env.SECRET as string, { issuer: 'directus' }) as {
verifier: string;
redirect: string;
};
const authenticationService = new AuthenticationService({
accountability: {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
},
schema: req.schema,
});
let authResponse;
try {
res.clearCookie(`openid.${providerName}`);
authResponse = await authenticationService.login(providerName, {
code: req.query.code,
codeVerifier: verifier,
});
} catch (error: any) {
logger.warn(error);
if (redirect) {
let reason = 'UNKNOWN_EXCEPTION';
if (error instanceof ServiceUnavailableException) {
reason = 'SERVICE_UNAVAILABLE';
} else if (error instanceof InvalidCredentialsException) {
reason = 'INVALID_USER';
}
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);
}
throw error;
}
const { accessToken, refreshToken, expires } = authResponse;
if (redirect) {
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
return res.redirect(redirect);
}
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
next();
}),
respond
);
return router;
}

View File

@@ -41,9 +41,9 @@ REFRESH_TOKEN_COOKIE_SAME_SITE="lax"
REFRESH_TOKEN_COOKIE_NAME="directus_refresh_token"
####################################################################################################
## SSO (OAuth) Providers
## Auth Providers
OAUTH_PROVIDERS=""
AUTH_PROVIDERS=""
####################################################################################################
## Extensions

View File

@@ -1,23 +1,13 @@
import { Router } from 'express';
import grant from 'grant';
import ms from 'ms';
import emitter, { emitAsyncSafe } from '../emitter';
import env from '../env';
import {
InvalidCredentialsException,
RouteNotFoundException,
ServiceUnavailableException,
InvalidPayloadException,
} from '../exceptions';
import grantConfig from '../grant';
import { InvalidPayloadException } from '../exceptions';
import { respond } from '../middleware/respond';
import { AuthenticationService, UsersService } from '../services';
import asyncHandler from '../utils/async-handler';
import { getAuthProviders } from '../utils/get-auth-providers';
import getEmailFromProfile from '../utils/get-email-from-profile';
import { toArray } from '@directus/shared/utils';
import logger from '../logger';
import { createLocalAuthRouter } from '../auth/drivers';
import { createLocalAuthRouter, createOAuth2AuthRouter, createOpenIDAuthRouter } from '../auth/drivers';
import { DEFAULT_AUTH_PROVIDER } from '../constants';
const router = Router();
@@ -30,6 +20,15 @@ for (const authProvider of authProviders) {
switch (authProvider.driver) {
case 'local':
authRouter = createLocalAuthRouter(authProvider.name);
break;
case 'oauth2':
authRouter = createOAuth2AuthRouter(authProvider.name);
break;
case 'openid':
authRouter = createOpenIDAuthRouter(authProvider.name);
break;
}
if (!authRouter) {
@@ -189,156 +188,4 @@ router.get(
respond
);
router.get(
'/oauth',
asyncHandler(async (req, res, next) => {
const providers = toArray(env.OAUTH_PROVIDERS);
res.locals.payload = { data: env.OAUTH_PROVIDERS ? providers : null };
return next();
}),
respond
);
router.get(
'/oauth/:provider',
asyncHandler(async (req, res, next) => {
const config = { ...grantConfig };
delete config.defaults;
const availableProviders = Object.keys(config);
if (availableProviders.includes(req.params.provider) === false) {
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
}
if (req.query?.redirect && req.session) {
req.session.redirect = req.query.redirect as string;
}
const hookPayload = {
provider: req.params.provider,
redirect: req.query?.redirect,
};
emitAsyncSafe(`oauth.${req.params.provider}.redirect`, {
event: `oauth.${req.params.provider}.redirect`,
action: 'redirect',
schema: req.schema,
payload: hookPayload,
accountability: req.accountability,
user: null,
});
await emitter.emitAsync(`oauth.${req.params.provider}.redirect.before`, {
event: `oauth.${req.params.provider}.redirect.before`,
action: 'redirect',
schema: req.schema,
payload: hookPayload,
accountability: req.accountability,
user: null,
});
next();
})
);
router.use(grant.express()(grantConfig));
router.get(
'/oauth/:provider/callback',
asyncHandler(async (req, res, next) => {
const redirect = req.session.redirect;
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
role: null,
};
const authenticationService = new AuthenticationService({
accountability: accountability,
schema: req.schema,
});
let authResponse: { accessToken: any; refreshToken: any; expires: any; id?: any };
const hookPayload = req.session.grant.response;
await emitter.emitAsync(`oauth.${req.params.provider}.login.before`, hookPayload, {
event: `oauth.${req.params.provider}.login.before`,
action: 'oauth.login',
schema: req.schema,
payload: hookPayload,
accountability: accountability,
status: 'pending',
user: null,
});
const emitStatus = (status: 'fail' | 'success') => {
emitAsyncSafe(`oauth.${req.params.provider}.login`, hookPayload, {
event: `oauth.${req.params.provider}.login`,
action: 'oauth.login',
schema: req.schema,
payload: hookPayload,
accountability: accountability,
status,
user: null,
});
};
try {
const email = getEmailFromProfile(req.params.provider, req.session.grant.response?.profile);
req.session?.destroy(() => {
// Do nothing
});
// Workaround to use the default local auth provider to validate
// the email and login without a password.
authResponse = await authenticationService.login(DEFAULT_AUTH_PROVIDER, { email });
} catch (error: any) {
emitStatus('fail');
logger.warn(error);
if (redirect) {
let reason = 'UNKNOWN_EXCEPTION';
if (error instanceof ServiceUnavailableException) {
reason = 'SERVICE_UNAVAILABLE';
} else if (error instanceof InvalidCredentialsException) {
reason = 'INVALID_USER';
}
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);
}
throw error;
}
const { accessToken, refreshToken, expires } = authResponse;
emitStatus('success');
if (redirect) {
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, {
httpOnly: true,
domain: env.REFRESH_TOKEN_COOKIE_DOMAIN,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false,
sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict',
});
return res.redirect(redirect);
} else {
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
return next();
}
}),
respond
);
export default router;

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_users', (table) => {
table.json('auth_data');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_users', (table) => {
table.dropColumn('auth_data');
});
}

View File

@@ -154,7 +154,6 @@ fields:
- field: provider
width: half
interface: select-dropdown
readonly: true
options:
choices:
- text: $t:default_provider
@@ -165,4 +164,6 @@ fields:
options:
iconRight: account_circle
interface: input
readonly: true
- field: auth_data
hidden: true

View File

@@ -30,8 +30,6 @@ const defaults: Record<string, any> = {
RATE_LIMITER_DURATION: 1,
RATE_LIMITER_STORE: 'memory',
SESSION_STORE: 'memory',
ACCESS_TOKEN_TTL: '15m',
REFRESH_TOKEN_TTL: '7d',
REFRESH_TOKEN_COOKIE_SECURE: false,
@@ -58,8 +56,6 @@ const defaults: Record<string, any> = {
AUTH_PROVIDERS: '',
OAUTH_PROVIDERS: '',
EXTENSIONS_PATH: './extensions',
EMAIL_FROM: 'no-reply@directus.io',

View File

@@ -1,24 +0,0 @@
/**
* Grant is the oAuth library
*/
import env from './env';
import { toArray } from '@directus/shared/utils';
import { getConfigFromEnv } from './utils/get-config-from-env';
const enabledProviders = toArray(env.OAUTH_PROVIDERS).map((provider) => provider.toLowerCase());
const config: any = {
defaults: {
origin: env.PUBLIC_URL,
transport: 'session',
prefix: '/auth/oauth',
response: ['tokens', 'profile'],
},
};
for (const provider of enabledProviders) {
config[provider] = getConfigFromEnv(`OAUTH_${provider.toUpperCase()}_`, undefined, 'underscore');
}
export default config;

View File

@@ -1,28 +0,0 @@
import expressSession, { Store } from 'express-session';
import env from '../env';
import { getConfigFromEnv } from '../utils/get-config-from-env';
import getDatabase from '../database';
let store: Store | undefined = undefined;
if (env.SESSION_STORE === 'redis') {
const Redis = require('ioredis');
const RedisStore = require('connect-redis')(expressSession);
const redisClient = new Redis(env.SESSION_REDIS || getConfigFromEnv('SESSION_REDIS_'));
store = new RedisStore({ client: redisClient });
}
if (env.SESSION_STORE === 'memcache') {
const MemcachedStore = require('connect-memcached')(expressSession);
store = new MemcachedStore(getConfigFromEnv('SESSION_MEMCACHE_'));
}
if (env.SESSION_STORE === 'database') {
const KnexSessionStore = require('connect-session-knex')(expressSession);
store = new KnexSessionStore({
knex: getDatabase(),
tablename: 'oauth_sessions', // optional. Defaults to 'sessions'
});
}
export const session = expressSession({ store, secret: env.SECRET as string, saveUninitialized: false, resave: false });

View File

@@ -14,9 +14,10 @@ import { TFAService } from './tfa';
import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types';
import { Accountability } from '@directus/shared/types';
import { SettingsService } from './settings';
import { merge, clone, cloneDeep } from 'lodash';
import { merge, clone, cloneDeep, omit } from 'lodash';
import { performance } from 'perf_hooks';
import { stall } from '../utils/stall';
import logger from '../logger';
const loginAttemptsLimiter = createRateLimiter({ duration: 0 });
@@ -60,7 +61,8 @@ export class AuthenticationService {
'role',
'tfa_secret',
'provider',
'external_identifier'
'external_identifier',
'auth_data'
)
.from('directus_users')
.where('id', await provider.getUserID(cloneDeep(payload)))
@@ -191,7 +193,7 @@ export class AuthenticationService {
expires: refreshTokenExpiration,
ip: this.accountability?.ip,
user_agent: this.accountability?.userAgent,
data: sessionData,
data: sessionData && JSON.stringify(sessionData),
});
await this.knex('directus_sessions').delete().where('expires', '<', new Date());
@@ -242,7 +244,8 @@ export class AuthenticationService {
'u.status',
'u.role',
'u.provider',
'u.external_identifier'
'u.external_identifier',
'u.auth_data'
)
.from('directus_sessions as s')
.innerJoin('directus_users as u', 's.user', 'u.id')
@@ -253,10 +256,20 @@ export class AuthenticationService {
throw new InvalidCredentialsException();
}
const { data: sessionData, ...user } = record;
let { data: sessionData } = record;
const user = omit(record, 'data');
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
}
const provider = getAuthProvider(user.provider);
await provider.refresh(clone(user), sessionData);
const newSessionData = await provider.refresh(clone(user), sessionData as SessionData);
const accessToken = jwt.sign({ id: user.id }, env.SECRET as string, {
expiresIn: env.ACCESS_TOKEN_TTL,
@@ -267,7 +280,11 @@ export class AuthenticationService {
const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string));
await this.knex('directus_sessions')
.update({ token: newRefreshToken, expires: refreshTokenExpiration })
.update({
token: newRefreshToken,
expires: refreshTokenExpiration,
data: newSessionData && JSON.stringify(newSessionData),
})
.where({ token: refreshToken });
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
@@ -292,6 +309,7 @@ export class AuthenticationService {
'u.role',
'u.provider',
'u.external_identifier',
'u.auth_data',
's.data'
)
.from('directus_sessions as s')
@@ -300,10 +318,19 @@ export class AuthenticationService {
.first();
if (record) {
const { data: sessionData, ...user } = record;
let { data: sessionData } = record;
const user = omit(record, 'data');
if (typeof sessionData === 'string') {
try {
sessionData = JSON.parse(sessionData);
} catch {
logger.warn(`Session data isn't valid JSON: ${sessionData}`);
}
}
const provider = getAuthProvider(user.provider);
await provider.logout(clone(user), sessionData);
await provider.logout(clone(user), sessionData as SessionData);
await this.knex.delete().from('directus_sessions').where('token', refreshToken);
}
@@ -320,7 +347,8 @@ export class AuthenticationService {
'status',
'role',
'provider',
'external_identifier'
'external_identifier',
'auth_data'
)
.from('directus_users')
.where('id', userID)

View File

@@ -82,21 +82,23 @@ export class UsersService extends ItemsService {
fields: ['auth_password_policy'],
});
if (policyRegExString) {
const wrapped = policyRegExString.startsWith('/') && policyRegExString.endsWith('/');
const regex = new RegExp(wrapped ? policyRegExString.slice(1, -1) : policyRegExString);
if (!policyRegExString) {
return;
}
for (const password of passwords) {
if (regex.test(password) === false) {
throw new FailedValidationException({
message: `Provided password doesn't match password policy`,
path: ['password'],
type: 'custom.pattern.base',
context: {
value: password,
},
});
}
const wrapped = policyRegExString.startsWith('/') && policyRegExString.endsWith('/');
const regex = new RegExp(wrapped ? policyRegExString.slice(1, -1) : policyRegExString);
for (const password of passwords) {
if (!regex.test(password)) {
throw new FailedValidationException({
message: `Provided password doesn't match password policy`,
path: ['password'],
type: 'custom.pattern.base',
context: {
value: password,
},
});
}
}
}
@@ -141,16 +143,6 @@ export class UsersService extends ItemsService {
await this.checkPasswordPolicy(passwords);
}
for (const user of data) {
if (user.provider !== undefined) {
throw new InvalidPayloadException(`You can't set the "provider" value manually.`);
}
if (user.external_identifier !== undefined) {
throw new InvalidPayloadException(`You can't set the "external_identifier" value manually.`);
}
}
return await super.createMany(data, opts);
}

View File

@@ -1,3 +1,11 @@
import { Knex } from 'knex';
import { SchemaOverview } from './schema';
export interface AuthDriverOptions {
knex: Knex;
schema: SchemaOverview;
}
export interface User {
id: string;
first_name: string | null;
@@ -8,12 +16,15 @@ export interface User {
role: string | null;
provider: string;
external_identifier: string | null;
auth_data: string | Record<string, unknown> | null;
}
export type SessionData = Record<string, any> | null;
export type AuthData = Record<string, any> | null;
export interface Session {
token: string;
expires: Date;
data: SessionData;
data: string | null;
}
export type SessionData = Record<string, any> | null;

View File

@@ -1,10 +0,0 @@
import { SessionData } from 'express-session';
export = SessionData;
declare module 'express-session' {
interface SessionData {
redirect: string;
grant: any;
}
}

View File

@@ -4,6 +4,7 @@ import env from '../env';
interface AuthProvider {
name: string;
driver: string;
icon?: string;
}
export function getAuthProviders(): AuthProvider[] {
@@ -12,5 +13,6 @@ export function getAuthProviders(): AuthProvider[] {
.map((provider) => ({
name: provider,
driver: env[`AUTH_${provider.toUpperCase()}_DRIVER`],
icon: env[`AUTH_${provider.toUpperCase()}_ICON`],
}));
}

View File

@@ -1,36 +0,0 @@
import { get } from 'lodash';
import env from '../env';
import { ServiceUnavailableException } from '../exceptions';
// The path in JSON to fetch the email address from the profile.
// Note: a lot of services use `email` as the path. We fall back to that as default, so no need to
// map it here
const profileMap: Record<string, string> = {};
/**
* Extract the email address from a given user profile coming from a providers API
*
* Falls back to OAUTH_<PROVIDER>_PROFILE_EMAIL if we don't have it preconfigured yet, and defaults
* to `email` if nothing is set
*
* This is used in the SSO flow to extract the users
*/
export default function getEmailFromProfile(provider: string, profile: Record<string, any>): string {
const path = profileMap[provider] || env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] || 'email';
const email = get(profile, path);
if (!email) {
throw new ServiceUnavailableException(
`Email address not found. Profile "${JSON.stringify(profile)}", path: "${path}"`,
{
service: 'oauth',
provider,
profile,
path,
}
);
}
return email;
}

View File

@@ -54,8 +54,8 @@ async function getEnvInfo(event: string) {
email: {
transport: env.EMAIL_TRANSPORT,
},
oauth: {
providers: env.OAUTH_PROVIDERS.split(',')
auth: {
providers: env.AUTH_PROVIDERS.split(',')
.map((v: string) => v.trim())
.filter((v: string) => v),
},

View File

@@ -28,6 +28,8 @@
"@directus/extensions-sdk": "9.0.0-rc.98",
"@directus/format-title": "9.0.0-rc.98",
"@directus/shared": "9.0.0-rc.98",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fullcalendar/core": "5.10.0",
"@fullcalendar/daygrid": "5.10.0",
"@fullcalendar/interaction": "5.10.0",

View File

@@ -8,12 +8,15 @@
@click="emitClick"
>
<component :is="customIconName" v-if="customIconName" />
<socialIcon v-else-if="socialIconName" :name="socialIconName" />
<i v-else :class="{ filled }">{{ name }}</i>
</span>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { defineComponent, computed, h, PropType } from 'vue';
import { library, icon, findIconDefinition, IconName } from '@fortawesome/fontawesome-svg-core';
import { fab } from '@fortawesome/free-brands-svg-icons';
import useSizeClass, { sizeProps } from '@/composables/size-class';
import CustomIconDirectus from './custom-icons/directus.vue';
@@ -35,6 +38,8 @@ import CustomIconFolderMove from './custom-icons/folder_move.vue';
import CustomIconFolderLock from './custom-icons/folder_lock.vue';
import CustomIconLogout from './custom-icons/logout.vue';
library.add(fab);
const customIcons: string[] = [
'directus',
'bookmark_save',
@@ -56,6 +61,478 @@ const customIcons: string[] = [
'logout',
];
const socialIcons: string[] = [
'500px',
'accessible_icon',
'accusoft',
'acquisitions_incorporated',
'adn',
'adversal',
'affiliatetheme',
'airbnb',
'algolia',
'alipay',
'amazon',
'amazon_pay',
'amilia',
'android',
'angellist',
'angrycreative',
'angular',
'app_store',
'app_store_ios',
'apper',
'apple',
'apple_pay',
'artstation',
'asymmetrik',
'atlassian',
'audible',
'autoprefixer',
'avianex',
'aviato',
'aws',
'bandcamp',
'battle_net',
'behance',
'behance_square',
'bimobject',
'bitbucket',
'bitcoin',
'bity',
'black_tie',
'blackberry',
'blogger',
'blogger_b',
'bluetooth',
'bluetooth_b',
'bootstrap',
'btc',
'buffer',
'buromobelexperte',
'buy_n_large',
'buysellads',
'canadian_maple_leaf',
'cc_amazon_pay',
'cc_amex',
'cc_apple_pay',
'cc_diners_club',
'cc_discover',
'cc_jcb',
'cc_mastercard',
'cc_paypal',
'cc_stripe',
'cc_visa',
'centercode',
'centos',
'chrome',
'chromecast',
'cloudflare',
'cloudscale',
'cloudsmith',
'cloudversify',
'codepen',
'codiepie',
'confluence',
'connectdevelop',
'contao',
'cotton_bureau',
'cpanel',
'creative_commons',
'creative_commons_by',
'creative_commons_nc',
'creative_commons_nc_eu',
'creative_commons_nc_jp',
'creative_commons_nd',
'creative_commons_pd',
'creative_commons_pd_alt',
'creative_commons_remix',
'creative_commons_sa',
'creative_commons_sampling',
'creative_commons_sampling_plus',
'creative_commons_share',
'creative_commons_zero',
'critical_role',
'css3',
'css3_alt',
'cuttlefish',
'd_and_d',
'd_and_d_beyond',
'dailymotion',
'dashcube',
'deezer',
'delicious',
'deploydog',
'deskpro',
'dev',
'deviantart',
'dhl',
'diaspora',
'digg',
'digital_ocean',
'discord',
'discourse',
'dochub',
'docker',
'draft2digital',
'dribbble',
'dribbble_square',
'dropbox',
'drupal',
'dyalog',
'earlybirds',
'ebay',
'edge',
'edge_legacy',
'elementor',
'ello',
'ember',
'empire',
'envira',
'erlang',
'ethereum',
'etsy',
'evernote',
'expeditedssl',
'facebook',
'facebook_f',
'facebook_messenger',
'facebook_square',
'fantasy_flight_games',
'fedex',
'fedora',
'figma',
'firefox',
'firefox_browser',
'first_order',
'first_order_alt',
'firstdraft',
'flickr',
'flipboard',
'fly',
'font_awesome',
'font_awesome_alt',
'font_awesome_flag',
'fonticons',
'fonticons_fi',
'fort_awesome',
'fort_awesome_alt',
'forumbee',
'foursquare',
'free_code_camp',
'freebsd',
'fulcrum',
'galactic_republic',
'galactic_senate',
'get_pocket',
'gg',
'gg_circle',
'git',
'git_alt',
'git_square',
'github',
'github_alt',
'github_square',
'gitkraken',
'gitlab',
'gitter',
'glide',
'glide_g',
'gofore',
'goodreads',
'goodreads_g',
'google',
'google_drive',
'google_pay',
'google_play',
'google_plus',
'google_plus_g',
'google_plus_square',
'google_wallet',
'gratipay',
'grav',
'gripfire',
'grunt',
'guilded',
'gulp',
'hacker_news',
'hacker_news_square',
'hackerrank',
'hips',
'hire_a_helper',
'hive',
'hooli',
'hornbill',
'hotjar',
'houzz',
'html5',
'hubspot',
'ideal',
'imdb',
'innosoft',
'instagram',
'instagram_square',
'instalod',
'intercom',
'internet_explorer',
'invision',
'ioxhost',
'itch_io',
'itunes',
'itunes_note',
'java',
'jedi_order',
'jenkins',
'jira',
'joget',
'joomla',
'js',
'js_square',
'jsfiddle',
'kaggle',
'keybase',
'keycdn',
'kickstarter',
'kickstarter_k',
'korvue',
'laravel',
'lastfm',
'lastfm_square',
'leanpub',
'less',
'line',
'linkedin',
'linkedin_in',
'linode',
'linux',
'lyft',
'magento',
'mailchimp',
'mandalorian',
'markdown',
'mastodon',
'maxcdn',
'mdb',
'medapps',
'medium',
'medium_m',
'medrt',
'meetup',
'megaport',
'mendeley',
'microblog',
'microsoft',
'mix',
'mixcloud',
'mixer',
'mizuni',
'modx',
'monero',
'napster',
'neos',
'nimblr',
'node',
'node_js',
'npm',
'ns8',
'nutritionix',
'octopus_deploy',
'odnoklassniki',
'odnoklassniki_square',
'old_republic',
'opencart',
'openid',
'opera',
'optin_monster',
'orcid',
'osi',
'page4',
'pagelines',
'palfed',
'patreon',
'paypal',
'penny_arcade',
'perbyte',
'periscope',
'phabricator',
'phoenix_framework',
'phoenix_squadron',
'php',
'pied_piper',
'pied_piper_alt',
'pied_piper_hat',
'pied_piper_pp',
'pied_piper_square',
'pinterest',
'pinterest_p',
'pinterest_square',
'playstation',
'product_hunt',
'pushed',
'python',
'qq',
'quinscape',
'quora',
'r_project',
'raspberry_pi',
'ravelry',
'react',
'reacteurope',
'readme',
'rebel',
'red_river',
'reddit',
'reddit_alien',
'reddit_square',
'redhat',
'renren',
'replyd',
'researchgate',
'resolving',
'rev',
'rocketchat',
'rockrms',
'rust',
'safari',
'salesforce',
'sass',
'schlix',
'scribd',
'searchengin',
'sellcast',
'sellsy',
'servicestack',
'shirtsinbulk',
'shopify',
'shopware',
'simplybuilt',
'sistrix',
'sith',
'sketch',
'skyatlas',
'skype',
'slack',
'slack_hash',
'slideshare',
'snapchat',
'snapchat_ghost',
'snapchat_square',
'soundcloud',
'sourcetree',
'speakap',
'speaker_deck',
'spotify',
'squarespace',
'stack_exchange',
'stack_overflow',
'stackpath',
'staylinked',
'steam',
'steam_square',
'steam_symbol',
'sticker_mule',
'strava',
'stripe',
'stripe_s',
'studiovinari',
'stumbleupon',
'stumbleupon_circle',
'superpowers',
'supple',
'suse',
'swift',
'symfony',
'teamspeak',
'telegram',
'telegram_plane',
'tencent_weibo',
'the_red_yeti',
'themeco',
'themeisle',
'think_peaks',
'tiktok',
'trade_federation',
'trello',
'tumblr',
'tumblr_square',
'twitch',
'twitter',
'twitter_square',
'typo3',
'uber',
'ubuntu',
'uikit',
'umbraco',
'uncharted',
'uniregistry',
'unity',
'unsplash',
'untappd',
'ups',
'usb',
'usps',
'ussunnah',
'vaadin',
'viacoin',
'viadeo',
'viadeo_square',
'viber',
'vimeo',
'vimeo_square',
'vimeo_v',
'vine',
'vk',
'vnv',
'vuejs',
'watchman_monitoring',
'waze',
'weebly',
'weibo',
'weixin',
'whatsapp',
'whatsapp_square',
'whmcs',
'wikipedia_w',
'windows',
'wix',
'wizards_of_the_coast',
'wodu',
'wolf_pack_battalion',
'wordpress',
'wordpress_simple',
'wpbeginner',
'wpexplorer',
'wpforms',
'wpressr',
'xbox',
'xing',
'xing_square',
'y_combinator',
'yahoo',
'yammer',
'yandex',
'yandex_international',
'yarn',
'yelp',
'yoast',
'youtube',
'youtube_square',
'zhihu',
];
const SocialIcon = defineComponent({
props: {
name: {
type: String as PropType<IconName>,
required: true,
},
},
render() {
const socialIcon = icon(findIconDefinition({ prefix: 'fab', iconName: this.name }));
return h({ template: socialIcon?.html[0] });
},
});
export default defineComponent({
components: {
CustomIconDirectus,
@@ -76,6 +553,7 @@ export default defineComponent({
CustomIconFolderMove,
CustomIconFolderLock,
CustomIconLogout,
SocialIcon,
},
props: {
name: {
@@ -125,9 +603,15 @@ export default defineComponent({
return null;
});
const socialIconName = computed<string | null>(() => {
if (socialIcons.includes(props.name)) return props.name.replace(/_/g, '-');
return null;
});
return {
sizeClass,
customIconName,
socialIconName,
emitClick,
};
@@ -182,6 +666,11 @@ body {
display: inline-block;
color: inherit;
fill: currentColor;
&.svg-inline--fa {
width: auto;
height: auto;
}
}
&.has-click {

View File

@@ -69,3 +69,5 @@ export const MODULE_BAR_DEFAULT = [
locked: true,
},
];
export const AUTH_SSO_DRIVERS = ['oauth2', 'openid'];

View File

@@ -305,7 +305,13 @@ export default defineComponent({
];
const fieldsFiltered = computed(() => {
return fields.value.filter((field: Field) => fieldsDenyList.includes(field.field) === false);
return fields.value.filter((field: Field) => {
// These fields should only be editable when creating new users
if (!isNew.value && ['provider', 'external_identifier'].includes(field.field)) {
field.meta.readonly = true;
}
return !fieldsDenyList.includes(field.field);
});
});
const { formFields } = useFormFields(fieldsFiltered);

View File

@@ -17,7 +17,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref } from 'vue';
import { defineComponent, ref, onMounted } from 'vue';
import api from '@/api';
import { hydrate } from '@/hydrate';
@@ -37,6 +37,12 @@ export default defineComponent({
fetchUser();
onMounted(() => {
if ('continue' in router.currentRoute.value.query) {
hydrateAndLogin();
}
});
return { t, name, lastPage, loading, hydrateAndLogin };
async function fetchUser() {

View File

@@ -17,19 +17,20 @@
</router-link>
</div>
<sso-links />
<sso-links :providers="providers" />
</form>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, computed, watch } from 'vue';
import { defineComponent, ref, computed, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import ssoLinks from '../sso-links.vue';
import { login } from '@/auth';
import { RequestError } from '@/api';
import api, { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
import { useUserStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
type Credentials = {
email: string;
@@ -50,8 +51,11 @@ export default defineComponent({
const error = ref<RequestError | string | null>(null);
const otp = ref<string | null>(null);
const requiresTFA = ref(false);
const providers = ref([]);
const userStore = useUserStore();
onMounted(() => fetchProviders());
watch(email, () => {
if (requiresTFA.value === true) requiresTFA.value = false;
});
@@ -68,7 +72,28 @@ export default defineComponent({
return null;
});
return { t, errorFormatted, error, email, password, onSubmit, loggingIn, translateAPIError, otp, requiresTFA };
return {
t,
errorFormatted,
error,
email,
password,
onSubmit,
loggingIn,
translateAPIError,
otp,
requiresTFA,
providers,
};
async function fetchProviders() {
try {
const response = await api.get('/auth');
providers.value = response.data.data;
} catch (err: any) {
unexpectedError(err);
}
}
async function onSubmit() {
if (email.value === null || password.value === null) return;

View File

@@ -1,10 +1,15 @@
<template>
<div class="sso-links">
<template v-if="providers && providers.length > 0">
<template v-if="ssoProviders.length > 0">
<v-divider />
<a v-for="provider in providers" :key="provider.name" class="sso-link" :href="provider.link">
{{ t('log_in_with', { provider: provider.name }) }}
<a v-for="provider in ssoProviders" :key="provider.name" class="sso-link" :href="provider.link">
<div class="sso-icon">
<v-icon :name="provider.icon" />
</div>
<div class="sso-title">
{{ t('log_in_with', { provider: provider.name }) }}
</div>
</a>
</template>
</div>
@@ -12,40 +17,40 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, onMounted } from 'vue';
import api from '@/api';
import { defineComponent, ref, watch, toRefs, PropType } from 'vue';
import { AuthProvider } from '@/types';
import { AUTH_SSO_DRIVERS } from '@/constants';
import { getRootPath } from '@/utils/get-root-path';
import { unexpectedError } from '@/utils/unexpected-error';
import formatTitle from '@directus/format-title';
export default defineComponent({
setup() {
props: {
providers: {
type: Array as PropType<AuthProvider[]>,
default: () => [],
},
},
setup(props) {
const { t } = useI18n();
const providers = ref([]);
const loading = ref(false);
const { providers } = toRefs(props);
onMounted(() => fetchProviders());
const ssoProviders = ref<{ name: string; link: string; icon: string }[]>([]);
return { t, providers };
watch(providers, () => {
ssoProviders.value = providers.value
.filter((provider: AuthProvider) => AUTH_SSO_DRIVERS.includes(provider.driver))
.map((provider: AuthProvider) => ({
name: formatTitle(provider.name),
link: `${getRootPath()}auth/login/${provider.name}?redirect=${window.location.href.replace(
location.search,
'?continue'
)}`,
icon: provider.icon ?? 'account_circle',
}));
});
async function fetchProviders() {
loading.value = true;
try {
const response = await api.get('/auth/oauth/');
providers.value = response.data.data?.map((providerName: string) => {
return {
name: providerName,
link: `${getRootPath()}auth/oauth/${providerName.toLowerCase()}?redirect=${window.location.href}`,
};
});
} catch (err: any) {
unexpectedError(err);
} finally {
loading.value = false;
}
}
return { t, ssoProviders };
},
});
</script>
@@ -56,19 +61,39 @@ export default defineComponent({
}
.sso-link {
display: block;
$sso-link-border-width: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: var(--input-height);
text-align: center;
background-color: var(--background-normal);
border: $sso-link-border-width var(--background-normal) solid;
border-radius: var(--border-radius);
transition: background var(--fast) var(--transition);
transition: border-color var(--fast) var(--transition);
.sso-icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--input-height);
margin: -$sso-link-border-width;
background-color: var(--background-normal-alt);
border-radius: var(--border-radius);
span {
--v-icon-size: 28px;
}
}
.sso-title {
display: flex;
align-items: center;
padding: 0 16px 0 20px;
font-size: 16px;
}
&:hover {
background-color: var(--background-normal-alt);
border-color: var(--background-normal-alt);
}
& + & {

View File

@@ -2,3 +2,4 @@ export * from './collections';
export * from './error';
export * from './insights';
export * from './notifications';
export * from './login';

5
app/src/types/login.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface AuthProvider {
driver: string;
name: string;
icon?: string;
}

View File

@@ -54,55 +54,60 @@ possible to cache assets for way longer than you would with the actual content.
ASSETS_CACHE_TTL="7d"
```
## oAuth (Single Sign-On (SSO) / OpenID)
## Auth
Directus' oAuth integration provides a powerful alternative way to authenticate into your project. Directus will ask you
to login on the external service, and if your user exists in Directus, you'll be logged in automatically.
#### Multiple Providers
Directus relies on [`grant`](https://www.npmjs.com/package/grant) for the handling of the oAuth flow. This means that
there's hundreds of services that are supported out of the box. For example, enabling logging in through GitHub is as
easy as creating an [oAuth app in GitHub](https://github.com/settings/developers) and adding the following to Directus:
You can configure multiple providers for handling authentication in Directus. This allows for different options when
logging in. To do this, you can provide a CSV of provider names, and provide a config block for each of them:
```
OAUTH_PROVIDERS="github"
OAUTH_GITHUB_KEY="99d3...c3c4"
OAUTH_GITHUB_SECRET="34ae...f963"
AUTH_PROVIDERS="google,adobe"
AUTH_GOOGLE_DRIVER="openid"
AUTH_GOOGLE_CLIENT_ID="<google_application_id>"
AUTH_GOOGLE_CLIENT_SECRET= "<google_application_secret_key>"
AUTH_GOOGLE_ISSUER_URL="https://accounts.google.com"
AUTH_GOOGLE_ALLOW_PUBLIC_REGISTRATION="true"
AUTH_GOOGLE_DEFAULT_ROLE_ID="<directus_role_id>"
AUTH_GOOGLE_ICON="google"
AUTH_ADOBE_DRIVER="oauth2"
AUTH_ADOBE_CLIENT_ID="<adobe_application_id>"
AUTH_ADOBE_CLIENT_SECRET="<adobe_application_secret_key>"
AUTH_ADOBE_AUTHORIZE_URL="https://ims-na1.adobelogin.com/ims/authorize/v2"
AUTH_ADOBE_ACCESS_URL="https://ims-na1.adobelogin.com/ims/token/v3"
AUTH_ADOBE_PROFILE_URL="https://ims-na1.adobelogin.com/ims/userinfo/v2"
AUTH_ADOBE_ALLOW_PUBLIC_REGISTRATION="true"
AUTH_ADOBE_DEFAULT_ROLE_ID="<directus_role_id>"
AUTH_ADOBE_ICON="adobe"
```
### OAuth 2.0 and OpenID
Directus' OAuth 2.0 and OpenID integrations provide powerful alternative ways to authenticate into your project.
Directus will ask you to login on the external service, and return authenticated with a Directus account linked to that
service.
Directus supports hundreds of OAuth 2.0 and OpenID services, but requires some configuration to authenticate users
correctly. For example, enabling authentication through GitHub requires creating an
[OAuth 2.0 app in GitHub](https://github.com/settings/developers) and adding the following configuration to Directus:
```
AUTH_PROVIDERS="github"
AUTH_GITHUB_CLIENT_ID="99d3...c3c4"
AUTH_GITHUB_CLIENT_SECRET="34ae...f963"
AUTH_GITHUB_AUTHORIZE_URL="https://github.com/login/oauth/authorize"
AUTH_GITHUB_ACCESS_URL="https://github.com/login/oauth/access_token"
AUTH_GITHUB_PROFILE_URL="https://api.github.com/user"
```
::: warning PUBLIC_URL
The oAuth flow relies on the `PUBLIC_URL` variable for it's redirecting. Make sure that variable is configured
correctly.
These flows rely on the `PUBLIC_URL` variable for redirecting. Make sure that variable is configured correctly.
:::
#### Multiple Providers
`OAUTH_PROVIDERS` accepts a CSV of providers, allowing you to specify multiple at the same time:
```
OAUTH_PROVIDERS ="google,microsoft"
OAUTH_GOOGLE_KEY = "<google_application_id>"
OAUTH_GOOGLE_SECRET= "<google_application_secret_key>"
OAUTH_GOOGLE_SCOPE="openid email"
OAUTH_MICROSOFT_KEY = "<microsoft_application_id>"
OAUTH_MICROSOFT_SECRET = "<microsoft_application_secret_key>"
OAUTH_MICROSOFT_SCOPE = "openid email"
OAUTH_MICROSOFT_AUTHORIZE_URL = "https://login.microsoftonline.com/<microsoft_application_id>/oauth2/v2.0/authorize"
OAUTH_MICROSOFT_ACCESS_URL = "https://login.microsoftonline.com/<microsoft_application_id>/oauth2/v2.0/token"
PUBLIC_URL = "<public_url_of_directus_instance>"
```
### Provider Specific Configuration
If you use one of the many supported providers, you often don't have to configure any more than just the key and secret
for the service. That being said, if you use a more tailored service (like the specific Microsoft application in the
example above), you might have to provide more configuration values yourself. Please see
https://github.com/simov/grant#configuration-description for a list of all available configuration flags.
## File Storage
By default, Directus stores every file you upload locally on disk. Instead of local file storage, you can configure

View File

@@ -72,39 +72,37 @@ module.exports = function registerHook({ exceptions }) {
### Event Format Options
| Scope | Actions | Before |
| ------------------------------- | ------------------------------------------------------------------ | ---------------- |
| `cron()` | [See below for configuration](#interval-cron) | No |
| `cli.init` | `before` and `after` | No |
| `server` | `start` and `stop` | Optional |
| `init` | | Optional |
| `routes.init` | `before` and `after` | No |
| `routes.custom.init` | `before` and `after` | No |
| `middlewares.init` | `before` and `after` | No |
| `request` | `not_found` | No |
| `response` | | No<sup>[1]</sup> |
| `database.error` | When a database error is thrown | No |
| `error` | | No |
| `auth` | `login`, `logout`<sup>[1]</sup>, `jwt` and `refresh`<sup>[1]</sup> | Optional |
| `oauth.:provider`<sup>[2]</sup> | `login` and `redirect` | Optional |
| `items` | `read`<sup>[3]</sup>, `create`, `update` and `delete` | Optional |
| `activity` | `create`, `update` and `delete` | Optional |
| `collections` | `create`, `update` and `delete` | Optional |
| `fields` | `create`, `update` and `delete` | Optional |
| `files` | `upload`<sup>[3]</sup> | No |
| `folders` | `create`, `update` and `delete` | Optional |
| `permissions` | `create`, `update` and `delete` | Optional |
| `presets` | `create`, `update` and `delete` | Optional |
| `relations` | `create`, `update` and `delete` | Optional |
| `revisions` | `create`, `update` and `delete` | Optional |
| `roles` | `create`, `update` and `delete` | Optional |
| `settings` | `create`, `update` and `delete` | Optional |
| `users` | `create`, `update` and `delete` | Optional |
| `webhooks` | `create`, `update` and `delete` | Optional |
| Scope | Actions | Before |
| -------------------- | ------------------------------------------------------------------ | ---------------- |
| `cron()` | [See below for configuration](#interval-cron) | No |
| `cli.init` | `before` and `after` | No |
| `server` | `start` and `stop` | Optional |
| `init` | | Optional |
| `routes.init` | `before` and `after` | No |
| `routes.custom.init` | `before` and `after` | No |
| `middlewares.init` | `before` and `after` | No |
| `request` | `not_found` | No |
| `response` | | No<sup>[1]</sup> |
| `database.error` | When a database error is thrown | No |
| `error` | | No |
| `auth` | `login`, `logout`<sup>[1]</sup>, `jwt` and `refresh`<sup>[1]</sup> | Optional |
| `items` | `read`<sup>[2]</sup>, `create`, `update` and `delete` | Optional |
| `activity` | `create`, `update` and `delete` | Optional |
| `collections` | `create`, `update` and `delete` | Optional |
| `fields` | `create`, `update` and `delete` | Optional |
| `files` | `upload`<sup>[2]</sup> | No |
| `folders` | `create`, `update` and `delete` | Optional |
| `permissions` | `create`, `update` and `delete` | Optional |
| `presets` | `create`, `update` and `delete` | Optional |
| `relations` | `create`, `update` and `delete` | Optional |
| `revisions` | `create`, `update` and `delete` | Optional |
| `roles` | `create`, `update` and `delete` | Optional |
| `settings` | `create`, `update` and `delete` | Optional |
| `users` | `create`, `update` and `delete` | Optional |
| `webhooks` | `create`, `update` and `delete` | Optional |
<sup>1</sup> Feature Coming Soon\
<sup>2</sup> oAuth provider name can replaced with wildcard for any oauth providers `oauth.*.login`\
<sup>3</sup> Doesn't support `.before` modifier
<sup>2</sup> Doesn't support `.before` modifier
#### Interval (cron)
@@ -196,7 +194,7 @@ receive the primary key(s) of the items but the query used:
#### Auth
The `auth` and `oauth` hooks have the following context properties:
The `auth` hooks have the following context properties:
- `event` — Full event string
- `accountability` — Information about the current user
@@ -204,9 +202,7 @@ The `auth` and `oauth` hooks have the following context properties:
- `payload` Payload of the request
- `schema` - The current API schema in use
- `status` - One of `pending`, `success`, `fail`
- `user` <sup>†</sup> - ID of the user that tried logging in/has logged in
<sup>†</sup> Not available in `oauth`
- `user` - ID of the user that tried logging in/has logged in
## 5. Restart the API

View File

@@ -80,6 +80,10 @@ The token's expiration time can be configured through
POST /auth/login
```
```
POST /auth/login/:provider
```
```json
{
"email": "admin@example.com",
@@ -331,17 +335,16 @@ mutation {
---
## List oAuth providers
## List Auth Providers
List all the configured oAuth providers.
List all the configured auth providers.
<div class="two-up">
<div class="left">
::: tip Configuring oAuth
::: tip Configuring auth providers
To learn more about setting up oAuth providers, see
[Configuring SSO through oAuth](/guides/api-config/#oauth-single-sign-on-sso-openid).
To learn more about setting up auth providers, see [Configuring auth providers](/guides/api-config/#auth).
:::
@@ -350,7 +353,7 @@ To learn more about setting up oAuth providers, see
<div class="definitions">
`data` **Array**\
Array of configured oAuth providers.
Array of configured auth providers.
</div>
@@ -358,12 +361,27 @@ Array of configured oAuth providers.
<div class="right">
```
GET /auth/oauth
GET /auth
```
```json
{
"data": ["GitHub", "Google", "Okta"]
"data": [
{
"name": "GitHub",
"driver": "oauth2",
"icon": "github"
},
{
"name": "Google",
"driver": "openid",
"icon": "google"
},
{
"name": "Okta",
"driver": "openid"
}
]
}
```
@@ -372,15 +390,15 @@ GET /auth/oauth
---
## Login using oAuth provider
## Login Using SSO Providers
Will redirect to the configured oAuth provider for the user to login.
Will redirect to the configured SSO provider for the user to login.
<div class="two-up">
<div class="right">
```
GET /auth/oauth/:provider
GET /auth/login/:provider
```
</div>

View File

@@ -196,40 +196,6 @@ Alternatively, you can provide the individual connection parameters:
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `CACHE_MEMCACHE` | Location of your memcache instance. You can use [`array:` syntax](#environment-syntax-prefix), eg: `array:<instance-1>,<instance-2>` for multiple memcache instances. | --- |
## Sessions
Sessions are only used in the oAuth authentication flow.
| Variable | Description | Default Value |
| --------------- | ------------------------------------------------------------------------------------ | ------------- |
| `SESSION_STORE` | Where to store the session data. Either `memory`, `redis`, `memcache` or `database`. | `memory` |
Based on the `SESSION_STORE` used, you must also provide the following configurations:
### Memory
No additional configuration required.
### Redis
| Variable | Description | Default Value |
| --------------- | --------------------------------------------------------------------- | ------------- |
| `SESSION_REDIS` | Redis connection string, eg: `redis://:authpassword@127.0.0.1:6380/4` | --- |
Alternatively, you can provide the individual connection parameters:
| Variable | Description | Default Value |
| ------------------------ | -------------------------------- | ------------- |
| `SESSION_REDIS_HOST` | Hostname of the Redis instance | -- |
| `SESSION_REDIS_PORT` | Port of the Redis instance | -- |
| `SESSION_REDIS_PASSWORD` | Password for your Redis instance | -- |
### Memcache
| Variable | Description | Default Value |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `SESSION_MEMCACHE_HOSTS` | Location of your memcache instance. You can use [`array:` syntax](#environment-syntax-prefix), eg: `array:<instance-1>,<instance-2>` for multiple memcache instances. | --- |
### Database
No additional configuration required.
@@ -301,28 +267,57 @@ STORAGE_LOCAL_ROOT="./uploads"
Image transformations can be fairly heavy on memory usage. If you're using a system with 1GB or less available memory,
we recommend lowering the allowed concurrent transformations to prevent you from overflowing your server.
## OAuth
## Auth
| Variable | Description | Default Value |
| ----------------- | --------------------------------------- | ------------- |
| `OAUTH_PROVIDERS` | CSV of oAuth providers you want to use. | -- |
| Variable | Description | Default Value |
| ---------------- | -------------------------------------- | ------------- |
| `AUTH_PROVIDERS` | CSV of auth providers you want to use. | -- |
For each of the OAuth providers you list, you must also provide a number of extra variables. These differ per external
service. The following is a list of common required configuration options:
For each of the auth providers you list, you must provide the following configuration:
| Variable | Description | Default Value |
| -------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------- |
| `OAUTH_<PROVIDER>_KEY` | oAuth key (a.k.a. application id) for the external service. | -- |
| `OAUTH_<PROVIDER>_SECRET` | oAuth secret for the external service. | -- |
| `OAUTH_<PROVIDER>_SCOPE` | A white-space separated list of privileges directus should ask for. A common value is: `openid email`. | -- |
| `OAUTH_<PROVIDER>_AUTHORIZE_URL` | The authorize page URL of the external service | -- |
| `OAUTH_<PROVIDER>_ACCESS_URL` | The access URL of the external service | -- |
| `OAUTH_<PROVIDER>_PROFILE_URL` | Where Directus can fetch the profile information of the authenticated user. | -- |
| Variable | Description | Default Value |
| ------------------------ | ------------------------------------------------------- | ------------- |
| `AUTH_<PROVIDER>_DRIVER` | Which driver to use, either `local`, `oauth2`, `openid` | -- |
Directus relies on [`grant`](https://www.npmjs.com/package/grant) for the handling of the oAuth flow. Grant includes
[a lot of default values](https://github.com/simov/grant/blob/master/config/oauth.json) for popular services. For
example, if you use `apple` as one of your providers, you only have to specify the key and secret, as Grant has the rest
covered. Checkout [the grant repo](https://github.com/simov/grant) for more information.
You must also provide a number of extra variables. These differ per auth driver service. The following is a list of
common required configuration options:
### Local (`local`)
No additional configuration required.
### OAuth 2.0 (`oauth2`)
| Variable | Description | Default Value |
| ------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------- |
| `AUTH_<PROVIDER>_CLIENT_ID` | OAuth identifier for the external service. | -- |
| `AUTH_<PROVIDER>_CLIENT_SECRET` | OAUth secret for the external service. | -- |
| `AUTH_<PROVIDER>_SCOPE` | A white-space separated list of privileges Directus will request. | `email` |
| `AUTH_<PROVIDER>_AUTHORIZE_URL` | The authorize page URL of the external service. | -- |
| `AUTH_<PROVIDER>_ACCESS_URL` | The token access URL of the external service. | -- |
| `AUTH_<PROVIDER>_PROFILE_URL` | Where Directus can fetch the profile information of the authenticated user. | -- |
| `AUTH_<PROVIDER>_EMAIL_KEY` | OAuth profile email key used to verify the user. | `email` |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY` | OAuth profile identifier key used to verify the user. Can be used in place of `EMAIL_KEY`. | -- |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Whether to allow public registration of authenticating users. | `false` |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID` | Directus role ID to assign to users. | -- |
| `AUTH_<PROVIDER>_ICON` | SVG icon to display with the login link. | `account_circle` |
If possible, OpenID is preferred over OAuth 2.0 as it provides better verification and consistent user information,
allowing more complete user registrations.
### OpenID (`openid`)
| Variable | Description | Default Value |
| ------------------------------------------- | ----------------------------------------------------------------- | ---------------------- |
| `AUTH_<PROVIDER>_CLIENT_ID` | OpenID identifier for the external service. | -- |
| `AUTH_<PROVIDER>_CLIENT_SECRET` | OpenID secret for the external service. | -- |
| `AUTH_<PROVIDER>_SCOPE` | A white-space separated list of privileges Directus will request. | `openid profile email` |
| `AUTH_<PROVIDER>_ISSUER_URL` | The OpenID `.well-known` Discovery Document URL. | -- |
| `AUTH_<PROVIDER>_IDENTIFIER_KEY` | OpenID profile identifier key used to verify the user. | `sub` |
| `AUTH_<PROVIDER>_ALLOW_PUBLIC_REGISTRATION` | Whether to allow public registration of authenticating users. | `false` |
| `AUTH_<PROVIDER>_REQUIRE_VERIFIED_EMAIL` | Require users to have a verified email address. | `false` |
| `AUTH_<PROVIDER>_DEFAULT_ROLE_ID` | Directus role ID to assign to users. | -- |
| `AUTH_<PROVIDER>_ICON` | SVG icon to display with the login link. | `account_circle` |
## Extensions

576
package-lock.json generated
View File

@@ -96,10 +96,8 @@
"execa": "^5.1.1",
"exifr": "^7.1.2",
"express": "^4.17.1",
"express-session": "^1.17.2",
"flat": "^5.0.2",
"fs-extra": "^10.0.0",
"grant": "^5.4.14",
"graphql": "^15.5.0",
"graphql-compose": "^9.0.1",
"inquirer": "^8.1.1",
@@ -122,6 +120,7 @@
"nodemailer": "^6.6.1",
"object-hash": "^2.2.0",
"openapi3-ts": "^2.0.0",
"openid-client": "^4.9.0",
"ora": "^5.4.0",
"otplib": "^12.0.1",
"pino": "6.13.3",
@@ -303,6 +302,8 @@
"@directus/extensions-sdk": "9.0.0-rc.98",
"@directus/format-title": "9.0.0-rc.98",
"@directus/shared": "9.0.0-rc.98",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fullcalendar/core": "5.10.0",
"@fullcalendar/daygrid": "5.10.0",
"@fullcalendar/interaction": "5.10.0",
@@ -3269,6 +3270,42 @@
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"dev": true,
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fullcalendar/common": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@fullcalendar/common/-/common-5.10.0.tgz",
@@ -6529,6 +6566,14 @@
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"node_modules/@panva/asn1.js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz",
"integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -11930,7 +11975,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"devOptional": true,
"dev": true,
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
@@ -12772,7 +12817,7 @@
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"devOptional": true
"dev": true
},
"node_modules/body-parser": {
"version": "1.19.0",
@@ -12905,7 +12950,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
"devOptional": true
"dev": true
},
"node_modules/browser-process-hrtime": {
"version": "1.0.0",
@@ -18123,7 +18168,7 @@
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
"integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
"devOptional": true,
"dev": true,
"dependencies": {
"bn.js": "^4.11.9",
"brorand": "^1.1.0",
@@ -19822,72 +19867,6 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"peer": true
},
"node_modules/express-session": {
"version": "1.17.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.2.tgz",
"integrity": "sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ==",
"dependencies": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/express-session/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express-session/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express-session/node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express-session/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"node_modules/express-session/node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -24681,43 +24660,6 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
"integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg=="
},
"node_modules/grant": {
"version": "5.4.18",
"resolved": "https://registry.npmjs.org/grant/-/grant-5.4.18.tgz",
"integrity": "sha512-5rw0RkbpmzgM1Q4n8FSasnlqud2NcyxFaEMjA++JQ77MGbRL8VnUZ3JeBoF0HTyfI+xzJbHn68LhtLdYnBz3rA==",
"dependencies": {
"qs": "^6.10.1",
"request-compose": "^2.1.4",
"request-oauth": "^1.0.1"
},
"engines": {
"node": ">=12.0.0"
},
"optionalDependencies": {
"cookie": "^0.4.1",
"cookie-signature": "^1.1.0",
"jwk-to-pem": "^2.0.5",
"jws": "^4.0.0"
}
},
"node_modules/grant/node_modules/cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"optional": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/grant/node_modules/cookie-signature": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.1.0.tgz",
"integrity": "sha512-Alvs19Vgq07eunykd3Xy2jF0/qSNv2u7KDbAek9H5liV1UMijbqFs5cycZvv5dVsvseT/U4H8/7/w8Koh35C4A==",
"optional": true,
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -25207,7 +25149,7 @@
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"devOptional": true,
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
@@ -25296,7 +25238,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
"integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
"devOptional": true,
"dev": true,
"dependencies": {
"hash.js": "^1.0.3",
"minimalistic-assert": "^1.0.0",
@@ -27799,6 +27741,20 @@
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/jose": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz",
"integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==",
"dependencies": {
"@panva/asn1.js": "^1.0.0"
},
"engines": {
"node": ">=10.13.0 < 13 || >=13.7.0"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -28265,17 +28221,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwk-to-pem": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz",
"integrity": "sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==",
"optional": true,
"dependencies": {
"asn1.js": "^5.3.0",
"elliptic": "^6.5.4",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
@@ -32260,7 +32205,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
"integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
"devOptional": true
"dev": true
},
"node_modules/minimatch": {
"version": "3.0.4",
@@ -34163,6 +34108,7 @@
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"dev": true,
"engines": {
"node": "*"
}
@@ -34463,6 +34409,14 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"devOptional": true
},
"node_modules/oidc-token-hash": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -34542,6 +34496,94 @@
"opencollective-postinstall": "index.js"
}
},
"node_modules/openid-client": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.9.1.tgz",
"integrity": "sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==",
"dependencies": {
"aggregate-error": "^3.1.0",
"got": "^11.8.0",
"jose": "^2.0.5",
"lru-cache": "^6.0.0",
"make-error": "^1.3.6",
"object-hash": "^2.0.1",
"oidc-token-hash": "^5.0.1"
},
"engines": {
"node": "^10.19.0 || >=12.0.0 < 13 || >=13.7.0 < 14 || >= 14.2.0"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/@sindresorhus/is": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
"integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/openid-client/node_modules/cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
"engines": {
"node": ">=10.6.0"
}
},
"node_modules/openid-client/node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openid-client/node_modules/got": {
"version": "11.8.2",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
"integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
"dependencies": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.1",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
},
"engines": {
"node": ">=10.19.0"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/openid-client/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opentracing": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.5.tgz",
@@ -37966,14 +38008,6 @@
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.24.1.tgz",
"integrity": "sha1-w7d1UZfzW43DUCIoJixMkd22uFc="
},
"node_modules/random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -39254,35 +39288,6 @@
"node": ">= 6"
}
},
"node_modules/request-compose": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/request-compose/-/request-compose-2.1.4.tgz",
"integrity": "sha512-F8xik9Dxd5i2aHZ0/L/oIrCM1kKSgvp9BKYxGXk91lSWF9TbicWpnuxdOchxIhEWwvLdSBWZIAbCOeXfGfqaqA==",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/request-oauth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/request-oauth/-/request-oauth-1.0.1.tgz",
"integrity": "sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==",
"dependencies": {
"oauth-sign": "^0.9.0",
"qs": "^6.9.6",
"uuid": "^8.3.2"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/request-oauth/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/request/node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
@@ -45063,17 +45068,6 @@
"node": "*"
}
},
"node_modules/uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"dependencies": {
"random-bytes": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/umask": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/umask/-/umask-1.1.0.tgz",
@@ -51193,6 +51187,8 @@
"@directus/extensions-sdk": "9.0.0-rc.98",
"@directus/format-title": "9.0.0-rc.98",
"@directus/shared": "9.0.0-rc.98",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fullcalendar/core": "5.10.0",
"@fullcalendar/daygrid": "5.10.0",
"@fullcalendar/interaction": "5.10.0",
@@ -51898,6 +51894,30 @@
}
}
},
"@fortawesome/fontawesome-common-types": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"dev": true
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"dev": true,
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"dev": true,
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fullcalendar/common": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@fullcalendar/common/-/common-5.10.0.tgz",
@@ -54629,6 +54649,11 @@
"@otplib/plugin-thirty-two": "^12.0.1"
}
},
"@panva/asn1.js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz",
"integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw=="
},
"@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -59232,7 +59257,7 @@
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"devOptional": true,
"dev": true,
"requires": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
@@ -59927,7 +59952,7 @@
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==",
"devOptional": true
"dev": true
},
"body-parser": {
"version": "1.19.0",
@@ -60045,7 +60070,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=",
"devOptional": true
"dev": true
},
"browser-process-hrtime": {
"version": "1.0.0",
@@ -63961,10 +63986,8 @@
"execa": "^5.1.1",
"exifr": "^7.1.2",
"express": "^4.17.1",
"express-session": "^1.17.2",
"flat": "^5.0.2",
"fs-extra": "^10.0.0",
"grant": "^5.4.14",
"graphql": "^15.5.0",
"graphql-compose": "^9.0.1",
"inquirer": "^8.1.1",
@@ -63993,6 +64016,7 @@
"nodemailer-mailgun-transport": "^2.1.3",
"object-hash": "^2.2.0",
"openapi3-ts": "^2.0.0",
"openid-client": "^4.9.0",
"ora": "^5.4.0",
"otplib": "^12.0.1",
"pg": "^8.6.0",
@@ -64371,7 +64395,7 @@
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz",
"integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==",
"devOptional": true,
"dev": true,
"requires": {
"bn.js": "^4.11.9",
"brorand": "^1.1.0",
@@ -65664,51 +65688,6 @@
}
}
},
"express-session": {
"version": "1.17.2",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.2.tgz",
"integrity": "sha512-mPcYcLA0lvh7D4Oqr5aNJFMtBMKPLl++OKKxkHzZ0U0oDq1rpKBnkR5f5vCHR26VeArlTOEF9td4x5IjICksRQ==",
"requires": {
"cookie": "0.4.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-headers": "~1.0.2",
"parseurl": "~1.3.3",
"safe-buffer": "5.2.1",
"uid-safe": "~2.1.5"
},
"dependencies": {
"cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"ext": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
@@ -69400,34 +69379,6 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
"integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg=="
},
"grant": {
"version": "5.4.18",
"resolved": "https://registry.npmjs.org/grant/-/grant-5.4.18.tgz",
"integrity": "sha512-5rw0RkbpmzgM1Q4n8FSasnlqud2NcyxFaEMjA++JQ77MGbRL8VnUZ3JeBoF0HTyfI+xzJbHn68LhtLdYnBz3rA==",
"requires": {
"cookie": "^0.4.1",
"cookie-signature": "^1.1.0",
"jwk-to-pem": "^2.0.5",
"jws": "^4.0.0",
"qs": "^6.10.1",
"request-compose": "^2.1.4",
"request-oauth": "^1.0.1"
},
"dependencies": {
"cookie": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==",
"optional": true
},
"cookie-signature": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.1.0.tgz",
"integrity": "sha512-Alvs19Vgq07eunykd3Xy2jF0/qSNv2u7KDbAek9H5liV1UMijbqFs5cycZvv5dVsvseT/U4H8/7/w8Koh35C4A==",
"optional": true
}
}
},
"graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
@@ -69804,7 +69755,7 @@
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"devOptional": true,
"dev": true,
"requires": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
@@ -69874,7 +69825,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
"integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
"devOptional": true,
"dev": true,
"requires": {
"hash.js": "^1.0.3",
"minimalistic-assert": "^1.0.0",
@@ -71809,6 +71760,14 @@
}
}
},
"jose": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz",
"integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==",
"requires": {
"@panva/asn1.js": "^1.0.0"
}
},
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -72187,17 +72146,6 @@
"safe-buffer": "^5.0.1"
}
},
"jwk-to-pem": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz",
"integrity": "sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==",
"optional": true,
"requires": {
"asn1.js": "^5.3.0",
"elliptic": "^6.5.4",
"safe-buffer": "^5.0.1"
}
},
"jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
@@ -75161,7 +75109,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
"integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=",
"devOptional": true
"dev": true
},
"minimatch": {
"version": "3.0.4",
@@ -76764,7 +76712,8 @@
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -76979,6 +76928,11 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"devOptional": true
},
"oidc-token-hash": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
"integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ=="
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -77037,6 +76991,63 @@
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q=="
},
"openid-client": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-4.9.1.tgz",
"integrity": "sha512-DYUF07AHjI3QDKqKbn2F7RqozT4hyi4JvmpodLrq0HHoNP7t/AjeG/uqiBK1/N2PZSAQEThVjDLHSmJN4iqu/w==",
"requires": {
"aggregate-error": "^3.1.0",
"got": "^11.8.0",
"jose": "^2.0.5",
"lru-cache": "^6.0.0",
"make-error": "^1.3.6",
"object-hash": "^2.0.1",
"oidc-token-hash": "^5.0.1"
},
"dependencies": {
"@sindresorhus/is": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
"integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw=="
},
"cacheable-lookup": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="
},
"decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"requires": {
"mimic-response": "^3.1.0"
}
},
"got": {
"version": "11.8.2",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
"integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
"requires": {
"@sindresorhus/is": "^4.0.0",
"@szmarczak/http-timer": "^4.0.5",
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.1",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
"p-cancelable": "^2.0.0",
"responselike": "^2.0.0"
}
},
"mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
}
}
},
"opentracing": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.5.tgz",
@@ -79800,11 +79811,6 @@
}
}
},
"random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
"integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -80842,28 +80848,6 @@
}
}
},
"request-compose": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/request-compose/-/request-compose-2.1.4.tgz",
"integrity": "sha512-F8xik9Dxd5i2aHZ0/L/oIrCM1kKSgvp9BKYxGXk91lSWF9TbicWpnuxdOchxIhEWwvLdSBWZIAbCOeXfGfqaqA=="
},
"request-oauth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/request-oauth/-/request-oauth-1.0.1.tgz",
"integrity": "sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==",
"requires": {
"oauth-sign": "^0.9.0",
"qs": "^6.9.6",
"uuid": "^8.3.2"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
}
}
},
"require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -85402,14 +85386,6 @@
"integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=",
"dev": true
},
"uid-safe": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
"integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
"requires": {
"random-bytes": "~1.0.0"
}
},
"umask": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/umask/-/umask-1.1.0.tgz",