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_BASIC_EMAIL=test@parcelvoy.com
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(serve('./public', {
hidden: true,
defer: !app.env.mono,
defer: !app.env.config.monoDocker,
}))
this.registerControllers()

View file

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

View file

@ -1,56 +1,96 @@
import { Context } from 'koa'
import AuthProvider from './AuthProvider'
import OpenIDProvider, { OpenIDConfig } from './OpenIDAuthProvider'
import GoogleProvider, { GoogleConfig } from './GoogleAuthProvider'
import SAMLProvider, { SAMLConfig } from './SAMLAuthProvider'
import { DriverConfig } from '../config/env'
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 AuthTypeConfig extends DriverConfig {
export interface AuthConfig {
driver: AuthProviderName[]
tokenLife: number
driver: AuthProviderName
basic: BasicAuthConfig
saml: SAMLConfig
openid: OpenIDConfig
google: GoogleConfig
multi: MultiAuthConfig
}
export default class Auth {
provider: AuthProvider
export { BasicAuthConfig, SAMLConfig, OpenIDConfig }
constructor(config?: AuthConfig) {
this.provider = Auth.provider(config)
}
export interface AuthTypeConfig extends DriverConfig {
driver: AuthProviderName
name?: string
}
static provider(config?: AuthConfig): AuthProvider {
interface AuthMethod {
driver: AuthProviderName
name: string
}
export const initProvider = (config?: AuthProviderConfig): AuthProvider => {
if (config?.driver === 'basic') {
return new BasicAuthProvider(config)
} else if (config?.driver === 'saml') {
return new SAMLProvider(config)
} else if (config?.driver === 'openid') {
return new OpenIDProvider(config)
} else if (config?.driver === 'google') {
return new GoogleProvider(config)
} else if (config?.driver === 'multi') {
return new MultiAuthProvider()
} else {
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 App from '../app'
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',
})
router.get('/login', async ctx => {
await App.main.auth.start(ctx)
router.use(async (ctx: Context, next: () => void) => {
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 => {
await App.main.auth.start(ctx)
router.get('/methods', async 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
await App.main.auth.validate(ctx)
await startAuth(ctx)
})
router.get('/login/callback', async ctx => {
router.post('/login/:driver', async ctx => {
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 => {

View file

@ -7,7 +7,7 @@ import { createOrUpdateAdmin } from './AdminRepository'
import { generateAccessToken, OAuthResponse, setTokenCookies } from './TokenRepository'
import Organization from '../organizations/Organization'
import { State } from './AuthMiddleware'
import { createOrganization, getOrganizationByDomain } from '../organizations/OrganizationService'
import { createOrganization, getDefaultOrganization, getOrganizationByDomain } from '../organizations/OrganizationService'
type OrgState = State & { organization?: Organization }
export type AuthContext = Context & { state: OrgState }
@ -18,11 +18,22 @@ export default abstract class AuthProvider {
abstract validate(ctx: AuthContext): Promise<void>
async loadAuthOrganization(ctx: AuthContext, domain: string) {
const organization = ctx.state.organization ?? await getOrganizationByDomain(domain)
if (!organization) {
return await createOrganization(domain)
// If we have an organization or can find one by 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> {

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'
issuerUrl: string // 'https://accounts.google.com'
clientId: string
cliendSecret: string
clientSecret: string
redirectUri: string
domain: string
domain?: string
}
export default class OpenIDAuthProvider extends AuthProvider {
@ -32,9 +32,6 @@ export default class OpenIDAuthProvider extends AuthProvider {
const client = await this.getClient()
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, {
secure: ctx.request.secure,
httpOnly: true,
@ -49,6 +46,15 @@ export default class OpenIDAuthProvider extends AuthProvider {
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({
scope: 'openid email profile',
response_mode: 'form_post',
@ -95,6 +101,10 @@ export default class OpenIDAuthProvider extends AuthProvider {
}
await this.login(admin, ctx, state)
ctx.cookies.set('nonce', null)
ctx.cookies.set('relaystate', null)
ctx.cookies.set('organization', null)
} catch (error) {
logger.warn(error)
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?
this.client = new issuer.Client({
client_id: this.config.clientId,
client_secret: this.config.cliendSecret,
client_secret: this.config.clientSecret,
redirect_uris: [this.config.redirectUri],
response_types: ['id_token'],
})

View file

@ -7,6 +7,7 @@ import { AuthTypeConfig } from './Auth'
import AuthProvider from './AuthProvider'
import AuthError from './AuthError'
import { firstQueryParam } from '../utilities'
import { addSeconds } from 'date-fns'
export interface SAMLConfig extends AuthTypeConfig {
driver: 'saml'
@ -65,6 +66,15 @@ export default class SAMLAuthProvider extends AuthProvider {
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)
}
@ -88,6 +98,8 @@ export default class SAMLAuthProvider extends AuthProvider {
const { id } = await this.loadAuthOrganization(ctx, domain)
await this.login({ first_name, last_name, email, organization_id: id }, ctx, state)
ctx.cookies.set('organization', null)
}
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 (app.env.mono) {
if (app.env.config.monoDocker) {
const ui = new Router()
ui.get('/(.*)', async (ctx, next) => {
try {

View file

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

View file

@ -73,7 +73,7 @@ export default class Model {
static async first<T extends typeof Model>(
this: T,
query: Query,
query: Query = (qb) => qb,
db: Database = App.main.db,
): Promise<InstanceType<T> | undefined> {
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'
export default class Organization extends Model {
username!: string
domain!: string
auth!: AuthConfig
auth!: AuthProviderConfig
static jsonAttributes = ['auth']
}

View file

@ -1,6 +1,11 @@
import { Admin } from '../auth/Admin'
import { encodeHashid } from '../utilities'
import Organization from './Organization'
export const getOrganization = async (id: number) => {
return await Organization.find(id)
}
export const getOrganizationByUsername = async (username: string) => {
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))
}
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> => {
const username = domain.split('.').shift()
const org = await Organization.insertAndFetch({

View file

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

View file

@ -8,5 +8,5 @@ export interface Webhook {
export interface WebhookResponse {
message: Webhook
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 method = options.method
await this.provider.send({
return await this.provider.send({
endpoint,
method,
headers,

View file

@ -1,6 +1,6 @@
import Axios from 'axios'
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) {
if (typeof value === 'undefined' || value === null || typeof value === 'function') return
@ -29,7 +29,7 @@ client.interceptors.response.use(
response => response,
async error => {
if (error.response.status === 401) {
api.login()
api.auth.login()
}
throw error
},
@ -115,17 +115,23 @@ const cache: {
const api = {
auth: {
methods: async () => await client
.get<AuthMethod[]>('/auth/methods')
.then(r => r.data),
check: async (method: string, email: string) => await client
.post<boolean>('/auth/check', { method, email })
.then(r => r.data),
basicAuth: async (email: string, password: string, redirect: string = '/') => {
await client.post('/auth/login/basic/callback', { email, password })
window.location.href = redirect
},
login() {
window.location.href = `/login?r=${encodeURIComponent(window.location.href)}`
},
async logout() {
window.location.href = env.api.baseURL + '/auth/logout'
},
async basicAuth(email: string, password: string, redirect: string = '/') {
await client.post('/auth/login/callback', { email, password })
window.location.href = redirect
},
profile: {

View file

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

View file

@ -28,6 +28,12 @@
height: 20px;
padding: 2px 0;
display: inline-block;
flex-shrink: 0;
}
.ui-button .button-text {
display: inline-block;
flex-grow: 1;
}
.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}
>
{icon && (<span className="button-icon" aria-hidden="true">{icon}</span>)}
{children}
<span className="button-text">{children}</span>
</button>
)
})

View file

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

View file

@ -22,7 +22,13 @@
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;
}
@ -30,6 +36,21 @@
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) {
.auth {
padding: 40px 20px;

View file

@ -3,24 +3,101 @@ import { ReactComponent as Logo } from '../../assets/logo.svg'
import { env } from '../../config/env'
import Button from '../../ui/Button'
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() {
const [searchParams] = useSearchParams()
const [methods, setMethods] = useState<AuthMethod[]>()
const [method, setMethod] = useState<AuthMethod>()
const handleRedirect = () => {
window.location.href = `${env.api.baseURL}/auth/login?r=${searchParams.get('r') ?? '/'}`
const handleRedirect = (driver: string, email?: string) => {
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 (
<div className="auth login">
<div className="logo">
<Logo />
</div>
{!method && (
<div className="auth-step">
<h1>Welcome!</h1>
<p>Please use the button below to authenticate.</p>
<Button onClick={handleRedirect}>Login</Button>
<h2>Welcome!</h2>
<p>Select an authentication method below to continue.</p>
<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>
)
}

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={() => 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>
</div>
)

View file

@ -36,7 +36,6 @@ import OnboardingProject from './auth/OnboardingProject'
import { CampaignsIcon, JourneysIcon, ListsIcon, PerformanceIcon, ProjectIcon, SettingsIcon, UsersIcon } from '../ui/icons'
import { Projects } from './project/Projects'
import { pushRecentProject } from '../utils'
import LoginBasic from './auth/LoginBasic'
import Performance from './organization/Performance'
import Settings from './settings/Settings'
import ProjectSidebar from './project/ProjectSidebar'
@ -60,10 +59,6 @@ export const router = createBrowserRouter([
path: '/login',
element: <Login />,
},
{
path: '/login/basic',
element: <LoginBasic />,
},
{
path: '*',
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 |
### 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_PASSWORD | string | If driver is Basic |
| AUTH_BASIC_NAME | string | false |
| AUTH_SAML_CALLBACK_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_CERT | string | 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_CLIENT_ID | 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_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
| key | type | required |