Merge pull request #435 from directus/sso

Fix SSO flow
This commit is contained in:
Rijk van Zanten
2020-09-28 12:24:28 -04:00
committed by GitHub
8 changed files with 177 additions and 109 deletions

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ node_modules
npm-debug.log
lerna-debug.log
.nova
*.code-workspace

View File

@@ -3,13 +3,14 @@ import session from 'express-session';
import asyncHandler from 'express-async-handler';
import Joi from 'joi';
import grant from 'grant';
import getGrantConfig from '../utils/get-grant-config';
import getEmailFromProfile from '../utils/get-email-from-profile';
import { InvalidPayloadException } from '../exceptions/invalid-payload';
import ms from 'ms';
import cookieParser from 'cookie-parser';
import env from '../env';
import { UsersService, AuthenticationService } from '../services';
import grantConfig from '../grant';
import { RouteNotFoundException } from '../exceptions';
const router = Router();
@@ -203,19 +204,41 @@ router.post(
})
);
router.get('/oauth', asyncHandler(async (req, res, next) => {
const providers = env.OAUTH_PROVIDERS.split(',');
res.locals.payload = { data: providers };
return next();
}));
router.use(
'/sso',
'/oauth',
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
);
router.use(grant.express()(getGrantConfig()));
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;
}
next();
}));
router.use(grant.express()(grantConfig));
/**
* @todo allow json / cookie mode in SSO
*/
router.get(
'/sso/:provider/callback',
'/oauth/:provider/callback',
asyncHandler(async (req, res, next) => {
const redirect = req.session?.redirect;
const accountability = {
ip: req.ip,
userAgent: req.get('user-agent'),
@@ -226,17 +249,29 @@ router.get(
accountability: accountability,
});
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response.profile);
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response?.profile);
const { accessToken, refreshToken, expires } = await authenticationService.authenticate(
email
);
req.session?.destroy(() => { });
res.locals.payload = {
data: { access_token: accessToken, refresh_token: refreshToken, expires },
};
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ email });
return next();
if (redirect) {
res.cookie('directus_refresh_token', refreshToken, {
httpOnly: true,
maxAge: ms(env.REFRESH_TOKEN_TTL as string),
secure: env.REFRESH_TOKEN_COOKIE_SECURE === 'true' ? true : 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();
}
})
);

39
api/src/grant.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Grant is the oAuth library
*/
import env from './env';
const enabledProviders = (env.OAUTH_PROVIDERS as string)
.split(',')
.map((provider) => provider.trim().toLowerCase());
const config: any = {
defaults: {
origin: env.PUBLIC_URL,
transport: 'session',
prefix: '/auth/oauth',
response: ['tokens', 'profile'],
},
};
for (const [key, value] of Object.entries(env)) {
if (key.startsWith('OAUTH') === false) continue;
const parts = key.split('_');
const provider = parts[1].toLowerCase();
if (enabledProviders.includes(provider) === false) continue;
// OAUTH <PROVIDER> SETTING = VALUE
parts.splice(0, 2);
const configKey = parts.join('_').toLowerCase();
config[provider] = {
...(config[provider] || {}),
[configKey]: value,
};
}
export default config;

View File

@@ -1,5 +1,6 @@
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
@@ -17,10 +18,11 @@ const profileMap: Record<string, string> = {};
export default function getEmailFromProfile(provider: string, profile: Record<string, any>) {
const path =
profileMap[provider] || env[`OAUTH_${provider.toUpperCase()}_PROFILE_EMAIL`] || 'email';
const email = get(profile, path);
if (!email) {
throw new Error("Couldn't extract email address from SSO provider response");
throw new ServiceUnavailableException("Couldn't extract email address from SSO provider response", { service: 'oauth', provider });
}
return email;

View File

@@ -1,40 +0,0 @@
import env from '../env';
/**
* Reads the environment variables to construct the configuration object required by Grant
*/
export default function getGrantConfig() {
const enabledProviders = (env.OAUTH_PROVIDERS as string)
.split(',')
.map((provider) => provider.trim());
const config: any = {
defaults: {
origin: env.PUBLIC_URL,
transport: 'session',
prefix: '/auth/sso',
response: ['tokens', 'profile'],
},
};
for (const [key, value] of Object.entries(env)) {
if (key.startsWith('OAUTH') === false) continue;
const parts = key.split('_');
const provider = parts[1].toLowerCase();
if (enabledProviders.includes(provider) === false) continue;
// OAUTH <PROVIDER> SETTING = VALUE
parts.splice(0, 2);
const configKey = parts.join('_').toLowerCase();
config[provider] = {
...(config[provider] || {}),
[configKey]: value,
};
}
return config;
}

View File

@@ -690,6 +690,8 @@
"empty_item": "Empty Item",
"log_in_with": "Log In with {provider}",
"filter": "Filter",
"advanced_filter": "Advanced Filter",
"operators": {

View File

@@ -17,32 +17,14 @@
</router-link>
</div>
<!-- <template v-if="ssoProviders">
<v-divider class="sso-divider" />
<v-button
secondary
class="sso-button"
rounded
icon
v-for="provider in ssoProviders"
:key="provider.name"
:href="provider.link"
>
<img :src="provider.icon" :alt="provider.name" />
</v-button>
<v-notice class="sso-notice" type="danger" v-if="ssoError">
{{ translateAPIError(ssoError) }}
</v-notice>
</template> -->
<sso-links />
</form>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch } from '@vue/composition-api';
import router from '@/router';
//
import ssoLinks from '../sso-links.vue';
import { login } from '@/auth';
import { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
@@ -55,12 +37,7 @@ type Credentials = {
};
export default defineComponent({
props: {
ssoError: {
type: String,
default: null,
},
},
components: { ssoLinks },
setup() {
const loggingIn = ref(false);
const email = ref<string | null>(null);
@@ -81,19 +58,7 @@ export default defineComponent({
return null;
});
/** @todo fetch these from /auth/sso */
// const ssoProviders = computed(() => {
// const redirectURL = getRootPath() + `admin/login`;
// return projectsStore.currentProject.value.sso.map((provider: { icon: string; name: string }) => {
// return {
// ...provider,
// link: `/auth/sso/${provider.name}?mode=cookie&redirect_url=${redirectURL}`,
// };
// });
// });
return {
// ssoProviders,
errorFormatted,
error,
email,
@@ -162,19 +127,4 @@ export default defineComponent({
color: var(--foreground-normal);
}
}
.sso-divider {
margin: 24px 0;
}
.sso-button {
img {
width: 24px;
height: auto;
}
}
.sso-notice {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="sso-links">
<template v-if="providers.length > 0">
<v-divider />
<a class="sso-link" v-for="provider in providers" :key="provider.name" :href="provider.link">
{{ $t('log_in_with', { provider: provider.name }) }}
</a>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted } from '@vue/composition-api';
import api from '@/api';
import getRootPath from '@/utils/get-root-path';
export default defineComponent({
setup() {
const providers = ref([]);
const loading = ref(false);
const error = ref(null);
onMounted(() => fetchProviders());
return { providers };
async function fetchProviders() {
loading.value = true;
error.value = null;
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) {
error.value = err;
console.error(err);
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.v-divider {
margin: 24px 0;
}
.sso-link {
display: block;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: var(--input-height);
text-align: center;
background-color: var(--background-normal);
border-radius: var(--border-radius);
transition: background var(--fast) var(--transition);
&:hover {
background-color: var(--background-normal-alt);
}
& + & {
margin-top: 12px;
}
}
</style>