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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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. Adding a `returnTo` parameter on the logout route will override this value. The value used 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
156 changes: 55 additions & 101 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -1,138 +1,92 @@
const Joi = require('@hapi/joi');
const clone = require('clone');
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(),
clientSecret: Joi.string().when(
Joi.ref('authorizationParams.response_type', {adjust: (value) => value && value.split(' ').includes('code')}),
{
is: true,
then: Joi.string().required().messages({
'any.required': '"clientSecret" is required for response_type code'
}),
otherwise: Joi.when(
Joi.ref('idTokenAlg', {adjust: (value) => value && 'HS' === value.substring(0,2)}),
{
is: true,
then: Joi.string().required().messages({
'any.required': '"clientSecret" is required for ID tokens with HS algorithms'
})
}
)
}
),
clockTolerance: Joi.number().optional().default(60),
errorOnRequiredAuth: Joi.boolean().optional().default(false),
getUser: Joi.function().optional().default(() => getUser),
handleCallback: Joi.function().optional().default(() => handleCallback),
httpOptions: Joi.object().optional(),
identityClaimFilter: Joi.array().optional().default(['aud', 'iss', 'iat', 'exp', 'nonce', 'azp', 'auth_time']),
idpLogout: Joi.boolean().optional().default(false).when(
'auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }
),
idpLogout: Joi.boolean().optional().default((parent) => parent.auth0Logout || false),
idTokenAlg: Joi.string().not('none').optional().default('RS256'),
issuerBaseURL: Joi.alternatives([ Joi.string().uri(), Joi.string().hostname() ]).required(),
issuerBaseURL: Joi.alternatives([
Joi.string().uri(),
Joi.string().hostname()
]).required(),
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) : {};

loadEnvs(config);
let config = (typeof params == 'object' ? clone(params) : {});
config = Object.assign({
issuerBaseURL: process.env.ISSUER_BASE_URL,
baseURL: process.env.BASE_URL,
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
appSessionSecret: process.env.APP_SESSION_SECRET,
}, config);

const paramsValidation = paramsSchema.validate(config);

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

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');
if (responseTypeHasCode && !config.clientSecret) {
throw new Error('"clientSecret" is required for response_type code');
}

// HS256 ID tokens require a client secret to validate the signature.
if ('HS' === config.idTokenAlg.substring(0,2) && !config.clientSecret) {
throw new Error('"clientSecret" is required for ID tokens with HS algorithms');
}

return config;
return paramsValidation.value;
};
16 changes: 0 additions & 16 deletions lib/loadEnvs.js

This file was deleted.

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
26 changes: 20 additions & 6 deletions test/config.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ describe('config', function() {
assert.equal(config.authorizationParams.response_mode, 'form_post');
});

it('should default to scope=openid profile email ', function() {
it('should default to scope=openid profile email', function() {
assert.equal(config.authorizationParams.scope, 'openid profile email');
});

it('should default to required true ', function() {
it('should default to required true', function() {
assert.ok(config.required);
});
});

describe('when authorizationParams is response_type=x', function() {
describe('when authorizationParams is response_type=code', function() {
const customConfig = Object.assign({}, defaultConfig, {
clientSecret: '__test_client_secret__',
authorizationParams: {
Expand All @@ -38,19 +38,33 @@ describe('config', function() {
});
const config = getConfig(customConfig);

it('should default to response_type=id_token', function() {
it('should set new response_type', function() {
assert.equal(config.authorizationParams.response_type, 'code');
});

it('should default to response_mode=form_post', function() {
it('should allow undefined response_mode', function() {
assert.equal(config.authorizationParams.response_mode, undefined);
});

it('should default to scope=openid profile email ', function() {
it('should keep default scope', function() {
assert.equal(config.authorizationParams.scope, 'openid profile email');
});
});

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
Loading