SSO fixes and improvements (#10301)

* Update refresh_token on refresh, allow custom authorize params, handle invalidated tokens

* Added default to generateAuthUrl

* Apparently issuer can be undefined now

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

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

* Make linter happy
This commit is contained in:
Aiden Foxx
2021-12-06 19:13:24 +01:00
committed by GitHub
parent 90b59d89e0
commit f48a413bb9
4 changed files with 89 additions and 32 deletions

View File

@@ -7,7 +7,12 @@ 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 {
InvalidCredentialsException,
ServiceUnavailableException,
InvalidConfigException,
InvalidTokenException,
} from '../../exceptions';
import { respond } from '../../middleware/respond';
import asyncHandler from '../../utils/async-handler';
import { Url } from '../../utils/url';
@@ -38,7 +43,8 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
authorization_endpoint: authorizeUrl,
token_endpoint: accessUrl,
userinfo_endpoint: profileUrl,
issuer: additionalConfig.provider,
// Required for openid providers (openid flow should be preferred!)
issuer: additionalConfig.issuerUrl,
});
this.client = new issuer.Client({
@@ -53,16 +59,20 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
return generators.codeVerifier();
}
generateAuthUrl(codeVerifier: string): string {
generateAuthUrl(codeVerifier: string, prompt = false): string {
try {
const codeChallenge = generators.codeChallenge(codeVerifier);
const paramsConfig = typeof this.config.params === 'object' ? this.config.params : {};
return this.client.authorizationUrl({
scope: this.config.scope ?? 'email',
access_type: 'offline',
prompt: prompt ? 'consent' : undefined,
...paramsConfig,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
// Some providers require state even with PKCE
state: codeChallenge,
access_type: 'offline',
});
} catch (e) {
throw handleError(e);
@@ -160,16 +170,21 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
}
}
if (!authData?.refreshToken) {
return sessionData;
if (authData?.refreshToken) {
try {
const tokenSet = await this.client.refresh(authData.refreshToken);
// Update user refreshToken if provided
if (tokenSet.refresh_token) {
await this.usersService.updateOne(user.id, {
auth_data: JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
}
} catch (e) {
throw handleError(e);
}
}
try {
const tokenSet = await this.client.refresh(authData.refreshToken);
return { accessToken: tokenSet.access_token };
} catch (e) {
throw handleError(e);
}
return sessionData;
}
}
@@ -177,7 +192,7 @@ const handleError = (e: any) => {
if (e instanceof errors.OPError) {
if (e.error === 'invalid_grant') {
// Invalid token
return new InvalidCredentialsException();
return new InvalidTokenException();
}
// Server response error
return new ServiceUnavailableException('Service returned unexpected response', {
@@ -199,7 +214,8 @@ export function createOAuth2AuthRouter(providerName: string): Router {
(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, {
const prompt = !!req.query.prompt;
const token = jwt.sign({ verifier: codeVerifier, redirect: req.query.redirect, prompt }, env.SECRET as string, {
expiresIn: '5m',
issuer: 'directus',
});
@@ -209,7 +225,7 @@ export function createOAuth2AuthRouter(providerName: string): Router {
sameSite: 'lax',
});
return res.redirect(provider.generateAuthUrl(codeVerifier));
return res.redirect(provider.generateAuthUrl(codeVerifier, prompt));
},
respond
);
@@ -223,12 +239,14 @@ export function createOAuth2AuthRouter(providerName: string): Router {
tokenData = jwt.verify(req.cookies[`oauth2.${providerName}`], env.SECRET as string, { issuer: 'directus' }) as {
verifier: string;
redirect?: string;
prompt: boolean;
};
} catch (e) {
logger.warn(`Couldn't verify OAuth2 cookie`);
throw new InvalidCredentialsException();
}
const { verifier, redirect } = tokenData;
const { verifier, redirect, prompt } = tokenData;
const authenticationService = new AuthenticationService({
accountability: {
@@ -254,6 +272,11 @@ export function createOAuth2AuthRouter(providerName: string): Router {
state: req.query.state,
});
} catch (error: any) {
// Prompt user for a new refresh_token if invalidated
if (error instanceof InvalidTokenException && !prompt) {
return res.redirect(`./?${redirect ? `redirect=${redirect}&` : ''}prompt=true`);
}
logger.warn(error);
if (redirect) {
@@ -263,6 +286,8 @@ export function createOAuth2AuthRouter(providerName: string): Router {
reason = 'SERVICE_UNAVAILABLE';
} else if (error instanceof InvalidCredentialsException) {
reason = 'INVALID_USER';
} else if (error instanceof InvalidTokenException) {
reason = 'INVALID_TOKEN';
}
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);

View File

@@ -7,7 +7,12 @@ 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 {
InvalidCredentialsException,
ServiceUnavailableException,
InvalidConfigException,
InvalidTokenException,
} from '../../exceptions';
import { respond } from '../../middleware/respond';
import asyncHandler from '../../utils/async-handler';
import { Url } from '../../utils/url';
@@ -62,17 +67,21 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
return generators.codeVerifier();
}
async generateAuthUrl(codeVerifier: string): Promise<string> {
async generateAuthUrl(codeVerifier: string, prompt = false): Promise<string> {
try {
const client = await this.client;
const codeChallenge = generators.codeChallenge(codeVerifier);
const paramsConfig = typeof this.config.params === 'object' ? this.config.params : {};
return client.authorizationUrl({
scope: this.config.scope ?? 'openid profile email',
access_type: 'offline',
prompt: prompt ? 'consent' : undefined,
...paramsConfig,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
// Some providers require state even with PKCE
state: codeChallenge,
access_type: 'offline',
});
} catch (e) {
throw handleError(e);
@@ -173,17 +182,22 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
}
}
if (!authData?.refreshToken) {
return sessionData;
if (authData?.refreshToken) {
try {
const client = await this.client;
const tokenSet = await client.refresh(authData.refreshToken);
// Update user refreshToken if provided
if (tokenSet.refresh_token) {
await this.usersService.updateOne(user.id, {
auth_data: JSON.stringify({ refreshToken: tokenSet.refresh_token }),
});
}
} catch (e) {
throw handleError(e);
}
}
try {
const client = await this.client;
const tokenSet = await client.refresh(authData.refreshToken);
return { accessToken: tokenSet.access_token };
} catch (e) {
throw handleError(e);
}
return sessionData;
}
}
@@ -191,7 +205,7 @@ const handleError = (e: any) => {
if (e instanceof errors.OPError) {
if (e.error === 'invalid_grant') {
// Invalid token
return new InvalidCredentialsException();
return new InvalidTokenException();
}
// Server response error
return new ServiceUnavailableException('Service returned unexpected response', {
@@ -213,7 +227,8 @@ export function createOpenIDAuthRouter(providerName: string): Router {
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, {
const prompt = !!req.query.prompt;
const token = jwt.sign({ verifier: codeVerifier, redirect: req.query.redirect, prompt }, env.SECRET as string, {
expiresIn: '5m',
issuer: 'directus',
});
@@ -223,7 +238,7 @@ export function createOpenIDAuthRouter(providerName: string): Router {
sameSite: 'lax',
});
return res.redirect(await provider.generateAuthUrl(codeVerifier));
return res.redirect(await provider.generateAuthUrl(codeVerifier, prompt));
}),
respond
);
@@ -237,12 +252,14 @@ export function createOpenIDAuthRouter(providerName: string): Router {
tokenData = jwt.verify(req.cookies[`openid.${providerName}`], env.SECRET as string, { issuer: 'directus' }) as {
verifier: string;
redirect?: string;
prompt: boolean;
};
} catch (e) {
logger.warn(`Couldn't verify OpenID cookie`);
throw new InvalidCredentialsException();
}
const { verifier, redirect } = tokenData;
const { verifier, redirect, prompt } = tokenData;
const authenticationService = new AuthenticationService({
accountability: {
@@ -268,6 +285,11 @@ export function createOpenIDAuthRouter(providerName: string): Router {
state: req.query.state,
});
} catch (error: any) {
// Prompt user for a new refresh_token if invalidated
if (error instanceof InvalidTokenException && !prompt) {
return res.redirect(`./?${redirect ? `redirect=${redirect}&` : ''}prompt=true`);
}
logger.warn(error);
if (redirect) {
@@ -277,6 +299,8 @@ export function createOpenIDAuthRouter(providerName: string): Router {
reason = 'SERVICE_UNAVAILABLE';
} else if (error instanceof InvalidCredentialsException) {
reason = 'INVALID_USER';
} else if (error instanceof InvalidTokenException) {
reason = 'INVALID_TOKEN';
}
return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);

View File

@@ -8,6 +8,7 @@ export * from './invalid-ip';
export * from './invalid-otp';
export * from './invalid-payload';
export * from './invalid-query';
export * from './invalid-token';
export * from './method-not-allowed';
export * from './range-not-satisfiable';
export * from './route-not-found';

View File

@@ -0,0 +1,7 @@
import { BaseException } from '@directus/shared/exceptions';
export class InvalidTokenException extends BaseException {
constructor(message = 'Invalid token') {
super(message, 403, 'INVALID_TOKEN');
}
}