Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge seperate config schemas #57

Merged
merged 8 commits into from
Jan 27, 2020
6 changes: 3 additions & 3 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ Additional configuration keys that can be passed to `auth()` on initialization:
- **`legacySameSiteCookie`** - Set a fallback cookie with no SameSite attribute when `authorizationParams.response_mode` is `form_post`. Default is `true`.
- **`loginPath`** - Relative path to application login. Default is `/login`.
- **`logoutPath`** - Relative path to application logout. Default is `/logout`.
- **`postLogoutRedirectUri`** - Either a relative path to the application or a valid URI to an external domain. The user will be redirected to this after a logout has been performed. This value must be registered at the authorization server/ Default is `baseUrl`.
- **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`.
- **`postLogoutRedirectUri`** - Either a relative path to the application or a valid URI to an external domain. The user will be redirected to this after a logout has been performed. Default is `baseUrl`.
- **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`.
- **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`.

### Authorization Params Key

The `authorizationParams` key defines the URL parameters used when redirecting users to the authorization server to log in. If this key is not provided by your application, its default value will be:
The `authorizationParams` key defines the URL parameters used when redirecting users to the authorization server to log in. If this key is not provided by your application, its default values will be:

```js
{
Expand All @@ -54,7 +54,7 @@ The `authorizationParams` key defines the URL parameters used when redirecting u
}
```

A new object can be passed in to change what is returned from the authorization server depending on your specific scenario.
New values can be passed in to change what is returned from the authorization server depending on your specific scenario.

For example, to receive an access token for an API, you could initialize like the sample below. Note that `response_mode` can be omitted because the OAuth2 default mode of `query` is fine:

Expand Down
13 changes: 7 additions & 6 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface ConfigParams {

/**
* REQUIRED. The secret(s) used to derive an encryption key for the user identity in a session cookie.
* Use a single string key or array of keys for an encrypted session cookie or false to skip.
* Can use env key APP_SESSION_SECRET instead.
*/
appSessionSecret?: boolean | string | string[];
Expand Down Expand Up @@ -119,16 +120,16 @@ interface ConfigParams {
logoutPath?: string;

/**
* Relative path to the application callback to process the response from the authorization server.
* Either a relative path to the application or a valid URI to an external domain.
* This value must be registered on the authorization server.
* The user will be redirected to this after a logout has been performed.
*/
redirectUriPath?: string;
postLogoutRedirectUri?: string;

/**
* Either a relative path to the application
* or a valid URI to an external domain.
* The user will be redirected to this after a logout has been performed.
* Relative path to the application callback to process the response from the authorization server.
*/
postLogoutRedirectUri?: string;
redirectUriPath?: string;

/**
* Require authentication for all routes.
Expand Down
15 changes: 10 additions & 5 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,29 @@ const telemetryHeader = {
}
};

function spacedStringsToAlphabetical(string) {
return string.split(' ').sort().join(' ');
}

async function get(config) {

const issuer = await Issuer.discover(config.issuerBaseURL);

const issuerTokenAlgs = Array.isArray(issuer.id_token_signing_alg_values_supported) ?
const issuerTokenAlgs = Array.isArray(issuer.id_token_signing_alg_values_supported) ?
issuer.id_token_signing_alg_values_supported : [];
if (!issuerTokenAlgs.includes(config.idTokenAlg)) {
throw new Error(
`ID token algorithm "${config.idTokenAlg}" is not supported by the issuer. ` +
`ID token algorithm "${config.idTokenAlg}" is not supported by the issuer. ` +
`Supported ID token algorithms are: "${issuerTokenAlgs.join('", "')}". `
);
}

const configRespType = config.authorizationParams.response_type;
const configRespType = spacedStringsToAlphabetical(config.authorizationParams.response_type);
const issuerRespTypes = Array.isArray(issuer.response_types_supported) ? issuer.response_types_supported : [];
issuerRespTypes.map(spacedStringsToAlphabetical);
if (!issuerRespTypes.includes(configRespType)) {
throw new Error(
`Response type "${configRespType}" is not supported by the issuer. ` +
`Response type "${configRespType}" is not supported by the issuer. ` +
`Supported response types are: "${issuerRespTypes.join('", "')}". `
);
}
Expand All @@ -38,7 +43,7 @@ async function get(config) {
const issuerRespModes = Array.isArray(issuer.response_modes_supported) ? issuer.response_modes_supported : [];
if (configRespMode && ! issuerRespModes.includes(configRespMode)) {
throw new Error(
`Response mode "${configRespMode}" is not supported by the issuer. ` +
`Response mode "${configRespMode}" is not supported by the issuer. ` +
`Supported response modes are "${issuerRespModes.join('", "')}". `
);
}
Expand Down
98 changes: 22 additions & 76 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,35 @@ const loadEnvs = require('./loadEnvs');
const getUser = require('./hooks/getUser');
const handleCallback = require('./hooks/handleCallback');

const defaultAuthorizeParams = {
response_mode: 'form_post',
response_type: 'id_token',
scope: 'openid profile email'
};

const authorizationParamsSchema = Joi.object().keys({
response_mode: [Joi.string().optional(), Joi.allow(null).optional()],
response_type: Joi.string().required(),
scope: Joi.string().required()
}).unknown(true);

const defaultAppSessionCookie = {
httpOnly: true,
sameSite: 'Lax',
ephemeral: false
};

const appSessionCookieSchema = Joi.object().keys({
domain: Joi.string().optional(),
ephemeral: Joi.boolean().optional(),
httpOnly: Joi.boolean().optional(),
path: Joi.string().optional(),
sameSite: Joi.string().valid('Lax', 'Strict', 'None').optional(),
secure: Joi.boolean().optional()
}).unknown(false);

const paramsSchema = Joi.object().keys({
appSessionCookie: Joi.object().optional(),
const paramsSchema = Joi.object({
appSessionCookie: Joi.object({
domain: Joi.string().optional(),
ephemeral: Joi.boolean().optional().default(false),
httpOnly: Joi.boolean().optional().default(true),
path: Joi.string().optional(),
sameSite: Joi.string().valid('Lax', 'Strict', 'None').optional().default('Lax'),
secure: Joi.boolean().optional()
}).optional().unknown(false).default(),
appSessionDuration: Joi.number().integer().optional().default(7 * 24 * 60 * 60),
appSessionName: Joi.string().token().optional().default('identity'),
appSessionSecret: Joi.alternatives([
// Single string key.
Joi.string(),
// Array of keys to allow for rotation.
Joi.array().items(Joi.string()),
// False to stop client session from being created.
Joi.boolean().valid(false)
]).required(),
auth0Logout: Joi.boolean().optional().default(false),
authorizationParams: Joi.object().optional(),
authorizationParams: Joi.object({
response_type: Joi.string().optional().default('id_token'),
scope: Joi.string().optional().default('openid profile email'),
response_mode: Joi.alternatives([
Joi.string().optional(),
Joi.allow(null).optional()
]).default(function(parent) {
const responseType = parent.response_type.split(' ');
const responseIncludesTokens = responseType.includes('id_token') || responseType.includes('token');
return responseIncludesTokens ? 'form_post' : undefined;
}),
}).optional().unknown(true).default(),
baseURL: Joi.string().uri().required(),
clientID: Joi.string().required(),
clientSecret: Joi.string().optional(),
Expand All @@ -62,52 +50,12 @@ const paramsSchema = Joi.object().keys({
legacySameSiteCookie: Joi.boolean().optional().default(true),
loginPath: Joi.string().uri({relativeOnly: true}).optional().default('/login'),
logoutPath: Joi.string().uri({relativeOnly: true}).optional().default('/logout'),
postLogoutRedirectUri: Joi.string().uri({allowRelative: true}).optional().default(''),
redirectUriPath: Joi.string().uri({relativeOnly: true}).optional().default('/callback'),
required: Joi.alternatives([ Joi.function(), Joi.boolean()]).optional().default(true),
routes: Joi.boolean().optional().default(true),
postLogoutRedirectUri: Joi.string().uri({allowRelative: true}).optional().default('/')
});

function buildAuthorizeParams(authorizationParams) {
/*
If the user does not provide authorizationParams we default to "defaultAuthorizeParams" (id_token/form_post).

If the user provides authorizationParams then
- the default response_mode is DEFAULT (undefined),
- the default scope is defaultAuthorizeParams.scope
- response type is required
*/

authorizationParams = authorizationParams && Object.keys(authorizationParams).length > 0 ?
authorizationParams :
clone(defaultAuthorizeParams);

if (!authorizationParams.scope) {
authorizationParams.scope = defaultAuthorizeParams.scope;
}

const authParamsValidation = authorizationParamsSchema.validate(authorizationParams);

if(authParamsValidation.error) {
throw new Error(authParamsValidation.error.details[0].message);
}

return authorizationParams;
}

function buildAppSessionCookieConfig(cookieConfig) {

cookieConfig = cookieConfig && Object.keys(cookieConfig).length ? cookieConfig : {};
cookieConfig = Object.assign({}, defaultAppSessionCookie, cookieConfig);
const cookieConfigValidation = appSessionCookieSchema.validate(cookieConfig);

if(cookieConfigValidation.error) {
throw new Error(cookieConfigValidation.error.details[0].message);
}

return cookieConfig;
}

module.exports.get = function(params) {
let config = typeof params == 'object' ? clone(params) : {};

Expand All @@ -120,8 +68,6 @@ module.exports.get = function(params) {
}

config = paramsValidation.value;
config.authorizationParams = buildAuthorizeParams(config.authorizationParams);
config.appSessionCookie = buildAppSessionCookieConfig(config.appSessionCookie);

// Code grant requires a client secret to exchange the code for tokens
const responseTypeHasCode = config.authorizationParams.response_type.split(' ').includes('code');
Expand Down
4 changes: 2 additions & 2 deletions test/auth.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,14 +200,14 @@ describe('auth', function() {
assert.equal(parsed.query.client_id, '__test_client_id__');
assert.equal(parsed.query.scope, 'openid profile email');
assert.equal(parsed.query.response_type, 'id_token');
assert.equal(parsed.query.response_mode, undefined);
assert.equal(parsed.query.response_mode, 'form_post');
assert.equal(parsed.query.redirect_uri, 'https://example.org/callback');
assert.property(parsed.query, 'nonce');
assert.property(parsed.query, 'state');
});

it('should contain the two callbacks route', function() {
assert.ok(router.stack.some(filterRoute('GET', '/callback')));
assert.ok(router.stack.some(filterRoute('POST', '/callback')));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a remnant of fragment support. id_token with an undefined response_mode defaults to fragment which is not supported in this SDK.

});

});
Expand Down
14 changes: 14 additions & 0 deletions test/config.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ describe('config', function() {
});
});

describe('when authorizationParams response_type fuzzy matches issuer', function() {
const customConfig = Object.assign({}, defaultConfig, {
clientSecret: '__test_client_secret__',
authorizationParams: {
response_type: 'token id_token code'
}
});
const config = getConfig(customConfig);

it('should keep token code', function() {
assert.equal(config.authorizationParams.response_type, 'token id_token code');
Copy link
Contributor Author

@joshcanhelp joshcanhelp Jan 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does not exactly match metadata fixture so this would have thrown before.

});
});

describe('with auth0Logout', function() {
const config = getConfig(Object.assign({}, defaultConfig, {auth0Logout: true}));

Expand Down
10 changes: 5 additions & 5 deletions test/invalid_params.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('invalid parameters', function() {
httpOnly: '__invalid_httponly__'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error message changes only in this file.

}
}));
}, '"httpOnly" must be a boolean');
}, '"appSessionCookie.httpOnly" must be a boolean');
});

it('should fail when app session cookie secure is not a boolean', function() {
Expand All @@ -114,7 +114,7 @@ describe('invalid parameters', function() {
secure: '__invalid_secure__'
}
}));
}, '"secure" must be a boolean');
}, '"appSessionCookie.secure" must be a boolean');
});

it('should fail when app session cookie sameSite is invalid', function() {
Expand All @@ -124,7 +124,7 @@ describe('invalid parameters', function() {
sameSite: '__invalid_samesite__'
}
}));
}, '"sameSite" must be one of [Lax, Strict, None]');
}, '"appSessionCookie.sameSite" must be one of [Lax, Strict, None]');
});

it('should fail when app session cookie domain is invalid', function() {
Expand All @@ -134,7 +134,7 @@ describe('invalid parameters', function() {
domain: false
}
}));
}, '"domain" must be a string');
}, '"appSessionCookie.domain" must be a string');
});

it('should fail when app session cookie sameSite is an invalid value', function() {
Expand All @@ -144,6 +144,6 @@ describe('invalid parameters', function() {
path: 123
}
}));
}, '"path" must be a string');
}, '"appSessionCookie.path" must be a string');
});
});
17 changes: 8 additions & 9 deletions test/logout.tests.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const { assert } = require('chai');
const url = require('url');
const server = require('./fixture/server');
const { auth } = require('./..');

Expand Down Expand Up @@ -44,7 +43,7 @@ describe('logout route', function() {

it('should redirect to the base url', function() {
assert.equal(logoutResponse.statusCode, 302);
assert.equal(logoutResponse.headers.location, 'https://example.org/');
assert.equal(logoutResponse.headers.location, 'https://example.org');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

});
});

Expand Down Expand Up @@ -83,7 +82,7 @@ describe('logout route', function() {

it('should redirect to the base url', function() {
assert.equal(logoutResponse.statusCode, 302);
assert.equal(logoutResponse.headers.location, 'https://example.org/');
assert.equal(logoutResponse.headers.location, 'https://example.org');
});
});

Expand All @@ -92,7 +91,7 @@ describe('logout route', function() {
describe('should allow relative paths, and prepend with baseURL', () => {
let baseUrl;
const jar = request.jar();

before(async function() {
const middleware = auth({
idpLogout: false,
Expand All @@ -114,12 +113,12 @@ describe('logout route', function() {
baseUrl, jar
});
});

it('should redirect to postLogoutRedirectUri in auth() config', async function() {
const logoutResponse = await request.get({uri: '/logout', baseUrl, jar, followRedirect: false});
assert.equal(logoutResponse.headers.location, 'https://example.org/after-logout-in-auth-config');
});

it('should redirect to returnTo in logout query', async function() {
const logoutResponse = await request.get({uri: '/logout', qs: {returnTo: '/after-logout-in-logout-query'}, baseUrl, jar, followRedirect: false});
assert.equal(logoutResponse.headers.location, 'https://example.org/after-logout-in-logout-query');
Expand All @@ -129,7 +128,7 @@ describe('logout route', function() {
describe('should allow absolute paths', () => {
let baseUrl;
const jar = request.jar();

before(async function() {
const middleware = auth({
idpLogout: false,
Expand All @@ -151,12 +150,12 @@ describe('logout route', function() {
baseUrl, jar
});
});

it('should redirect to postLogoutRedirectUri in auth() config', async function() {
const logoutResponse = await request.get({uri: '/logout', baseUrl, jar, followRedirect: false});
assert.equal(logoutResponse.headers.location, 'https://external-domain.com/after-logout-in-auth-config');
});

it('should redirect to returnTo in logout query', async function() {
const logoutResponse = await request.get({uri: '/logout', qs: {returnTo: 'https://external-domain.com/after-logout-in-logout-query'}, baseUrl, jar, followRedirect: false});
assert.equal(logoutResponse.headers.location, 'https://external-domain.com/after-logout-in-logout-query');
Expand Down