Authentication Improvements (#236)

This commit is contained in:
Chris Anderson 2023-08-09 17:10:38 -07:00 committed by GitHub
parent 8635b0293e
commit a053b555da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 2259 additions and 1840 deletions

View file

@ -20,3 +20,4 @@ STORAGE_BASE_URL=http://localhost:3000/uploads
AUTH_DRIVER=basic AUTH_DRIVER=basic
AUTH_BASIC_EMAIL=test@parcelvoy.com AUTH_BASIC_EMAIL=test@parcelvoy.com
AUTH_BASIC_PASSWORD=password AUTH_BASIC_PASSWORD=password
AUTH_BASIC_NAME=Login

File diff suppressed because it is too large Load diff

View file

@ -53,7 +53,7 @@ export default class Api extends Koa {
.use(cors()) .use(cors())
.use(serve('./public', { .use(serve('./public', {
hidden: true, hidden: true,
defer: !app.env.mono, defer: !app.env.config.monoDocker,
})) }))
this.registerControllers() this.registerControllers()

View file

@ -1,13 +1,11 @@
import loadDatabase, { Database } from './config/database' import loadDatabase, { Database } from './config/database'
import loadQueue from './config/queue' import loadQueue from './config/queue'
import loadStorage from './config/storage' import loadStorage from './config/storage'
import loadAuth from './config/auth'
import loadError, { logger } from './config/logger' import loadError, { logger } from './config/logger'
import loadRateLimit, { RateLimiter } from './config/rateLimit' import loadRateLimit, { RateLimiter } from './config/rateLimit'
import type { Env } from './config/env' import type { Env } from './config/env'
import type Queue from './queue' import type Queue from './queue'
import Storage from './storage' import Storage from './storage'
import type Auth from './auth/Auth'
import { uuid } from './utilities' import { uuid } from './utilities'
import Api from './api' import Api from './api'
import Worker from './worker' import Worker from './worker'
@ -39,14 +37,10 @@ export default class App {
// Load storage // Load storage
const storage = loadStorage(env.storage) const storage = loadStorage(env.storage)
// Load auth
const auth = loadAuth(env.auth)
// Setup app // Setup app
const app = new this(env, const app = new this(env,
database, database,
queue, queue,
auth,
storage, storage,
error, error,
) as any ) as any
@ -70,7 +64,6 @@ export default class App {
public env: Env, public env: Env,
public db: Database, public db: Database,
public queue: Queue, public queue: Queue,
public auth: Auth,
public storage: Storage, public storage: Storage,
public error: ErrorHandler, public error: ErrorHandler,
) { ) {

View file

@ -1,56 +1,96 @@
import { Context } from 'koa' import { Context } from 'koa'
import AuthProvider from './AuthProvider' import AuthProvider from './AuthProvider'
import OpenIDProvider, { OpenIDConfig } from './OpenIDAuthProvider' import OpenIDProvider, { OpenIDConfig } from './OpenIDAuthProvider'
import GoogleProvider, { GoogleConfig } from './GoogleAuthProvider'
import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider' import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider'
import { DriverConfig } from '../config/env' import { DriverConfig } from '../config/env'
import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider' import BasicAuthProvider, { BasicAuthConfig } from './BasicAuthProvider'
import { getOrganizationByUsername } from '../organizations/OrganizationService' import Organization from '../organizations/Organization'
import App from '../app'
import MultiAuthProvider, { MultiAuthConfig } from './MultiAuthProvider'
export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'logger' export type AuthProviderName = 'basic' | 'saml' | 'openid' | 'google' | 'multi'
export type AuthConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig export type AuthProviderConfig = BasicAuthConfig | SAMLConfig | OpenIDConfig | GoogleConfig | MultiAuthConfig
export interface AuthConfig {
driver: AuthProviderName[]
tokenLife: number
basic: BasicAuthConfig
saml: SAMLConfig
openid: OpenIDConfig
google: GoogleConfig
multi: MultiAuthConfig
}
export { BasicAuthConfig, SAMLConfig, OpenIDConfig }
export interface AuthTypeConfig extends DriverConfig { export interface AuthTypeConfig extends DriverConfig {
tokenLife: number
driver: AuthProviderName driver: AuthProviderName
name?: string
} }
export default class Auth { interface AuthMethod {
provider: AuthProvider driver: AuthProviderName
name: string
}
constructor(config?: AuthConfig) { export const initProvider = (config?: AuthProviderConfig): AuthProvider => {
this.provider = Auth.provider(config) if (config?.driver === 'basic') {
} return new BasicAuthProvider(config)
} else if (config?.driver === 'saml') {
static provider(config?: AuthConfig): AuthProvider { return new SAMLProvider(config)
if (config?.driver === 'basic') { } else if (config?.driver === 'openid') {
return new BasicAuthProvider(config) return new OpenIDProvider(config)
} else if (config?.driver === 'saml') { } else if (config?.driver === 'google') {
return new SAMLProvider(config) return new GoogleProvider(config)
} else if (config?.driver === 'openid') { } else if (config?.driver === 'multi') {
return new OpenIDProvider(config) return new MultiAuthProvider()
} else { } else {
throw new Error('A valid auth driver must be set!') throw new Error('A valid auth driver must be set!')
}
}
async start(ctx: Context): Promise<void> {
const provider = await this.loadProvider(ctx)
return await provider.start(ctx)
}
async validate(ctx: Context): Promise<void> {
const provider = await this.loadProvider(ctx)
return await provider.validate(ctx)
}
private async loadProvider(ctx: Context): Promise<AuthProvider> {
if (ctx.subdomains && ctx.subdomains[0]) {
const subdomain = ctx.subdomains[0]
const org = await getOrganizationByUsername(subdomain)
ctx.state.organization = org
if (org) return Auth.provider(org.auth)
}
return this.provider
} }
} }
export const authMethods = async (organization?: Organization): Promise<AuthMethod[]> => {
// If we know the org, don't require any extra steps like
// providing email since we know where to route you. Otherwise
// we need context to properly fetch SSO and such.
return organization
? [mapMethod(organization.auth)]
: mapMethods(App.main.env.auth)
}
export const checkAuth = (organization?: Organization): boolean => {
return organization != null && organization.auth != null
}
export const startAuth = async (ctx: Context): Promise<void> => {
const provider = await loadProvider(ctx)
return await provider.start(ctx)
}
export const validateAuth = async (ctx: Context): Promise<void> => {
const provider = await loadProvider(ctx)
return await provider.validate(ctx)
}
const loadProvider = async (ctx: Context): Promise<AuthProvider> => {
const driver = ctx.params.driver as AuthProviderName
const organization = ctx.state.organization
if (organization) {
return initProvider(organization.auth)
}
return initProvider(App.main.env.auth[driver])
}
const mapMethods = (config: AuthConfig): AuthMethod[] => {
const drivers = config.driver
return drivers.map((driver) => mapMethod(config[driver]))
}
const mapMethod = ({ driver, name }: AuthTypeConfig): AuthMethod => ({
driver,
name: name ?? `Continue with ${driver}`,
})

View file

@ -1,27 +1,55 @@
import Router from '@koa/router' import Router from '@koa/router'
import App from '../app'
import { getTokenCookies, revokeAccessToken } from './TokenRepository' import { getTokenCookies, revokeAccessToken } from './TokenRepository'
import { Context } from 'koa'
import { getOrganization, getOrganizationByEmail, getOrganizationByUsername } from '../organizations/OrganizationService'
import Organization from '../organizations/Organization'
import { authMethods, checkAuth, startAuth, validateAuth } from './Auth'
const router = new Router({ const router = new Router<{
organization?: Organization
}>({
prefix: '/auth', prefix: '/auth',
}) })
router.get('/login', async ctx => { router.use(async (ctx: Context, next: () => void) => {
await App.main.auth.start(ctx) const organizationId = ctx.cookies.get('organization')
if (organizationId) {
ctx.state.organization = await getOrganization(parseInt(organizationId))
} else if (ctx.subdomains && ctx.subdomains[0]) {
const subdomain = ctx.subdomains[0]
ctx.state.organization = await getOrganizationByUsername(subdomain)
}
return next()
}) })
router.post('/login', async ctx => { router.get('/methods', async ctx => {
await App.main.auth.start(ctx) ctx.body = await authMethods(ctx.state.organization)
}) })
router.post('/login/callback', async ctx => { router.post('/check', async ctx => {
const email = ctx.query.email || ctx.request.body.email
const organization = await getOrganizationByEmail(email)
ctx.body = checkAuth(organization)
})
router.get('/login/:driver', async ctx => {
ctx.status = 204 ctx.status = 204
await App.main.auth.validate(ctx) await startAuth(ctx)
}) })
router.get('/login/callback', async ctx => { router.post('/login/:driver', async ctx => {
ctx.status = 204 ctx.status = 204
await App.main.auth.validate(ctx) await startAuth(ctx)
})
router.get('/login/:driver/callback', async ctx => {
ctx.status = 204
await validateAuth(ctx)
})
router.post('/login/:driver/callback', async ctx => {
ctx.status = 204
await validateAuth(ctx)
}) })
router.post('/logout', async ctx => { router.post('/logout', async ctx => {

View file

@ -7,7 +7,7 @@ import { createOrUpdateAdmin } from './AdminRepository'
import { generateAccessToken, OAuthResponse, setTokenCookies } from './TokenRepository' import { generateAccessToken, OAuthResponse, setTokenCookies } from './TokenRepository'
import Organization from '../organizations/Organization' import Organization from '../organizations/Organization'
import { State } from './AuthMiddleware' import { State } from './AuthMiddleware'
import { createOrganization, getOrganizationByDomain } from '../organizations/OrganizationService' import { createOrganization, getDefaultOrganization, getOrganizationByDomain } from '../organizations/OrganizationService'
type OrgState = State & { organization?: Organization } type OrgState = State & { organization?: Organization }
export type AuthContext = Context & { state: OrgState } export type AuthContext = Context & { state: OrgState }
@ -18,11 +18,22 @@ export default abstract class AuthProvider {
abstract validate(ctx: AuthContext): Promise<void> abstract validate(ctx: AuthContext): Promise<void>
async loadAuthOrganization(ctx: AuthContext, domain: string) { async loadAuthOrganization(ctx: AuthContext, domain: string) {
const organization = ctx.state.organization ?? await getOrganizationByDomain(domain)
if (!organization) { // If we have an organization or can find one by domain
return await createOrganization(domain) // we use that to start
let organization = ctx.state.organization ?? await getOrganizationByDomain(domain)
if (organization) return organization
// If we are not in multi-org mode we always fall back to
// a single organization
if (!App.main.env.config.multiOrg) {
organization = await getDefaultOrganization()
} }
return organization if (organization) return organization
// If there is no organization at all or are in multi-org mode
// and have no org for the user, create one
return await createOrganization(domain)
} }
async login(params: AdminParams, ctx?: AuthContext, redirect?: string): Promise<OAuthResponse> { async login(params: AdminParams, ctx?: AuthContext, redirect?: string): Promise<OAuthResponse> {

View file

@ -0,0 +1,31 @@
import { AuthTypeConfig } from './Auth'
import AuthProvider, { AuthContext } from './AuthProvider'
import OpenIDAuthProvider from './OpenIDAuthProvider'
export interface GoogleConfig extends AuthTypeConfig {
driver: 'google'
clientId: string
clientSecret: string
redirectUri: string
}
export default class GoogleAuthProvider extends AuthProvider {
private provider: OpenIDAuthProvider
constructor(config: GoogleConfig) {
super()
this.provider = new OpenIDAuthProvider({
...config,
driver: 'openid',
issuerUrl: 'https://accounts.google.com',
})
}
async start(ctx: AuthContext): Promise<void> {
return await this.provider.start(ctx)
}
async validate(ctx: AuthContext): Promise<void> {
return await this.provider.validate(ctx)
}
}

View file

@ -0,0 +1,27 @@
import { getOrganizationByEmail } from '../organizations/OrganizationService'
import { AuthTypeConfig, initProvider } from './Auth'
import AuthProvider, { AuthContext } from './AuthProvider'
export interface MultiAuthConfig extends AuthTypeConfig {
driver: 'multi'
}
export default class MultiAuthProvider extends AuthProvider {
async start(ctx: AuthContext): Promise<void> {
// Redirect to the default for the given org
if (ctx.query.email || ctx.request.body.email) {
const email = ctx.query.email ?? ctx.request.body.email
const organization = await getOrganizationByEmail(email)
if (organization) {
return await initProvider(organization.auth).start(ctx)
}
}
throw new Error('No organization found.')
}
async validate(): Promise<void> {
// Will never be called since it routes to other providers
}
}

View file

@ -11,9 +11,9 @@ export interface OpenIDConfig extends AuthTypeConfig {
driver: 'openid' driver: 'openid'
issuerUrl: string // 'https://accounts.google.com' issuerUrl: string // 'https://accounts.google.com'
clientId: string clientId: string
cliendSecret: string clientSecret: string
redirectUri: string redirectUri: string
domain: string domain?: string
} }
export default class OpenIDAuthProvider extends AuthProvider { export default class OpenIDAuthProvider extends AuthProvider {
@ -32,9 +32,6 @@ export default class OpenIDAuthProvider extends AuthProvider {
const client = await this.getClient() const client = await this.getClient()
const nonce = generators.nonce() const nonce = generators.nonce()
// store the nonce in your framework's session mechanism, if it is a cookie based solution
// it should be httpOnly (not readable by javascript) and encrypted.
ctx.cookies.set('nonce', nonce, { ctx.cookies.set('nonce', nonce, {
secure: ctx.request.secure, secure: ctx.request.secure,
httpOnly: true, httpOnly: true,
@ -49,6 +46,15 @@ export default class OpenIDAuthProvider extends AuthProvider {
expires: addSeconds(Date.now(), 3600), expires: addSeconds(Date.now(), 3600),
}) })
const organization = ctx.state.organization
if (organization) {
ctx.cookies.set('organization', `${organization.id}`, {
secure: ctx.request.secure,
httpOnly: true,
expires: addSeconds(Date.now(), 3600),
})
}
const url = client.authorizationUrl({ const url = client.authorizationUrl({
scope: 'openid email profile', scope: 'openid email profile',
response_mode: 'form_post', response_mode: 'form_post',
@ -95,6 +101,10 @@ export default class OpenIDAuthProvider extends AuthProvider {
} }
await this.login(admin, ctx, state) await this.login(admin, ctx, state)
ctx.cookies.set('nonce', null)
ctx.cookies.set('relaystate', null)
ctx.cookies.set('organization', null)
} catch (error) { } catch (error) {
logger.warn(error) logger.warn(error)
throw new RequestError(AuthError.OpenIdValidationError) throw new RequestError(AuthError.OpenIdValidationError)
@ -109,7 +119,7 @@ export default class OpenIDAuthProvider extends AuthProvider {
// TODO: Should we validate that we can use the issuer? // TODO: Should we validate that we can use the issuer?
this.client = new issuer.Client({ this.client = new issuer.Client({
client_id: this.config.clientId, client_id: this.config.clientId,
client_secret: this.config.cliendSecret, client_secret: this.config.clientSecret,
redirect_uris: [this.config.redirectUri], redirect_uris: [this.config.redirectUri],
response_types: ['id_token'], response_types: ['id_token'],
}) })

View file

@ -7,6 +7,7 @@ import { AuthTypeConfig } from './Auth'
import AuthProvider from './AuthProvider' import AuthProvider from './AuthProvider'
import AuthError from './AuthError' import AuthError from './AuthError'
import { firstQueryParam } from '../utilities' import { firstQueryParam } from '../utilities'
import { addSeconds } from 'date-fns'
export interface SAMLConfig extends AuthTypeConfig { export interface SAMLConfig extends AuthTypeConfig {
driver: 'saml' driver: 'saml'
@ -65,6 +66,15 @@ export default class SAMLAuthProvider extends AuthProvider {
const url = await this.saml.getAuthorizeUrlAsync(relayState, host, {}) const url = await this.saml.getAuthorizeUrlAsync(relayState, host, {})
const organization = ctx.state.organization
if (organization) {
ctx.cookies.set('organization', `${organization.id}`, {
secure: ctx.request.secure,
httpOnly: true,
expires: addSeconds(Date.now(), 3600),
})
}
ctx.redirect(url) ctx.redirect(url)
} }
@ -88,6 +98,8 @@ export default class SAMLAuthProvider extends AuthProvider {
const { id } = await this.loadAuthOrganization(ctx, domain) const { id } = await this.loadAuthOrganization(ctx, domain)
await this.login({ first_name, last_name, email, organization_id: id }, ctx, state) await this.login({ first_name, last_name, email, organization_id: id }, ctx, state)
ctx.cookies.set('organization', null)
} }
private getDomain(email: string): string | undefined { private getDomain(email: string): string | undefined {

View file

@ -1,6 +0,0 @@
import Auth, { AuthConfig } from '../auth/Auth'
export default (config: AuthConfig) => {
return new Auth(config)
}

View file

@ -38,7 +38,7 @@ export default (app: App) => {
} }
// If we are running in mono mode, we need to also serve the UI // If we are running in mono mode, we need to also serve the UI
if (app.env.mono) { if (app.env.config.monoDocker) {
const ui = new Router() const ui = new Router()
ui.get('/(.*)', async (ctx, next) => { ui.get('/(.*)', async (ctx, next) => {
try { try {

View file

@ -2,14 +2,17 @@ import * as dotenv from 'dotenv'
import type { StorageConfig } from '../storage/Storage' import type { StorageConfig } from '../storage/Storage'
import type { QueueConfig } from '../queue/Queue' import type { QueueConfig } from '../queue/Queue'
import type { DatabaseConfig } from './database' import type { DatabaseConfig } from './database'
import type { AuthConfig } from '../auth/Auth' import type { AuthConfig, AuthProviderName } from '../auth/Auth'
import type { ErrorConfig } from '../error/ErrorHandler' import type { ErrorConfig } from '../error/ErrorHandler'
import { RedisConfig } from './redis' import { RedisConfig } from './redis'
export type Runner = 'api' | 'worker' export type Runner = 'api' | 'worker'
export interface Env { export interface Env {
runners: Runner[] runners: Runner[]
mono: boolean config: {
monoDocker: boolean
multiOrg: boolean
}
db: DatabaseConfig db: DatabaseConfig
queue: QueueConfig queue: QueueConfig
storage: StorageConfig storage: StorageConfig
@ -50,7 +53,10 @@ export default (type?: EnvType): Env => {
return { return {
runners: (process.env.RUNNER ?? 'api,worker').split(',') as Runner[], runners: (process.env.RUNNER ?? 'api,worker').split(',') as Runner[],
mono: (process.env.MONO ?? 'false') === 'true', config: {
monoDocker: (process.env.MONO ?? 'false') === 'true',
multiOrg: (process.env.MULTI_ORG ?? 'false') === 'true',
},
db: { db: {
host: process.env.DB_HOST!, host: process.env.DB_HOST!,
user: process.env.DB_USERNAME!, user: process.env.DB_USERNAME!,
@ -97,28 +103,45 @@ export default (type?: EnvType): Env => {
apiBaseUrl, apiBaseUrl,
port, port,
secret: process.env.APP_SECRET!, secret: process.env.APP_SECRET!,
auth: driver<AuthConfig>(process.env.AUTH_DRIVER, { auth: {
basic: () => ({ driver: (process.env.AUTH_DRIVER?.split(',') ?? []) as AuthProviderName[],
tokenLife: defaultTokenLife, tokenLife: defaultTokenLife,
basic: {
driver: 'basic',
name: process.env.AUTH_BASIC_NAME!,
email: process.env.AUTH_BASIC_EMAIL!, email: process.env.AUTH_BASIC_EMAIL!,
password: process.env.AUTH_BASIC_PASSWORD!, password: process.env.AUTH_BASIC_PASSWORD!,
}), },
saml: () => ({ saml: {
tokenLife: defaultTokenLife, driver: 'saml',
callbackUrl: process.env.AUTH_SAML_CALLBACK_URL, name: process.env.AUTH_SAML_NAME!,
entryPoint: process.env.AUTH_SAML_ENTRY_POINT_URL, callbackUrl: `${apiBaseUrl}/auth/login/saml/callback`,
issuer: process.env.AUTH_SAML_ISSUER, entryPoint: process.env.AUTH_SAML_ENTRY_POINT_URL!,
cert: process.env.AUTH_SAML_CERT, issuer: process.env.AUTH_SAML_ISSUER!,
cert: process.env.AUTH_SAML_CERT!,
wantAuthnResponseSigned: process.env.AUTH_SAML_IS_AUTHN_SIGNED === 'true', wantAuthnResponseSigned: process.env.AUTH_SAML_IS_AUTHN_SIGNED === 'true',
}), },
openid: () => ({ openid: {
tokenLife: defaultTokenLife, driver: 'openid',
issuerUrl: process.env.AUTH_OPENID_ISSUER_URL, name: process.env.AUTH_OPENID_NAME!,
clientId: process.env.AUTH_OPENID_CLIENT_ID, issuerUrl: process.env.AUTH_OPENID_ISSUER_URL!,
clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET, clientId: process.env.AUTH_OPENID_CLIENT_ID!,
redirectUri: process.env.AUTH_OPENID_REDIRECT_URI, clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET!,
}), redirectUri: `${apiBaseUrl}/auth/login/openid/callback`,
}), domain: process.env.AUTH_OPENID_DOMAIN!,
},
google: {
driver: 'google',
name: process.env.AUTH_GOOGLE_NAME!,
clientId: process.env.AUTH_OPENID_CLIENT_ID!,
clientSecret: process.env.AUTH_OPENID_CLIENT_SECRET!,
redirectUri: `${apiBaseUrl}/auth/login/google/callback`,
},
multi: {
driver: 'multi',
name: process.env.AUTH_MULTI_NAME!,
},
},
error: driver<ErrorConfig>(process.env.ERROR_DRIVER, { error: driver<ErrorConfig>(process.env.ERROR_DRIVER, {
bugsnag: () => ({ bugsnag: () => ({
apiKey: process.env.ERROR_BUGSNAG_API_KEY, apiKey: process.env.ERROR_BUGSNAG_API_KEY,

View file

@ -73,7 +73,7 @@ export default class Model {
static async first<T extends typeof Model>( static async first<T extends typeof Model>(
this: T, this: T,
query: Query, query: Query = (qb) => qb,
db: Database = App.main.db, db: Database = App.main.db,
): Promise<InstanceType<T> | undefined> { ): Promise<InstanceType<T> | undefined> {
const record = await this.build(query, db).first() const record = await this.build(query, db).first()

View file

@ -1,10 +1,10 @@
import { AuthConfig } from '../auth/Auth' import { AuthProviderConfig } from '../auth/Auth'
import Model from '../core/Model' import Model from '../core/Model'
export default class Organization extends Model { export default class Organization extends Model {
username!: string username!: string
domain!: string domain!: string
auth!: AuthConfig auth!: AuthProviderConfig
static jsonAttributes = ['auth'] static jsonAttributes = ['auth']
} }

View file

@ -1,6 +1,11 @@
import { Admin } from '../auth/Admin'
import { encodeHashid } from '../utilities' import { encodeHashid } from '../utilities'
import Organization from './Organization' import Organization from './Organization'
export const getOrganization = async (id: number) => {
return await Organization.find(id)
}
export const getOrganizationByUsername = async (username: string) => { export const getOrganizationByUsername = async (username: string) => {
return await Organization.first(qb => qb.where('username', username)) return await Organization.first(qb => qb.where('username', username))
} }
@ -10,6 +15,16 @@ export const getOrganizationByDomain = async (domain?: string) => {
return await Organization.first(qb => qb.where('domain', domain)) return await Organization.first(qb => qb.where('domain', domain))
} }
export const getOrganizationByEmail = async (email: string) => {
const admin = await Admin.first(qb => qb.where('email', email))
if (!admin) return undefined
return await getOrganization(admin.organization_id)
}
export const getDefaultOrganization = async () => {
return await Organization.first()
}
export const createOrganization = async (domain: string): Promise<Organization> => { export const createOrganization = async (domain: string): Promise<Organization> => {
const username = domain.split('.').shift() const username = domain.split('.').shift()
const org = await Organization.insertAndFetch({ const org = await Organization.insertAndFetch({

View file

@ -14,7 +14,10 @@ export abstract class TextProvider extends Provider {
const router = new Router<{ provider: Provider }>() const router = new Router<{ provider: Provider }>()
router.post(`/${namespace}/unsubscribe`, async ctx => { router.post(`/${namespace}/unsubscribe`, async ctx => {
const channel = await loadTextChannel(ctx.state.provider.id) const channel = await loadTextChannel(ctx.state.provider.id)
if (!channel) return if (!channel) {
ctx.status = 404
return
}
// Always return with positive status code // Always return with positive status code
ctx.status = 204 ctx.status = 204

View file

@ -8,5 +8,5 @@ export interface Webhook {
export interface WebhookResponse { export interface WebhookResponse {
message: Webhook message: Webhook
success: boolean success: boolean
response: string response: Record<string, any> | string
} }

View file

@ -18,7 +18,7 @@ export default class WebhookChannel {
const endpoint = Render(options.endpoint, variables) const endpoint = Render(options.endpoint, variables)
const method = options.method const method = options.method
await this.provider.send({ return await this.provider.send({
endpoint, endpoint,
method, method,
headers, headers,

View file

@ -1,6 +1,6 @@
import Axios from 'axios' import Axios from 'axios'
import { env } from './config/env' import { env } from './config/env'
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types' import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
function appendValue(params: URLSearchParams, name: string, value: unknown) { function appendValue(params: URLSearchParams, name: string, value: unknown) {
if (typeof value === 'undefined' || value === null || typeof value === 'function') return if (typeof value === 'undefined' || value === null || typeof value === 'function') return
@ -29,7 +29,7 @@ client.interceptors.response.use(
response => response, response => response,
async error => { async error => {
if (error.response.status === 401) { if (error.response.status === 401) {
api.login() api.auth.login()
} }
throw error throw error
}, },
@ -115,17 +115,23 @@ const cache: {
const api = { const api = {
login() { auth: {
window.location.href = `/login?r=${encodeURIComponent(window.location.href)}` methods: async () => await client
}, .get<AuthMethod[]>('/auth/methods')
.then(r => r.data),
async logout() { check: async (method: string, email: string) => await client
window.location.href = env.api.baseURL + '/auth/logout' .post<boolean>('/auth/check', { method, email })
}, .then(r => r.data),
basicAuth: async (email: string, password: string, redirect: string = '/') => {
async basicAuth(email: string, password: string, redirect: string = '/') { await client.post('/auth/login/basic/callback', { email, password })
await client.post('/auth/login/callback', { email, password }) window.location.href = redirect
window.location.href = redirect },
login() {
window.location.href = `/login?r=${encodeURIComponent(window.location.href)}`
},
async logout() {
window.location.href = env.api.baseURL + '/auth/logout'
},
}, },
profile: { profile: {

View file

@ -104,6 +104,11 @@ export interface SearchResult<T> {
export type AuditFields = 'created_at' | 'updated_at' | 'deleted_at' export type AuditFields = 'created_at' | 'updated_at' | 'deleted_at'
export interface AuthMethod {
driver: string
name: string
}
export interface Admin { export interface Admin {
id: number id: number
organization_id: number organization_id: number

View file

@ -28,6 +28,12 @@
height: 20px; height: 20px;
padding: 2px 0; padding: 2px 0;
display: inline-block; display: inline-block;
flex-shrink: 0;
}
.ui-button .button-text {
display: inline-block;
flex-grow: 1;
} }
.ui-button.ui-button-no-children .button-icon { .ui-button.ui-button-no-children .button-icon {

View file

@ -69,7 +69,7 @@ const Button = forwardRef(function Button(props: ButtonProps, ref: Ref<HTMLButto
style={style} style={style}
> >
{icon && (<span className="button-icon" aria-hidden="true">{icon}</span>)} {icon && (<span className="button-icon" aria-hidden="true">{icon}</span>)}
{children} <span className="button-text">{children}</span>
</button> </button>
) )
}) })

View file

@ -69,6 +69,8 @@ export default function FormWrapper<T extends FieldValues>({
return submitError.error return submitError.error
} else if (submitError.response?.data?.error) { } else if (submitError.response?.data?.error) {
return submitError.response?.data?.error return submitError.response?.data?.error
} else if (submitError.message) {
return submitError.message
} }
return defaultError return defaultError
} }

View file

@ -22,7 +22,13 @@
padding: 40px; padding: 40px;
} }
.auth-step h1 { .auth.login .auth-step {
min-width: 300px;
width: 100%;
max-width: 350px;
}
.auth-step h1, .auth-step h2 {
margin: 0; margin: 0;
} }
@ -30,6 +36,21 @@
padding: 10px 0; padding: 10px 0;
} }
.auth-methods {
display: flex;
flex-direction: column;
gap: 10px;
}
.auth.login .ui-button,
.auth.login .form-submit {
width: 100%;
}
.auth.login form {
margin-bottom: 10px;
}
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.auth { .auth {
padding: 40px 20px; padding: 40px 20px;

View file

@ -3,24 +3,101 @@ import { ReactComponent as Logo } from '../../assets/logo.svg'
import { env } from '../../config/env' import { env } from '../../config/env'
import Button from '../../ui/Button' import Button from '../../ui/Button'
import './Auth.css' import './Auth.css'
import { useEffect, useState } from 'react'
import api from '../../api'
import { AuthMethod } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import TextInput from '../../ui/form/TextInput'
interface LoginParams {
email: string
password?: string
}
export default function Login() { export default function Login() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [methods, setMethods] = useState<AuthMethod[]>()
const [method, setMethod] = useState<AuthMethod>()
const handleRedirect = () => { const handleRedirect = (driver: string, email?: string) => {
window.location.href = `${env.api.baseURL}/auth/login?r=${searchParams.get('r') ?? '/'}` window.location.href = `${env.api.baseURL}/auth/login/${driver}?r=${searchParams.get('r') ?? '/'}&email=${email}`
} }
const handleMethod = (method: AuthMethod) => {
if (method.driver === 'multi' || method.driver === 'basic') {
setMethod(method)
} else {
handleRedirect(method.driver)
}
}
const handleLogin = (method: string) => {
return async ({ email, password }: LoginParams) => {
if (password && method === 'basic') {
await api.auth.basicAuth(email, password, searchParams.get('r') ?? '/')
} else {
await checkEmail(method, email)
handleRedirect(method, email)
}
}
}
const checkEmail = async (method: string, email: string) => {
const isAllowed = await api.auth.check(method, email)
if (!isAllowed) throw new Error('This login method is not available for this email address or an account with this email address does not exist.')
return isAllowed
}
useEffect(() => {
api.auth.methods().then((methods) => {
setMethods(methods)
}).catch(() => {})
}, [])
return ( return (
<div className="auth login"> <div className="auth login">
<div className="logo"> <div className="logo">
<Logo /> <Logo />
</div> </div>
<div className="auth-step"> {!method && (
<h1>Welcome!</h1> <div className="auth-step">
<p>Please use the button below to authenticate.</p> <h2>Welcome!</h2>
<Button onClick={handleRedirect}>Login</Button> <p>Select an authentication method below to continue.</p>
</div> <div className="auth-methods">
{methods?.map((method) => (
<Button key={method.driver} onClick={() => handleMethod(method)}>{method.name}</Button>
))}
</div>
</div>
)}
{method && method.driver === 'basic' && (
<div className="auth-step">
<h2>Login</h2>
<p>Enter your email and password to continue.</p>
<FormWrapper<LoginParams>
onSubmit={handleLogin(method.driver)}>
{form => <>
<TextInput.Field form={form} name="email" />
<TextInput.Field form={form} name="password" type="password" />
</>}
</FormWrapper>
<Button variant="plain" onClick={() => setMethod(undefined)}>Back</Button>
</div>
)}
{method && method.driver !== 'basic' && (
<div className="auth-step">
<h2>Auth</h2>
<p>What is your email address?</p>
<FormWrapper<LoginParams>
onSubmit={handleLogin(method.driver)}
submitLabel={method.name}>
{form => <>
<TextInput.Field form={form} name="email" />
</>}
</FormWrapper>
<Button variant="secondary" onClick={() => setMethod(undefined)}>Back</Button>
</div>
)}
</div> </div>
) )
} }

View file

@ -1,37 +0,0 @@
import { useSearchParams } from 'react-router-dom'
import api from '../../api'
import { ReactComponent as Logo } from '../../assets/logo.svg'
import FormWrapper from '../../ui/form/FormWrapper'
import TextInput from '../../ui/form/TextInput'
import './Auth.css'
interface LoginBasicParams {
email: string
password: string
}
export default function Login() {
const [searchParams] = useSearchParams()
const handleLogin = async ({ email, password }: LoginBasicParams) => {
await api.basicAuth(email, password, searchParams.get('r') ?? '/')
}
return (
<div className="auth login">
<div className="logo">
<Logo />
</div>
<div className="auth-step">
<h1>Login</h1>
<FormWrapper<LoginBasicParams>
onSubmit={handleLogin}>
{form => <>
<TextInput.Field form={form} name="email" />
<TextInput.Field form={form} name="password" type="password" />
</>}
</FormWrapper>
</div>
</div>
)
}

View file

@ -93,7 +93,7 @@ export default function ProjectSidebar({ children, links }: PropsWithChildren<Si
}> }>
<MenuItem onClick={() => navigate('/settings')}>Settings</MenuItem> <MenuItem onClick={() => navigate('/settings')}>Settings</MenuItem>
<MenuItem onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>Use {preferences.mode === 'dark' ? 'Light' : 'Dark'} Theme</MenuItem> <MenuItem onClick={() => setPreferences({ ...preferences, mode: preferences.mode === 'dark' ? 'light' : 'dark' })}>Use {preferences.mode === 'dark' ? 'Light' : 'Dark'} Theme</MenuItem>
<MenuItem onClick={async () => await api.logout()}>Sign Out</MenuItem> <MenuItem onClick={async () => await api.auth.logout()}>Sign Out</MenuItem>
</Menu> </Menu>
</div> </div>
) )

View file

@ -36,7 +36,6 @@ import OnboardingProject from './auth/OnboardingProject'
import { CampaignsIcon, JourneysIcon, ListsIcon, PerformanceIcon, ProjectIcon, SettingsIcon, UsersIcon } from '../ui/icons' import { CampaignsIcon, JourneysIcon, ListsIcon, PerformanceIcon, ProjectIcon, SettingsIcon, UsersIcon } from '../ui/icons'
import { Projects } from './project/Projects' import { Projects } from './project/Projects'
import { pushRecentProject } from '../utils' import { pushRecentProject } from '../utils'
import LoginBasic from './auth/LoginBasic'
import Performance from './organization/Performance' import Performance from './organization/Performance'
import Settings from './settings/Settings' import Settings from './settings/Settings'
import ProjectSidebar from './project/ProjectSidebar' import ProjectSidebar from './project/ProjectSidebar'
@ -60,10 +59,6 @@ export const router = createBrowserRouter([
path: '/login', path: '/login',
element: <Login />, element: <Login />,
}, },
{
path: '/login/basic',
element: <LoginBasic />,
},
{ {
path: '*', path: '*',
errorElement: <ErrorPage />, errorElement: <ErrorPage />,

View file

@ -38,21 +38,28 @@ Find below a list of all environment variables that can be set at launch to conf
| AWS_SECRET_ACCESS_KEY | string | If driver is S3 | | AWS_SECRET_ACCESS_KEY | string | If driver is S3 |
### Auth ### Auth
| key | type | required | | key | type | required | notes
|--|--|--| |--|--|--|
| AUTH_DRIVER | 'basic', 'openid' or 'saml' | true | | AUTH_DRIVER | 'basic', 'google', 'openid', 'saml' | true | Can be multiple
| AUTH_BASIC_EMAIL | string | If driver is Basic | | AUTH_BASIC_EMAIL | string | If driver is Basic |
| AUTH_BASIC_PASSWORD | string | If driver is Basic | | AUTH_BASIC_PASSWORD | string | If driver is Basic |
| AUTH_BASIC_NAME | string | false |
| AUTH_SAML_CALLBACK_URL | string | If driver is SAML | | AUTH_SAML_CALLBACK_URL | string | If driver is SAML |
| AUTH_SAML_ENTRY_POINT_URL | string | If driver is SAML | | AUTH_SAML_ENTRY_POINT_URL | string | If driver is SAML |
| AUTH_SAML_ISSUER | string | If driver is SAML | | AUTH_SAML_ISSUER | string | If driver is SAML |
| AUTH_SAML_CERT | string | If driver is SAML | | AUTH_SAML_CERT | string | If driver is SAML |
| AUTH_SAML_IS_AUTHN_SIGNED | boolean | If driver is SAML | | AUTH_SAML_IS_AUTHN_SIGNED | boolean | If driver is SAML |
| AUTH_SAML_NAME | string | false |
| AUTH_OPENID_ISSUER_URL | string | If driver is OpenID | | AUTH_OPENID_ISSUER_URL | string | If driver is OpenID |
| AUTH_OPENID_CLIENT_ID | string | If driver is OpenID | | AUTH_OPENID_CLIENT_ID | string | If driver is OpenID |
| AUTH_OPENID_CLIENT_SECRET | string | If driver is OpenID | | AUTH_OPENID_CLIENT_SECRET | string | If driver is OpenID |
| AUTH_OPENID_REDIRECT_URI | string | If driver is OpenID | | AUTH_OPENID_REDIRECT_URI | string | If driver is OpenID |
| AUTH_OPENID_DOMAIN_WHITELIST | string | If driver is OpenID | | AUTH_OPENID_DOMAIN_WHITELIST | string | If driver is OpenID |
| AUTH_OPENID_NAME | string | false |
| AUTH_GOOGLE_ISSUER_URL | string | If driver is Google |
| AUTH_GOOGLE_CLIENT_ID | string | If driver is Google |
| AUTH_GOOGLE_CLIENT_SECRET | string | If driver is Google |
| AUTH_GOOGLE_NAME | string | false |
### Tracking ### Tracking
| key | type | required | | key | type | required |