Skip to content

Commit

Permalink
feat: add okta sso (#2792)
Browse files Browse the repository at this point in the history
Closes #2283 
Closes #2757
  • Loading branch information
owlas committed Aug 3, 2022
1 parent 60d42fb commit ef8877e
Show file tree
Hide file tree
Showing 24 changed files with 505 additions and 142 deletions.
14 changes: 14 additions & 0 deletions docs/docs/references/environmentVariables.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,17 @@ This is a reference to all the SMTP environment variables that can be used to co
|`EMAIL_SMTP_SENDER_NAME` | The name of the email address that sends emails | | Lightdash |

[1] `EMAIL_SMTP_PASSWORD` or `EMAIL_SMTP_ACCESS_TOKEN` needs to be provided

# SSO environment variables

These variables enable you to control Single Sign On (SSO) functionality.

| Variable | Description | Required? | Default |
|----------------------------------------|------------------------------------------------------|-----------|------|
| `AUTH_DISABLE_PASSWORD_AUTHENTICATION` | If `"true"` disables signing in with plain passwords | | false |
| `AUTH_GOOGLE_OAUTH2_CLIENT_ID` | Required for Google SSO | | |
| `AUTH_GOOGLE_OAUTH2_CLIENT_SECRET` | Required for Google SSO | | |
| `AUTH_OKTA_OAUTH2_CLIENT_ID` | Required for Okta SSO | | |
| `AUTH_OKTA_OAUTH2_CLIENT_SECRET` | Required for Okta SSO | | |
| `AUTH_OKTA_OAUTH2_ISSUER` | Required for Okta SSO | | |
| `AUTH_OKTA_DOMAIN` | Required for Okta SSO | | |
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"passport-google-oidc": "^0.1.0",
"passport-headerapikey": "^1.2.2",
"passport-local": "^1.0.0",
"passport-openidconnect": "^0.1.1",
"passport-strategy": "^1.0.0",
"pg": "^8.7.1",
"pg-connection-string": "^2.5.0",
Expand Down
57 changes: 57 additions & 0 deletions packages/backend/src/@types/passport-openidconnect.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
declare module 'passport-openidconnect' {
import { Strategy as PassportStrategy } from 'passport-strategy';
import express = require('express');

interface IStrategyOptions {
issuer: string;
authorizationURL: string;
tokenURL: string;
userInfoURL: string;
clientID: string;
clientSecret: string;
callbackURL: string;
scope?: string;
passReqToCallback?: false | undefined;
}

interface IStrategyOptionsWithRequest {
issuer: string;
authorizationURL: string;
tokenURL: string;
clientID: string;
userInfoURL: string;
clientSecret: string;
callbackURL: string;
scope?: string;
passReqToCallback: true;
}

interface IVerifyOptions {
message: string;
}

interface VerifyFunctionWithRequest {
(
req: express.Request,
issuer: string,
profile: any,
done: (error: any, user?: any, options?: IVerifyOptions) => void,
): void;
}

interface VerifyFunction {
(
issuer: string,
profile: any,
done: (error: any, user?: any, options?: IVerifyOptions) => void,
): void;
}
export declare class Strategy extends PassportStrategy {
constructor(
options: IStrategyOptionsWithRequest,
verify: VerifyFunctionWithRequest,
);
constructor(options: IStrategyOptions, verify: VerifyFunction);
constructor(verify: VerifyFunction);
}
}
18 changes: 18 additions & 0 deletions packages/backend/src/config/parseConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,19 @@ export type AuthGoogleConfig = {
callbackPath: string;
};

type AuthOktaConfig = {
oauth2Issuer: string | undefined;
oauth2ClientId: string | undefined;
oauth2ClientSecret: string | undefined;
oktaDomain: string | undefined;
callbackPath: string;
loginPath: string;
};

export type AuthConfig = {
disablePasswordAuthentication: boolean;
google: AuthGoogleConfig;
okta: AuthOktaConfig;
};

export type SmtpConfig = {
Expand Down Expand Up @@ -181,6 +191,14 @@ const mergeWithEnvironment = (config: LightdashConfigIn): LightdashConfig => {
loginPath: '/login/google',
callbackPath: '/oauth/redirect/google',
},
okta: {
oauth2Issuer: process.env.AUTH_OKTA_OAUTH_ISSUER,
oauth2ClientId: process.env.AUTH_OKTA_OAUTH_CLIENT_ID,
oauth2ClientSecret: process.env.AUTH_OKTA_OAUTH_CLIENT_SECRET,
oktaDomain: process.env.AUTH_OKTA_DOMAIN,
callbackPath: '/oauth/redirect/okta',
loginPath: '/login/okta',
},
},
intercom: {
appId: process.env.INTERCOM_APP_ID || 'zppxyjpp',
Expand Down
100 changes: 92 additions & 8 deletions packages/backend/src/controllers/authentication.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
/// <reference path="../@types/passport-google-oidc.d.ts" />
/// <reference path="../@types/passport-openidconnect.d.ts" />
/// <reference path="../@types/express-session.d.ts" />
import {
AuthorizationError,
isOpenIdUser,
isSessionUser,
LightdashError,
LightdashMode,
OpenIdUser,
SessionUser,
Expand All @@ -13,6 +15,7 @@ import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oidc';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
import { Strategy as LocalStrategy } from 'passport-local';
import { Strategy as OpenIDConnectStrategy } from 'passport-openidconnect';
import { URL } from 'url';
import { lightdashConfig } from '../config/lightdashConfig';
import Logger from '../logger';
Expand Down Expand Up @@ -67,9 +70,13 @@ export const localPassportStrategy = new LocalStrategy(
try {
const user = await userService.loginWithPassword(email, password);
return done(null, user);
} catch {
} catch (e) {
return done(
new AuthorizationError('Email and password not recognized.'),
e instanceof LightdashError
? e
: new AuthorizationError(
'Unexpected error while logging in',
),
);
}
},
Expand All @@ -91,7 +98,7 @@ export const apiKeyPassportStrategy = new HeaderAPIKeyStrategy(
},
);

export const getGoogleLogin: RequestHandler = (req, res, next) => {
export const storeOIDCRedirect: RequestHandler = (req, res, next) => {
const { redirect, inviteCode } = req.query;
req.session.oauth = {};

Expand All @@ -114,12 +121,12 @@ export const getGoogleLogin: RequestHandler = (req, res, next) => {
}
next();
};
export const getGoogleLoginSuccess: RequestHandler = (req, res) => {
export const redirectOIDCSuccess: RequestHandler = (req, res) => {
const { returnTo } = req.session.oauth || {};
req.session.oauth = {};
res.redirect(returnTo || '/');
};
export const getGoogleLoginFailure: RequestHandler = (req, res) => {
export const redirectOIDCFailure: RequestHandler = (req, res) => {
const { returnTo } = req.session.oauth || {};
req.session.oauth = {};
res.redirect(returnTo || '/');
Expand Down Expand Up @@ -150,13 +157,87 @@ export const googlePassportStrategy: GoogleStrategy | undefined = !(
message: 'Could not parse authentication token',
});
}
// TODO why we ned this
const normalisedIssuer = new URL('/', issuer).origin; // normalise issuer
const openIdUser: OpenIdUser = {
openId: {
issuer,
issuer: normalisedIssuer,
email,
subject,
firstName: profile.name?.givenName,
lastName: profile.name?.familyName,
issuerType: 'google',
},
};
const user = await userService.loginWithOpenId(
openIdUser,
req.user,
inviteCode,
);
return done(null, user);
} catch (e) {
if (e instanceof LightdashError) {
return done(null, false, { message: e.message });
}
Logger.warn(`Unexpected error while authorizing user: ${e}`);
return done(null, false, {
message: 'Unexpected error authorizing user',
});
}
},
);
export const oktaPassportStrategy = !(
lightdashConfig.auth.okta.oauth2ClientId &&
lightdashConfig.auth.okta.oauth2ClientSecret &&
lightdashConfig.auth.okta.oauth2Issuer &&
lightdashConfig.auth.okta.oktaDomain
)
? undefined
: new OpenIDConnectStrategy(
{
clientID: lightdashConfig.auth.okta.oauth2ClientId,
clientSecret: lightdashConfig.auth.okta.oauth2ClientSecret,
issuer: lightdashConfig.auth.okta.oauth2Issuer,
callbackURL: new URL(
`/api/v1${lightdashConfig.auth.okta.callbackPath}`,
lightdashConfig.siteUrl,
).href,
authorizationURL: new URL(
'/oauth2/default/v1/authorize',
`https://${lightdashConfig.auth.okta.oktaDomain}`,
).href,
tokenURL: new URL(
'/oauth2/default/v1/token',
`https://${lightdashConfig.auth.okta.oktaDomain}`,
).href,
userInfoURL: new URL(
'/oauth2/default/v1/userinfo',
`https://${lightdashConfig.auth.okta.oktaDomain}`,
).href,
passReqToCallback: true,
},
async (req, issuer, profile, done) => {
try {
const { inviteCode } = req.session.oauth || {};
req.session.oauth = {};
const [{ value: email }] = profile.emails;
const { id: subject } = profile;
if (!(email && subject)) {
return done(null, false, {
message: 'Could not parse authentication token',
});
}
const [firstName, lastName] = (
profile.displayName || ''
).split();
const openIdUser: OpenIdUser = {
openId: {
issuer,
email,
subject,
firstName,
lastName,
issuerType: 'okta',
},
};
const user = await userService.loginWithOpenId(
Expand All @@ -166,9 +247,12 @@ export const googlePassportStrategy: GoogleStrategy | undefined = !(
);
return done(null, user);
} catch (e) {
Logger.warn(`Failed to authorize user. ${e}`);
if (e instanceof LightdashError) {
return done(null, false, { message: e.message });
}
Logger.warn(`Unexpected error while authorizing user: ${e}`);
return done(null, false, {
message: 'Failed to authorize user',
message: 'Unexpected error authorizing user',
});
}
},
Expand Down
7 changes: 1 addition & 6 deletions packages/backend/src/database/entities/openIdIdentities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Knex } from 'knex';

export type DbOpenIdIdentity = {
issuer: string;
issuer_type: 'google' | 'okta';
subject: string;
user_id: number;
created_at: Date;
Expand All @@ -14,9 +15,3 @@ export type OpenIdIdentitiesTable = Knex.CompositeTableType<
DbOpenIdIdentity,
Omit<DbOpenIdIdentity, 'created_at'>
>;

export const OpenIdIssuersTableName = 'openid_issuers';
export type DbOpenIdIssuer = {
issuer: 'https://accounts.google.com';
};
export type OpenIdIssuersTable = Knex.CompositeTableType<DbOpenIdIssuer>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable('openid_identities')) {
await knex.schema.alterTable('openid_identities', (tableBuilder) => {
tableBuilder.dropForeign('issuer');
tableBuilder
.string('issuer_type')
.notNullable()
.defaultTo('google');
});
await knex.schema.alterTable('openid_identities', (tableBuilder) => {
tableBuilder.string('issuer_type').notNullable().alter(); // drop default
});
}
await knex.schema.dropTableIfExists('openid_issuers');
}

export async function down(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable('openid_issuers'))) {
await knex.schema.createTable('openid_issuers', (tableBuilder) => {
tableBuilder.text('issuer').primary();
});
await knex('openid_issuers').insert({
issuer: 'https://accounts.google.com',
});
}
if (await knex.schema.hasTable('openid_identities')) {
await knex('openid_identities')
.delete()
.whereNot('issuer', 'https://accounts.google.com');
await knex.schema.alterTable('openid_identities', (tableBuilder) => {
tableBuilder.dropPrimary();
tableBuilder.dropColumn('issuer_type');
});
await knex.schema.alterTable('openid_identities', (tableBuilder) => {
tableBuilder
.text('issuer')
.notNullable()
.references('issuer')
.inTable('openid_issuers')
.alter();
tableBuilder.primary(['issuer', 'subject']);
});
}
}
4 changes: 4 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
apiKeyPassportStrategy,
googlePassportStrategy,
localPassportStrategy,
oktaPassportStrategy,
} from './controllers/authentication';
import database from './database/database';
import { errorHandler } from './errors';
Expand Down Expand Up @@ -230,6 +231,9 @@ passport.use(localPassportStrategy);
if (googlePassportStrategy) {
passport.use(googlePassportStrategy);
}
if (oktaPassportStrategy) {
passport.use('okta', oktaPassportStrategy);
}
passport.serializeUser((user, done) => {
// On login (user changes), user.userUuid is written to the session store in the `sess.passport.data` field
done(null, user.userUuid);
Expand Down

0 comments on commit ef8877e

Please sign in to comment.