diff --git a/.eslintrc.json b/.eslintrc.json index a9602b2c..3a9fe607 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,35 +1,16 @@ { - "env": { - "es6": true, - "node": true, - "mocha": true - }, - "extends": "eslint:recommended", - "rules": { - "no-useless-escape": 1, - "no-console": 0, - "indent": [ - "error", - 2, - { "SwitchCase": 1 } - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single", - { - "avoidEscape": true - } - ], - "semi": [ - "error", - "always" - ] - }, - "parserOptions": { - "ecmaVersion": 2018 - } + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "extends": "eslint:recommended", + "rules": { + "no-useless-escape": 1, + "no-console": 0, + "linebreak-style": ["error", "unix"] + }, + "parserOptions": { + "ecmaVersion": 2018 + } } diff --git a/.github/stale.yml b/.github/stale.yml index b2e13fc7..3cc35f17 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -17,4 +17,4 @@ staleLabel: closed:stale # Comment to post when marking as stale. Set to `false` to disable markComment: > - This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If you have not received a response for our team (apologies for the delay) and this is still a blocker, please reply with additional information or just a ping. Thank you for your contribution! 🙇‍♂️ \ No newline at end of file + This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. If you have not received a response for our team (apologies for the delay) and this is still a blocker, please reply with additional information or just a ping. Thank you for your contribution! 🙇‍♂️ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..cda0b9a2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +CHANGELOG.md +coverage +.nyc_output diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..2ddc9656 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 80 +} diff --git a/API.md b/API.md index 336b424f..5131ea14 100644 --- a/API.md +++ b/API.md @@ -10,7 +10,7 @@ The `auth()` middleware has a few configuration properties that are required for - **`secret`** - The secret used to derive various keys utilized by the library for signing, encryption, etc. It must be a string, buffer, or an array of strings or buffers. When an array is provided, the first member is used for current operations while the other array members are used for decrypting/verifying old cookies, this enables secret rotation. This can be set automatically with a `SECRET` variable in your environment. - **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment. -- **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. +- **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. - **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment. If you are using a response type that includes `code`, you will need an additional configuration property: @@ -67,13 +67,15 @@ New values can be passed in to change what is returned from the authorization se 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: ```js -app.use(auth({ - authorizationParams: { - response_type: "code", - scope: "openid profile email read:reports", - audience: "https://your-api-identifier" - } -})); +app.use( + auth({ + authorizationParams: { + response_type: 'code', + scope: 'openid profile email read:reports', + audience: 'https://your-api-identifier', + }, + }) +); ``` Additional custom parameters can be added as well: @@ -99,8 +101,8 @@ The `requiresAuth()` function is an optional middleware that protects specific a ```js const { auth, requiresAuth } = require('express-openid-connect'); -app.use( auth( { authRequired: false } ) ); -app.use( '/admin', requiresAuth(), (req, res) => res.render('admin') ); +app.use(auth({ authRequired: false })); +app.use('/admin', requiresAuth(), (req, res) => res.render('admin')); ``` Using `requiresAuth()` on its own without initializing `auth()` will throw a `401 Unauthorized` error instead of triggering the login process: diff --git a/EXAMPLES.md b/EXAMPLES.md index ec06dd9d..5f9784b8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,4 +1,3 @@ - # Examples ## 1. Basic setup @@ -17,9 +16,11 @@ APP_SESSION_SECRET=LONG_RANDOM_STRING // app.js const { auth } = require('express-openid-connect'); -app.use(auth({ - required: true -})) +app.use( + auth({ + required: true, + }) +); app.use('/', (req, res) => { res.send(`hello ${req.openid.user.name}`); @@ -39,16 +40,22 @@ If your application has routes accessible to anonymous users, you can enable aut ```js const { auth, requiresAuth } = require('express-openid-connect'); -app.use(auth({ - required: false -})); +app.use( + auth({ + required: false, + }) +); // Anyone can access the homepage app.get('/', (req, res) => res.render('home')); // Require routes under the /admin/ prefix to check authentication. -app.get('/admin/users', requiresAuth(), (req, res) => res.render('admin-users')); -app.get('/admin/posts', requiresAuth(), (req, res) => res.render('admin-posts')); +app.get('/admin/users', requiresAuth(), (req, res) => + res.render('admin-users') +); +app.get('/admin/posts', requiresAuth(), (req, res) => + res.render('admin-posts') +); ``` Another way to configure this scenario: @@ -56,9 +63,11 @@ Another way to configure this scenario: ```js const { auth } = require('express-openid-connect'); -app.use(auth({ - required: req => req.originalUrl.startsWith('/admin/') -})); +app.use( + auth({ + required: (req) => req.originalUrl.startsWith('/admin/'), + }) +); app.use('/', (req, res) => res.render('home')); app.use('/admin/users', (req, res) => res.render('admin-users')); @@ -79,11 +88,13 @@ app.get('/account/logout', (req, res) => res.openid.logout()); ... or you can define specific routes in configuration keys where the default handler will run: ```js -app.use(auth({ - redirectUriPath: '/custom-callback-path', - loginPath: '/custom-login-path', - logoutPath: '/custom-logout-path', -})); +app.use( + auth({ + redirectUriPath: '/custom-callback-path', + loginPath: '/custom-login-path', + logoutPath: '/custom-logout-path', + }) +); ``` Please note that the login and logout routes are not required. Trying to access any protected resource triggers a redirect directly to Auth0 to login. These are helpful if you need to provide user-facing links to login or logout. @@ -96,26 +107,30 @@ If, for example, you want the user session to be stored on the server, you can u ```js const session = require('express-session'); -app.use(session({ - secret: 'replace this with a long, random, static string', - cookie: { - // Sets the session cookie to expire after 7 days. - maxAge: 7 * 24 * 60 * 60 * 1000 - } -})); - -app.use(auth({ - // Setting this configuration key to false will turn off internal session handling. - appSession: false, - handleCallback: async function (req, res, next) { - // This will store the user identity claims in the session. - req.session.userIdentity = req.openidTokens.claims(); - next(); - }, - getUser: async function (req) { - return req.session.userIdentity; - } -})); +app.use( + session({ + secret: 'replace this with a long, random, static string', + cookie: { + // Sets the session cookie to expire after 7 days. + maxAge: 7 * 24 * 60 * 60 * 1000, + }, + }) +); + +app.use( + auth({ + // Setting this configuration key to false will turn off internal session handling. + appSession: false, + handleCallback: async function (req, res, next) { + // This will store the user identity claims in the session. + req.session.userIdentity = req.openidTokens.claims(); + next(); + }, + getUser: async function (req) { + return req.session.userIdentity; + }, + }) +); ``` ## 5. Obtaining and storing access tokens to call external APIs @@ -126,33 +141,36 @@ If the tokens only need to be used during the user's session, they can be stored ```js const session = require('express-session'); -app.use(session({ - secret: 'replace this with a long, random, static string', - cookie: { - // Sets the session cookie to expire after 7 days. - maxAge: 7 * 24 * 60 * 60 * 1000 - } -})); - -app.use(auth({ - authorizationParams: { - response_type: 'code', - audience: process.env.API_AUDIENCE, - scope: 'openid profile email read:reports' - }, - handleCallback: async function (req, res, next) { - // Store recevied tokens (access and ID in this case) in server-side storage. - req.session.openidTokens = req.openidTokens; - next(); - } -})); +app.use( + session({ + secret: 'replace this with a long, random, static string', + cookie: { + // Sets the session cookie to expire after 7 days. + maxAge: 7 * 24 * 60 * 60 * 1000, + }, + }) +); + +app.use( + auth({ + authorizationParams: { + response_type: 'code', + audience: process.env.API_AUDIENCE, + scope: 'openid profile email read:reports', + }, + handleCallback: async function (req, res, next) { + // Store recevied tokens (access and ID in this case) in server-side storage. + req.session.openidTokens = req.openidTokens; + next(); + }, + }) +); ``` On a route that needs to use the access token, pull the token data from the storage and initialize a new `TokenSet` using `makeTokenSet()` method exposed by this library: ```js app.get('/route-that-calls-an-api', async (req, res, next) => { - const tokenSet = req.openid.makeTokenSet(req.session.openidTokens); let apiData = {}; @@ -165,27 +183,28 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { [Refresh tokens](https://auth0.com/docs/tokens/concepts/refresh-tokens) can be requested along with access tokens using the `offline_access` scope during login. Please see the section on access tokens above for information on token storage. ```js -app.use(auth({ - authorizationParams: { - response_type: 'code id_token', - response_mode: 'form_post', - // API identifier to indicate which API this application will be calling. - audience: process.env.API_AUDIENCE, - // Include the required scopes as well as offline_access to generate a refresh token. - scope: 'openid profile email read:reports offline_access' - }, - handleCallback: async function (req, res, next) { - // See the "Using access tokens" section above for token handling. - next(); - } -})); +app.use( + auth({ + authorizationParams: { + response_type: 'code id_token', + response_mode: 'form_post', + // API identifier to indicate which API this application will be calling. + audience: process.env.API_AUDIENCE, + // Include the required scopes as well as offline_access to generate a refresh token. + scope: 'openid profile email read:reports offline_access', + }, + handleCallback: async function (req, res, next) { + // See the "Using access tokens" section above for token handling. + next(); + }, + }) +); ``` On a route that calls an API, check for an expired token and attempt a refresh: ```js app.get('/route-that-calls-an-api', async (req, res, next) => { - let apiData = {}; // How the tokenSet is created will depend on how the tokens are stored. @@ -195,7 +214,7 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { if (tokenSet && tokenSet.expired() && refreshToken) { try { tokenSet = await req.openid.client.refresh(tokenSet); - } catch(err) { + } catch (err) { next(err); } @@ -220,22 +239,24 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { If your application needs to call the userinfo endpoint for the user's identity instead of the ID token used by default, add a `handleCallback` function during initialization that will make this call. Save the claims retrieved from the userinfo endpoint to the `appSession.name` on the request object (default is `appSession`): ```js -app.use(auth({ - handleCallback: async function (req, res, next) { - const client = req.openid.client; - req.appSession = req.appSession || {}; - try { - req.appSession.claims = await client.userinfo(req.openidTokens); - next(); - } catch(e) { - next(e); - } - }, - authorizationParams: { - response_type: 'code', - scope: 'openid profile email' - } -})); +app.use( + auth({ + handleCallback: async function (req, res, next) { + const client = req.openid.client; + req.appSession = req.appSession || {}; + try { + req.appSession.claims = await client.userinfo(req.openidTokens); + next(); + } catch (e) { + next(e); + } + }, + authorizationParams: { + response_type: 'code', + scope: 'openid profile email', + }, + }) +); ``` ## 8. Custom state handling @@ -245,24 +266,26 @@ If your application needs to keep track of the request state before redirecting You can define a `getLoginState` configuration key set to a function that takes an Express `RequestHandler` and an options object and returns a plain object: ```js -app.use(auth({ - getLoginState: function (req, options) { - // This object will be stringified and base64 URL-safe encoded. - return { - // Property used by the library for redirecting after logging in. - returnTo: '/custom-return-path', - // Additional properties as needed. - customProperty: req.someProperty, - }; - }, - handleCallback: function (req, res, next) { - // The req.openidState.customProperty is now available to use. - if ( req.openidState.customProperty ) { - // Do something ... - } - - // Call next() to redirect to req.openidState.returnTo. - next(); - } -})); +app.use( + auth({ + getLoginState: function (req, options) { + // This object will be stringified and base64 URL-safe encoded. + return { + // Property used by the library for redirecting after logging in. + returnTo: '/custom-return-path', + // Additional properties as needed. + customProperty: req.someProperty, + }; + }, + handleCallback: function (req, res, next) { + // The req.openidState.customProperty is now available to use. + if (req.openidState.customProperty) { + // Do something ... + } + + // Call next() to redirect to req.openidState.returnTo. + next(); + }, + }) +); ``` diff --git a/README.md b/README.md index 8fb1d469..531ed49c 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ SECRET=LONG_RANDOM_VALUE ```js // index.js -const { auth } = require("express-openid-connect"); +const { auth } = require('express-openid-connect'); app.use( auth({ - issuerBaseURL: "https://YOUR_DOMAIN", - baseURL: "https://YOUR_APPLICATION_ROOT_URL", - clientID: "YOUR_CLIENT_ID", - secret: "LONG_RANDOM_STRING" + issuerBaseURL: 'https://YOUR_DOMAIN', + baseURL: 'https://YOUR_APPLICATION_ROOT_URL', + clientID: 'YOUR_CLIENT_ID', + secret: 'LONG_RANDOM_STRING', }) ); ``` diff --git a/codecov.yml b/codecov.yml index 710b8156..0272cedc 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,7 +1,7 @@ coverage: precision: 2 round: down - range: "80...100" + range: '80...100' status: project: default: @@ -19,4 +19,4 @@ coverage: default: enabled: true if_no_uploads: error -comment: false \ No newline at end of file +comment: false diff --git a/index.d.ts b/index.d.ts index bc606d65..56ce8890 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,205 +1,219 @@ // Type definitions for express-openid-connect -import { AuthorizationParameters, TokenSet, UserinfoResponse } from 'openid-client'; -import { Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express'; +import { + AuthorizationParameters, + TokenSet, + UserinfoResponse, +} from 'openid-client'; +import { + Request, + Response, + NextFunction, + RequestHandler, + ErrorRequestHandler, +} from 'express'; interface OpenidRequest extends Request { - /** - * Library namespace for methods and data. - * See RequestContext and ResponseContext for how this is used. - */ - oidc: object; - - /** - * Decoded state for use in config.handleCallback(). - */ - openidState: object; - - /** - * Tokens for use in config.handleCallback(). - */ - openidTokens: TokenSet; + /** + * Library namespace for methods and data. + * See RequestContext and ResponseContext for how this is used. + */ + oidc: object; + + /** + * Decoded state for use in config.handleCallback(). + */ + openidState: object; + + /** + * Tokens for use in config.handleCallback(). + */ + openidTokens: TokenSet; } /** * Configuration parameters passed to the auth() middleware. */ interface ConfigParams { - /** - * Object defining application session cookie attributes. - */ - appSession: boolean | AppSessionConfigParams; - - /** - * Boolean value to enable Auth0's logout feature. - */ - auth0Logout?: boolean; - - /** - * URL parameters used when redirecting users to the authorization server to log in. - */ - authorizationParams?: AuthorizationParameters - - /** - * REQUIRED. The root URL for the application router. - * Can use env key BASE_URL instead. - */ - baseURL?: string; - - /** - * REQUIRED. The Client ID for your application. - * Can use env key CLIENT_ID instead. - */ - clientID?: string; - - /** - * The Client Secret for your application. - * Required when requesting access tokens. - * Can use env key CLIENT_SECRET instead. - */ - clientSecret?: string; - - /** - * Integer value for the system clock's tolerance (leeway) in seconds for ID token verification. - */ - clockTolerance?: number; - - /** - * Opt-in to sending the library and node version to your authorization server - * via the `Auth0-Client` header. - */ - enableTelemetry?: boolean; - - /** - * Throw a 401 error instead of triggering the login process for routes that require authentication. - */ - errorOnRequiredAuth?: boolean; - - /** - * Function that returns a URL-safe state value for `res.oidc.login()`. - */ - getLoginState?: (req: OpenidRequest, config: object) => object; - - /** - * Function that runs on the callback route, after callback processing but before redirection. - */ - handleCallback?: (req: OpenidRequest, res: Response, next: NextFunction) => void; - - /** - * Array value of claims to remove from the ID token before storing the cookie session. - */ - identityClaimFilter?: string[]; - - /** - * Boolean value to log the user out from the identity provider on application logout. - */ - idpLogout?: boolean; - - /** - * String value for the expected ID token algorithm. - */ - idTokenSigningAlg?: string; - - /** - * REQUIRED. The root URL for the token issuer with no trailing slash. - * Can use env key ISSUER_BASE_URL instead. - */ - issuerBaseURL?: string; - - /** - * Set a fallback cookie with no SameSite attribute when response_mode is form_post. + /** + * Object defining application session cookie attributes. + */ + appSession: boolean | AppSessionConfigParams; + + /** + * Boolean value to enable Auth0's logout feature. + */ + auth0Logout?: boolean; + + /** + * URL parameters used when redirecting users to the authorization server to log in. + */ + authorizationParams?: AuthorizationParameters; + + /** + * REQUIRED. The root URL for the application router. + * Can use env key BASE_URL instead. + */ + baseURL?: string; + + /** + * REQUIRED. The Client ID for your application. + * Can use env key CLIENT_ID instead. + */ + clientID?: string; + + /** + * The Client Secret for your application. + * Required when requesting access tokens. + * Can use env key CLIENT_SECRET instead. + */ + clientSecret?: string; + + /** + * Integer value for the system clock's tolerance (leeway) in seconds for ID token verification. + */ + clockTolerance?: number; + + /** + * Opt-in to sending the library and node version to your authorization server + * via the `Auth0-Client` header. + */ + enableTelemetry?: boolean; + + /** + * Throw a 401 error instead of triggering the login process for routes that require authentication. + */ + errorOnRequiredAuth?: boolean; + + /** + * Function that returns a URL-safe state value for `res.oidc.login()`. + */ + getLoginState?: (req: OpenidRequest, config: object) => object; + + /** + * Function that runs on the callback route, after callback processing but before redirection. + */ + handleCallback?: ( + req: OpenidRequest, + res: Response, + next: NextFunction + ) => void; + + /** + * Array value of claims to remove from the ID token before storing the cookie session. + */ + identityClaimFilter?: string[]; + + /** + * Boolean value to log the user out from the identity provider on application logout. + */ + idpLogout?: boolean; + + /** + * String value for the expected ID token algorithm. + */ + idTokenSigningAlg?: string; + + /** + * REQUIRED. The root URL for the token issuer with no trailing slash. + * Can use env key ISSUER_BASE_URL instead. + */ + issuerBaseURL?: string; + + /** + * Set a fallback cookie with no SameSite attribute when response_mode is form_post. + */ + legacySameSiteCookie?: boolean; + + /** + * Require authentication for all routes. + */ + authRequired?: boolean | ((request: Request) => boolean); + + /** + * Boolean value to automatically install the login and logout routes. + */ + routes?: { + /** + * Relative path to application login. + */ + login?: string | false; + + /** + * Relative path to application logout. + */ + logoutPath?: string | false; + + /** + * 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. + */ + postLogoutRedirectUri?: string; + + /** + * Relative path to the application callback to process the response from the authorization server. */ - legacySameSiteCookie?: boolean; - - /** - * Require authentication for all routes. - */ - authRequired?: boolean | ((request: Request) => boolean); - - /** - * Boolean value to automatically install the login and logout routes. - */ - routes?: { - /** - * Relative path to application login. - */ - login?: string | false; - - /** - * Relative path to application logout. - */ - logoutPath?: string | false; - - /** - * 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. - */ - postLogoutRedirectUri?: string; - - /** - * Relative path to the application callback to process the response from the authorization server. - */ - callback?: string; - } + callback?: string; + }; } /** * Configuration parameters used for the application session. */ interface AppSessionConfigParams { - /** - * 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. - * Can use env key SESSION_SECRET instead. - */ - secret?: string | Array; - - /** - * String value for the cookie name used for the internal session. - * This value must only include letters, numbers, and underscores. - * Default is `appSession`. - */ - name?: string; - - /** - * Integer value, in seconds, for application session rolling duration. - * Default is 86400 seconds (1 day). - */ - rollingDuration?: number - - /** - * Domain name for the cookie. - */ - cookieDomain?: string; - - /** - * Set to true to use a transient cookie (cookie without an explicit expiration). - * Default is `false` - */ - cookieTransient?: boolean; - - /** - * Flags the cookie to be accessible only by the web server. - * Defaults to `true`. - */ - cookieHttpOnly?: boolean; - - /** - * Path for the cookie. - */ - cookiePath?: string; - - /** - * Marks the cookie to be used over secure channels only. - */ - cookieSecure?: boolean; - - /** - * Value of the SameSite Set-Cookie attribute. - * Defaults to "Lax" but will be adjusted based on response_type. - */ - cookieSameSite?: string; + /** + * 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. + * Can use env key SESSION_SECRET instead. + */ + secret?: string | Array; + + /** + * String value for the cookie name used for the internal session. + * This value must only include letters, numbers, and underscores. + * Default is `appSession`. + */ + name?: string; + + /** + * Integer value, in seconds, for application session rolling duration. + * Default is 86400 seconds (1 day). + */ + rollingDuration?: number; + + /** + * Domain name for the cookie. + */ + cookieDomain?: string; + + /** + * Set to true to use a transient cookie (cookie without an explicit expiration). + * Default is `false` + */ + cookieTransient?: boolean; + + /** + * Flags the cookie to be accessible only by the web server. + * Defaults to `true`. + */ + cookieHttpOnly?: boolean; + + /** + * Path for the cookie. + */ + cookiePath?: string; + + /** + * Marks the cookie to be used over secure channels only. + */ + cookieSecure?: boolean; + + /** + * Value of the SameSite Set-Cookie attribute. + * Defaults to "Lax" but will be adjusted based on response_type. + */ + cookieSameSite?: string; } export function auth(params?: ConfigParams): RequestHandler; diff --git a/index.js b/index.js index 47bd64e5..9a2c039e 100644 --- a/index.js +++ b/index.js @@ -3,5 +3,5 @@ const requiresAuth = require('./middleware/requiresAuth'); module.exports = { auth, - requiresAuth + requiresAuth, }; diff --git a/lib/appSession.js b/lib/appSession.js index 1b80c344..a30fc3ab 100644 --- a/lib/appSession.js +++ b/lib/appSession.js @@ -1,28 +1,33 @@ const { strict: assert, AssertionError } = require('assert'); -const { JWK, JWKS, JWE, errors: { JOSEError } } = require('jose'); +const { + JWK, + JWKS, + JWE, + errors: { JOSEError }, +} = require('jose'); const onHeaders = require('on-headers'); const cookie = require('cookie'); const COOKIES = require('./cookies'); const { encryption: deriveKey } = require('./hkdf'); const debug = require('./debug'); -const epoch = () => Date.now() / 1000 | 0; +const epoch = () => (Date.now() / 1000) | 0; const CHUNK_BYTE_SIZE = 4000; -function attachSessionObject (req, sessionName, value) { +function attachSessionObject(req, sessionName, value) { Object.defineProperty(req, sessionName, { enumerable: true, - get () { + get() { return value; }, - set (arg) { + set(arg) { if (arg === null || arg === undefined) { value = arg; } else { throw new TypeError('session object cannot be reassigned'); } return undefined; - } + }, }); } @@ -31,10 +36,16 @@ module.exports = (config) => { const alg = 'dir'; const enc = 'A256GCM'; - const secrets = Array.isArray(config.secret) ? config.secret : [config.secret]; + const secrets = Array.isArray(config.secret) + ? config.secret + : [config.secret]; const sessionName = config.session.name; const cookieConfig = config.session.cookie; - const { absoluteDuration, rolling: rollingEnabled, rollingDuration } = config.session; + const { + absoluteDuration, + rolling: rollingEnabled, + rollingDuration, + } = config.session; let keystore = new JWKS.KeyStore(); @@ -50,27 +61,36 @@ module.exports = (config) => { keystore = current; } - function encrypt (payload, headers) { + function encrypt(payload, headers) { return JWE.encrypt(payload, current, { alg, enc, ...headers }); } - function decrypt (jwe) { + function decrypt(jwe) { return JWE.decrypt(jwe, keystore, { complete: true, algorithms: [enc] }); } - function calculateExp (iat, uat) { + function calculateExp(iat, uat) { if (!rollingEnabled) { return iat + absoluteDuration; } - return Math.min(...[uat + rollingDuration, iat + absoluteDuration].filter(Boolean)); + return Math.min( + ...[uat + rollingDuration, iat + absoluteDuration].filter(Boolean) + ); } - function setCookie (req, res, { uat = epoch(), iat = uat, exp = calculateExp(iat, uat) }) { + function setCookie( + req, + res, + { uat = epoch(), iat = uat, exp = calculateExp(iat, uat) } + ) { const cookieOptions = { ...cookieConfig, expires: cookieConfig.transient ? 0 : new Date(exp * 1000), - secure: typeof cookieConfig.secure === 'boolean' ? cookieConfig.secure : req.secure, + secure: + typeof cookieConfig.secure === 'boolean' + ? cookieConfig.secure + : req.secure, }; delete cookieOptions.transient; @@ -83,12 +103,19 @@ module.exports = (config) => { } } } else { - const value = encrypt(JSON.stringify(req[sessionName]), { iat, uat, exp }); + const value = encrypt(JSON.stringify(req[sessionName]), { + iat, + uat, + exp, + }); const chunkCount = Math.ceil(value.length / CHUNK_BYTE_SIZE); if (chunkCount > 1) { for (let i = 0; i < chunkCount; i++) { - const chunkValue = value.slice(i * CHUNK_BYTE_SIZE, (i + 1) * CHUNK_BYTE_SIZE); + const chunkValue = value.slice( + i * CHUNK_BYTE_SIZE, + (i + 1) * CHUNK_BYTE_SIZE + ); const chunkCookieName = `${sessionName}.${i}`; res.cookie(chunkCookieName, chunkValue, cookieOptions); } @@ -100,8 +127,14 @@ module.exports = (config) => { return (req, res, next) => { if (req.hasOwnProperty(sessionName)) { - debug.trace(`request object (req) already has ${sessionName} property, this is indicative of a middleware setup problem`); - return next(new Error(`req[${sessionName}] is already set, do you run this middleware twice?`)); + debug.trace( + `request object (req) already has ${sessionName} property, this is indicative of a middleware setup problem` + ); + return next( + new Error( + `req[${sessionName}] is already set, do you run this middleware twice?` + ) + ); } req[COOKIES] = cookie.parse(req.get('cookie') || ''); @@ -112,42 +145,60 @@ module.exports = (config) => { let existingSessionValue; try { - if (req[COOKIES].hasOwnProperty(sessionName)) { // get JWE from unchunked session cookie + if (req[COOKIES].hasOwnProperty(sessionName)) { + // get JWE from unchunked session cookie debug.trace(`reading session from ${sessionName} cookie`); existingSessionValue = req[COOKIES][sessionName]; - } else if (req[COOKIES].hasOwnProperty(`${sessionName}.0`)) { // get JWE from chunked session cookie + } else if (req[COOKIES].hasOwnProperty(`${sessionName}.0`)) { + // get JWE from chunked session cookie // iterate all cookie names // match and filter for the ones that match sessionName. // sort by chunk index // concat debug.trace('reading session chunks'); - existingSessionValue = Object.entries(req[COOKIES]).map(([cookie, value]) => { - const match = cookie.match(`^${sessionName}\\.(\\d+)$`); - if (match) { - return [match[1], value]; - } - }).filter(Boolean).sort(([a], [b]) => { - return parseInt(a, 10) - parseInt(b, 10); - }).map(([i, chunk]) => { - debug.trace(`reading session chunk from ${sessionName}.${i} cookie`); - return chunk; - }).join(''); + existingSessionValue = Object.entries(req[COOKIES]) + .map(([cookie, value]) => { + const match = cookie.match(`^${sessionName}\\.(\\d+)$`); + if (match) { + return [match[1], value]; + } + }) + .filter(Boolean) + .sort(([a], [b]) => { + return parseInt(a, 10) - parseInt(b, 10); + }) + .map(([i, chunk]) => { + debug.trace( + `reading session chunk from ${sessionName}.${i} cookie` + ); + return chunk; + }) + .join(''); } if (existingSessionValue) { const { protected: header, cleartext } = decrypt(existingSessionValue); ({ iat, uat, exp } = header); // check that the existing session isn't expired based on options when it was established - assert(exp > epoch(), 'it is expired based on options when it was established'); + assert( + exp > epoch(), + 'it is expired based on options when it was established' + ); // check that the existing session isn't expired based on current rollingDuration rules if (rollingDuration) { - assert(uat + rollingDuration > epoch(), 'it is expired based on current rollingDuration rules'); + assert( + uat + rollingDuration > epoch(), + 'it is expired based on current rollingDuration rules' + ); } // check that the existing session isn't expired based on current absoluteDuration rules if (absoluteDuration) { - assert(iat + absoluteDuration > epoch(), 'it is expired based on current absoluteDuration rules'); + assert( + iat + absoluteDuration > epoch(), + 'it is expired based on current absoluteDuration rules' + ); } attachSessionObject(req, sessionName, JSON.parse(cleartext)); @@ -156,7 +207,10 @@ module.exports = (config) => { if (err instanceof AssertionError) { debug.trace('existing session was rejected because', err.message); } else if (err instanceof JOSEError) { - debug.trace('existing session was rejected because it could not be decrypted', err); + debug.trace( + 'existing session was rejected because it could not be decrypted', + err + ); } else { debug.trace('unexpected error handling session', err); } diff --git a/lib/client.js b/lib/client.js index 829997de..da182eb8 100644 --- a/lib/client.js +++ b/lib/client.js @@ -9,78 +9,98 @@ const telemetryHeader = { name: 'express-oidc', version: pkg.version, env: { - node: process.version - } + node: process.version, + }, }; -function sortSpaceDelimitedString (string) { +function sortSpaceDelimitedString(string) { return string.split(' ').sort().join(' '); } const getIssuer = memoize((issuer) => Issuer.discover(issuer)); -async function get (config) { +async function get(config) { const defaultHttpOptions = (options) => { options.headers = { ...options.headers, 'User-Agent': `${pkg.name}/${pkg.version}`, - ...(config.enableTelemetry ? { 'Auth0-Client': Buffer.from(JSON.stringify(telemetryHeader)).toString('base64') } : undefined) + ...(config.enableTelemetry + ? { + 'Auth0-Client': Buffer.from( + JSON.stringify(telemetryHeader) + ).toString('base64'), + } + : undefined), }; options.timeout = 5000; return options; }; const applyHttpOptionsCustom = (entity) => { - entity[custom.http_options] = config.httpOptions ? (...args) => config.httpOptions(defaultHttpOptions(...args)) : defaultHttpOptions; + entity[custom.http_options] = config.httpOptions + ? (...args) => config.httpOptions(defaultHttpOptions(...args)) + : defaultHttpOptions; }; applyHttpOptionsCustom(Issuer); const issuer = await getIssuer(config.issuerBaseURL); applyHttpOptionsCustom(issuer); - const issuerTokenAlgs = Array.isArray(issuer.id_token_signing_alg_values_supported) - ? 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.idTokenSigningAlg)) { debug.warn( `ID token algorithm "${config.idTokenSigningAlg}" is not supported by the issuer. ` + - `Supported ID token algorithms are: "${issuerTokenAlgs.join('", "')}".` + `Supported ID token algorithms are: "${issuerTokenAlgs.join('", "')}".` ); } - const configRespType = sortSpaceDelimitedString(config.authorizationParams.response_type); - const issuerRespTypes = Array.isArray(issuer.response_types_supported) ? issuer.response_types_supported : []; + const configRespType = sortSpaceDelimitedString( + config.authorizationParams.response_type + ); + const issuerRespTypes = Array.isArray(issuer.response_types_supported) + ? issuer.response_types_supported + : []; issuerRespTypes.map(sortSpaceDelimitedString); if (!issuerRespTypes.includes(configRespType)) { debug.warn( `Response type "${configRespType}" is not supported by the issuer. ` + - `Supported response types are: "${issuerRespTypes.join('", "')}".` + `Supported response types are: "${issuerRespTypes.join('", "')}".` ); } const configRespMode = config.authorizationParams.response_mode; - const issuerRespModes = Array.isArray(issuer.response_modes_supported) ? issuer.response_modes_supported : []; + const issuerRespModes = Array.isArray(issuer.response_modes_supported) + ? issuer.response_modes_supported + : []; if (configRespMode && !issuerRespModes.includes(configRespMode)) { debug.warn( `Response mode "${configRespMode}" is not supported by the issuer. ` + - `Supported response modes are "${issuerRespModes.join('", "')}".` + `Supported response modes are "${issuerRespModes.join('", "')}".` ); } const client = new issuer.Client({ client_id: config.clientID, client_secret: config.clientSecret, - id_token_signed_response_alg: config.idTokenSigningAlg + id_token_signed_response_alg: config.idTokenSigningAlg, }); applyHttpOptionsCustom(client); client[custom.clock_tolerance] = config.clockTolerance; if (config.idpLogout && !issuer.end_session_endpoint) { - if (config.auth0Logout || url.parse(issuer.issuer).hostname.match('\\.auth0\\.com$')) { + if ( + config.auth0Logout || + url.parse(issuer.issuer).hostname.match('\\.auth0\\.com$') + ) { Object.defineProperty(client, 'endSessionUrl', { - value (params) { + value(params) { const parsedUrl = url.parse(urlJoin(issuer.issuer, '/v2/logout')); parsedUrl.query = { returnTo: params.post_logout_redirect_uri, - client_id: client.client_id + client_id: client.client_id, }; return url.format(parsedUrl); }, diff --git a/lib/config.js b/lib/config.js index b70dd383..fe1bfaf9 100644 --- a/lib/config.js +++ b/lib/config.js @@ -6,83 +6,125 @@ const paramsSchema = Joi.object({ secret: Joi.alternatives([ Joi.string().min(8), Joi.binary().min(8), - Joi.array().items( - Joi.string().min(8), - Joi.binary().min(8) - ) + Joi.array().items(Joi.string().min(8), Joi.binary().min(8)), ]).required(), session: Joi.object({ rolling: Joi.boolean().optional().default(true), - rollingDuration: Joi.when( - Joi.ref('rolling'), - { - is: true, - then: Joi.number().integer().messages({ - 'number.base': '"session.rollingDuration" must be provided an integer value when "session.rolling" is true' - }), - otherwise: Joi.boolean().valid(false).messages({ - 'any.only': '"session.rollingDuration" must be false when "session.rolling" is disabled' - }) - } - ).optional().default((parent) => parent.rolling ? (24 * 60 * 60) : false), // 1 day when rolling is enabled, else false - absoluteDuration: Joi.when( - Joi.ref('rolling'), - { - is: false, - then: Joi.number().integer().messages({ - 'number.base': '"session.absoluteDuration" must be provided an integer value when "session.rolling" is false' - }), - otherwise: Joi.alternatives([ - Joi.number().integer(), - Joi.boolean().valid(false), - ]) - } - ).optional().default(7 * 24 * 60 * 60), // 7 days, + rollingDuration: Joi.when(Joi.ref('rolling'), { + is: true, + then: Joi.number().integer().messages({ + 'number.base': + '"session.rollingDuration" must be provided an integer value when "session.rolling" is true', + }), + otherwise: Joi.boolean().valid(false).messages({ + 'any.only': + '"session.rollingDuration" must be false when "session.rolling" is disabled', + }), + }) + .optional() + .default((parent) => (parent.rolling ? 24 * 60 * 60 : false)), // 1 day when rolling is enabled, else false + absoluteDuration: Joi.when(Joi.ref('rolling'), { + is: false, + then: Joi.number().integer().messages({ + 'number.base': + '"session.absoluteDuration" must be provided an integer value when "session.rolling" is false', + }), + otherwise: Joi.alternatives([ + Joi.number().integer(), + Joi.boolean().valid(false), + ]), + }) + .optional() + .default(7 * 24 * 60 * 60), // 7 days, name: Joi.string().token().optional().default('appSession'), cookie: Joi.object({ domain: Joi.string().optional(), transient: Joi.boolean().optional().default(false), httpOnly: Joi.boolean().optional().default(true), - sameSite: Joi.string().valid('Lax', 'Strict', 'None').optional().default('Lax'), + sameSite: Joi.string() + .valid('Lax', 'Strict', 'None') + .optional() + .default('Lax'), secure: Joi.boolean().optional(), - }).default().unknown(false) - }).default().unknown(false), + }) + .default() + .unknown(false), + }) + .default() + .unknown(false), auth0Logout: Joi.boolean().optional().default(false), authorizationParams: Joi.object({ - response_type: Joi.string().optional().valid('id_token', 'code id_token', 'code').default('id_token'), - scope: Joi.string().optional().pattern(/\bopenid\b/, 'contains openid').default('openid profile email'), - response_mode: Joi.string().optional().when('response_type', { - is: 'code', - then: Joi.valid('query', 'form_post'), - otherwise: Joi.valid('form_post').default('form_post') - }), - }).optional().unknown(true).default(), + response_type: Joi.string() + .optional() + .valid('id_token', 'code id_token', 'code') + .default('id_token'), + scope: Joi.string() + .optional() + .pattern(/\bopenid\b/, 'contains openid') + .default('openid profile email'), + response_mode: Joi.string() + .optional() + .when('response_type', { + is: 'code', + then: Joi.valid('query', 'form_post'), + otherwise: Joi.valid('form_post').default('form_post'), + }), + }) + .optional() + .unknown(true) + .default(), baseURL: Joi.string().uri().required(), clientID: Joi.string().required(), - clientSecret: Joi.string().when( - Joi.ref('authorizationParams.response_type', { adjust: (value) => value && value.includes('code') }), - { - is: true, - then: Joi.string().required().messages({ - 'any.required': '"clientSecret" is required for a response_type that includes code' - }) - } - ).when( - Joi.ref('idTokenSigningAlg', { adjust: (value) => value && value.startsWith('HS') }), - { - is: true, - then: Joi.string().required().messages({ - 'any.required': '"clientSecret" is required for ID tokens with HMAC based algorithms' - }) - } - ), + clientSecret: Joi.string() + .when( + Joi.ref('authorizationParams.response_type', { + adjust: (value) => value && value.includes('code'), + }), + { + is: true, + then: Joi.string().required().messages({ + 'any.required': + '"clientSecret" is required for a response_type that includes code', + }), + } + ) + .when( + Joi.ref('idTokenSigningAlg', { + adjust: (value) => value && value.startsWith('HS'), + }), + { + is: true, + then: Joi.string().required().messages({ + 'any.required': + '"clientSecret" is required for ID tokens with HMAC based algorithms', + }), + } + ), clockTolerance: Joi.number().optional().default(60), enableTelemetry: Joi.boolean().optional().default(true), errorOnRequiredAuth: Joi.boolean().optional().default(false), // TODO: attemptSilentLogin: Joi.boolean().optional().default(false), - getLoginState: Joi.function().optional().default(() => getLoginState), - identityClaimFilter: Joi.array().optional().default(['aud', 'iss', 'iat', 'exp', 'nbf', 'nonce', 'azp', 'auth_time', 's_hash', 'at_hash', 'c_hash']), - idpLogout: Joi.boolean().optional().default((parent) => parent.auth0Logout || false), + getLoginState: Joi.function() + .optional() + .default(() => getLoginState), + identityClaimFilter: Joi.array() + .optional() + .default([ + 'aud', + 'iss', + 'iat', + 'exp', + 'nbf', + 'nonce', + 'azp', + 'auth_time', + 's_hash', + 'at_hash', + 'c_hash', + ]), + idpLogout: Joi.boolean() + .optional() + .default((parent) => parent.auth0Logout || false), idTokenSigningAlg: Joi.string().not('none').optional().default('RS256'), issuerBaseURL: Joi.string().uri().required(), legacySameSiteCookie: Joi.boolean().optional().default(true), @@ -90,26 +132,30 @@ const paramsSchema = Joi.object({ routes: Joi.object({ login: Joi.alternatives([ Joi.string().uri({ relativeOnly: true }), - Joi.boolean().valid(false) + Joi.boolean().valid(false), ]).default('/login'), logout: Joi.alternatives([ Joi.string().uri({ relativeOnly: true }), - Joi.boolean().valid(false) + Joi.boolean().valid(false), ]).default('/logout'), callback: Joi.string().uri({ relativeOnly: true }).default('/callback'), - postLogoutRedirectUri: Joi.string().uri({ allowRelative: true }).default('') //_ TODO: find a better name - }).default().unknown(false) + postLogoutRedirectUri: Joi.string() + .uri({ allowRelative: true }) + .default(''), //_ TODO: find a better name + }) + .default() + .unknown(false), }); module.exports.get = function (params) { - let config = (typeof params === 'object' ? clone(params) : {}); + let config = typeof params === 'object' ? clone(params) : {}; config = { secret: process.env.SECRET, issuerBaseURL: process.env.ISSUER_BASE_URL, baseURL: process.env.BASE_URL, clientID: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET, - ...config + ...config, }; const paramsValidation = paramsSchema.validate(config); diff --git a/lib/context.js b/lib/context.js index cbf03bfa..664681d4 100644 --- a/lib/context.js +++ b/lib/context.js @@ -5,17 +5,16 @@ const { TokenSet } = require('openid-client'); const clone = require('clone'); const { strict: assert } = require('assert'); - const debug = require('./debug'); const { get: getClient } = require('./client'); const { encodeState } = require('../lib/hooks/getLoginState'); const weakRef = require('./weakCache'); -function isExpired () { +function isExpired() { return tokenSet.call(this).expired(); } -function tokenSet () { +function tokenSet() { const contextCache = weakRef(this); const session = contextCache.req[contextCache.config.session.name]; @@ -26,23 +25,35 @@ function tokenSet () { const cachedTokenSet = weakRef(session); if (!('value' in cachedTokenSet)) { - const { id_token, access_token, refresh_token, token_type, expires_at } = session; - cachedTokenSet.value = new TokenSet({ id_token, access_token, refresh_token, token_type, expires_at }); + const { + id_token, + access_token, + refresh_token, + token_type, + expires_at, + } = session; + cachedTokenSet.value = new TokenSet({ + id_token, + access_token, + refresh_token, + token_type, + expires_at, + }); } return cachedTokenSet.value; } class RequestContext { - constructor (config, req, res, next) { + constructor(config, req, res, next) { Object.assign(weakRef(this), { config, req, res, next }); } - isAuthenticated () { + isAuthenticated() { return !!this.idTokenClaims; } - get idToken () { + get idToken() { try { return tokenSet.call(this).id_token; } catch (err) { @@ -50,7 +61,7 @@ class RequestContext { } } - get refreshToken () { + get refreshToken() { try { return tokenSet.call(this).refresh_token; } catch (err) { @@ -58,7 +69,7 @@ class RequestContext { } } - get accessToken () { + get accessToken() { try { const { access_token, token_type, expires_in } = tokenSet.call(this); @@ -66,13 +77,18 @@ class RequestContext { return undefined; } - return { access_token, token_type, expires_in, isExpired: isExpired.bind(this) }; + return { + access_token, + token_type, + expires_in, + isExpired: isExpired.bind(this), + }; } catch (err) { return undefined; } } - get idTokenClaims () { + get idTokenClaims() { try { return clone(tokenSet.call(this).claims()); } catch (err) { @@ -80,12 +96,14 @@ class RequestContext { } } - get user () { + get user() { try { - const { config: { identityClaimFilter } } = weakRef(this); + const { + config: { identityClaimFilter }, + } = weakRef(this); const { idTokenClaims } = this; const user = clone(idTokenClaims); - identityClaimFilter.forEach(claim => { + identityClaimFilter.forEach((claim) => { delete user[claim]; }); return user; @@ -96,20 +114,20 @@ class RequestContext { } class ResponseContext { - constructor (config, req, res, next, transient) { + constructor(config, req, res, next, transient) { Object.assign(weakRef(this), { config, req, res, next, transient }); } - get errorOnRequiredAuth () { + get errorOnRequiredAuth() { return weakRef(this).config.errorOnRequiredAuth; } - getRedirectUri () { + getRedirectUri() { const { config } = weakRef(this); return urlJoin(config.baseURL, config.routes.callback); } - async login (options = {}) { + async login(options = {}) { let { config, req, res, next, transient } = weakRef(this); next = cb(next).once(); const client = await getClient(config); @@ -127,18 +145,21 @@ class ResponseContext { options = { authorizationParams: {}, returnTo, - ...options + ...options, }; // Ensure a redirect_uri, merge in configuration options, then passed-in options. options.authorizationParams = { redirect_uri: this.getRedirectUri(), ...config.authorizationParams, - ...options.authorizationParams + ...options.authorizationParams, }; const transientOpts = { - sameSite: options.authorizationParams.response_mode === 'form_post' ? 'None' : 'Lax' + sameSite: + options.authorizationParams.response_mode === 'form_post' + ? 'None' + : 'Lax', }; const stateValue = await config.getLoginState(req, options); @@ -149,7 +170,9 @@ class ResponseContext { const usePKCE = options.authorizationParams.response_type.includes('code'); if (usePKCE) { - debug.trace('response_type includes code, the authorization request will use PKCE'); + debug.trace( + 'response_type includes code, the authorization request will use PKCE' + ); stateValue.code_verifier = transient.generateCodeVerifier(); } @@ -157,20 +180,33 @@ class ResponseContext { const authParams = { ...options.authorizationParams, nonce: transient.store('nonce', req, res, transientOpts), - state: transient.store('state', req, res, { ...transientOpts, value: encodeState(stateValue) }), - ...(usePKCE ? { - code_challenge: transient.calculateCodeChallenge(transient.store('code_verifier', req, res, transientOpts)), - code_challenge_method: 'S256', - } : undefined) + state: transient.store('state', req, res, { + ...transientOpts, + value: encodeState(stateValue), + }), + ...(usePKCE + ? { + code_challenge: transient.calculateCodeChallenge( + transient.store('code_verifier', req, res, transientOpts) + ), + code_challenge_method: 'S256', + } + : undefined), }; // TODO: validate response_type / response_mode? - assert(/\bopenid\b/.test(authParams.scope), 'scope should contain "openid"'); + assert( + /\bopenid\b/.test(authParams.scope), + 'scope should contain "openid"' + ); // TODO: hook here if (authParams.max_age) { - transient.store('max_age', req, res, { ...transientOpts, value: authParams.max_age }); + transient.store('max_age', req, res, { + ...transientOpts, + value: authParams.max_age, + }); } const authorizationUrl = client.authorizationUrl(authParams); @@ -180,7 +216,7 @@ class ResponseContext { } } - async logout (params = {}) { + async logout(params = {}) { let { config, req, res, next } = weakRef(this); next = cb(next).once(); const client = await getClient(config); @@ -203,7 +239,10 @@ class ResponseContext { } try { - returnURL = client.endSessionUrl({ post_logout_redirect_uri: returnURL, id_token_hint }); + returnURL = client.endSessionUrl({ + post_logout_redirect_uri: returnURL, + id_token_hint, + }); } catch (err) { return next(err); } diff --git a/lib/hkdf.js b/lib/hkdf.js index 3a901230..3bce27a3 100644 --- a/lib/hkdf.js +++ b/lib/hkdf.js @@ -12,6 +12,8 @@ const options = { hash: 'SHA-256' }; * * @see https://tools.ietf.org/html/rfc5869 * -*/ -module.exports.encryption = (secret) => hkdf(secret, BYTE_LENGTH, { info: ENCRYPTION_INFO, ...options }); -module.exports.signing = (secret) => hkdf(secret, BYTE_LENGTH, { info: SIGNING_INFO, ...options }); + */ +module.exports.encryption = (secret) => + hkdf(secret, BYTE_LENGTH, { info: ENCRYPTION_INFO, ...options }); +module.exports.signing = (secret) => + hkdf(secret, BYTE_LENGTH, { info: SIGNING_INFO, ...options }); diff --git a/lib/hooks/getLoginState.js b/lib/hooks/getLoginState.js index c8defeee..f8e1d26e 100644 --- a/lib/hooks/getLoginState.js +++ b/lib/hooks/getLoginState.js @@ -10,7 +10,7 @@ const base64url = require('base64url'); * * @return {object} */ -function defaultState (req, options) { +function defaultState(req, options) { return { returnTo: options.returnTo || req.originalUrl }; } @@ -21,7 +21,7 @@ function defaultState (req, options) { * * @return {string} */ -function encodeState (stateObject = {}) { +function encodeState(stateObject = {}) { // this filters out nonce, code_verifier, and max_age from the state object so that the values are // only stored in its dedicated transient cookie const { nonce, code_verifier, max_age, ...filteredState } = stateObject; // eslint-disable-line no-unused-vars @@ -35,7 +35,7 @@ function encodeState (stateObject = {}) { * * @return {object} */ -function decodeState (stateValue) { +function decodeState(stateValue) { return JSON.parse(base64url.decode(stateValue)); } diff --git a/lib/transientHandler.js b/lib/transientHandler.js index 0ca76662..aa8a049e 100644 --- a/lib/transientHandler.js +++ b/lib/transientHandler.js @@ -5,9 +5,13 @@ const { signing: deriveKey } = require('./hkdf'); const header = { alg: 'HS256', b64: false, crit: ['b64'] }; const getPayload = (cookie, value) => Buffer.from(`${cookie}=${value}`); const flattenedJWSFromCookie = (cookie, value, signature) => ({ - protected: Buffer.from(JSON.stringify(header)).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'), + protected: Buffer.from(JSON.stringify(header)) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'), payload: getPayload(cookie, value), - signature + signature, }); const generateSignature = (cookie, value, key) => { const payload = getPayload(cookie, value); @@ -15,7 +19,11 @@ const generateSignature = (cookie, value, key) => { }; const verifySignature = (cookie, value, signature, keystore) => { try { - return !!JWS.verify(flattenedJWSFromCookie(cookie, value, signature), keystore, { algorithm: 'HS256', crit: ['b64'] }); + return !!JWS.verify( + flattenedJWSFromCookie(cookie, value, signature), + keystore, + { algorithm: 'HS256', crit: ['b64'] } + ); } catch (err) { return false; } @@ -25,7 +33,7 @@ const getCookieValue = (cookie, value, keystore) => { return undefined; } let signature; - ([value, signature] = value.split('.')); + [value, signature] = value.split('.'); if (verifySignature(cookie, value, signature, keystore)) { return value; } @@ -59,7 +67,8 @@ class TransientCookieHandler { } this.currentKey = current; this.keyStore = keystore; - this.secureCookieConfig = session && session.cookie && session.cookie.secure; + this.secureCookieConfig = + session && session.cookie && session.cookie.secure; this.legacySameSiteCookie = legacySameSiteCookie; } @@ -76,18 +85,37 @@ class TransientCookieHandler { * * @return {String} Cookie value that was set. */ - store (key, req, res, { sameSite = 'None', value = this.generateNonce() } = {}) { + store( + key, + req, + res, + { sameSite = 'None', value = this.generateNonce() } = {} + ) { const isSameSiteNone = sameSite === 'None'; - const basicAttr = { httpOnly: true, secure: typeof this.secureCookieConfig === 'boolean' ? this.secureCookieConfig : req.secure }; + const basicAttr = { + httpOnly: true, + secure: + typeof this.secureCookieConfig === 'boolean' + ? this.secureCookieConfig + : req.secure, + }; { const cookieValue = generateCookieValue(key, value, this.currentKey); // Set the cookie with the SameSite attribute and, if needed, the Secure flag. - res.cookie(key, cookieValue, { ...basicAttr, sameSite, secure: isSameSiteNone ? true : basicAttr.secure }); + res.cookie(key, cookieValue, { + ...basicAttr, + sameSite, + secure: isSameSiteNone ? true : basicAttr.secure, + }); } if (isSameSiteNone && this.legacySameSiteCookie) { - const cookieValue = generateCookieValue(`_${key}`, value, this.currentKey); + const cookieValue = generateCookieValue( + `_${key}`, + value, + this.currentKey + ); // Set the fallback cookie with no SameSite or Secure attributes. res.cookie(`_${key}`, cookieValue, basicAttr); } @@ -104,7 +132,7 @@ class TransientCookieHandler { * * @return {String|undefined} Cookie value or undefined if cookie was not found. */ - getOnce (key, req, res) { + getOnce(key, req, res) { if (!req[COOKIES]) { return undefined; } @@ -115,7 +143,11 @@ class TransientCookieHandler { if (this.legacySameSiteCookie) { const fallbackKey = `_${key}`; if (!value) { - value = getCookieValue(fallbackKey, req[COOKIES][fallbackKey], this.keyStore); + value = getCookieValue( + fallbackKey, + req[COOKIES][fallbackKey], + this.keyStore + ); } this.deleteCookie(fallbackKey, res); } @@ -128,7 +160,7 @@ class TransientCookieHandler { * * @return {String} */ - generateNonce () { + generateNonce() { return generators.nonce(); } @@ -148,7 +180,7 @@ class TransientCookieHandler { * * @return {String} */ - calculateCodeChallenge (codeVerifier) { + calculateCodeChallenge(codeVerifier) { return generators.codeChallenge(codeVerifier); } @@ -158,7 +190,7 @@ class TransientCookieHandler { * @param {String} name Cookie name * @param {Object} res Express Response object */ - deleteCookie (name, res) { + deleteCookie(name, res) { res.clearCookie(name); } } diff --git a/middleware/auth.js b/middleware/auth.js index c7569031..c6a1d51f 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -16,15 +16,18 @@ const enforceLeadingSlash = (path) => { }; /** -* Returns a router with two routes /login and /callback -* -* @param {Object} [params] The parameters object; see index.d.ts for types and descriptions. -* -* @returns {express.Router} the router -*/ + * Returns a router with two routes /login and /callback + * + * @param {Object} [params] The parameters object; see index.d.ts for types and descriptions. + * + * @returns {express.Router} the router + */ module.exports = function (params) { const config = getConfig(params); - debug.trace('configuration object processed, resulting configuration:', config); + debug.trace( + 'configuration object processed, resulting configuration:', + config + ); const router = new express.Router(); const transient = new TransientCookieHandler(config); @@ -41,10 +44,8 @@ module.exports = function (params) { if (config.routes.login) { const path = enforceLeadingSlash(config.routes.login); debug.trace(`adding GET ${path} route`); - router.get( - path, - express.urlencoded({ extended: false }), - (req, res) => res.oidc.login({ returnTo: config.baseURL }) + router.get(path, express.urlencoded({ extended: false }), (req, res) => + res.oidc.login({ returnTo: config.baseURL }) ); } else { debug.trace('login handling route not applied'); @@ -54,10 +55,7 @@ module.exports = function (params) { if (config.routes.logout) { const path = enforceLeadingSlash(config.routes.logout); debug.trace(`adding GET ${path} route`); - router.get( - path, - (req, res) => res.oidc.logout() - ); + router.get(path, (req, res) => res.oidc.logout()); } else { debug.trace('logout handling route not applied'); } @@ -74,9 +72,11 @@ module.exports = function (params) { async (req, res, next) => { next = cb(next).once(); - client = client || await getClient(config).catch((err) => { - next(err); - }); + client = + client || + (await getClient(config).catch((err) => { + next(err); + })); if (!client) { return; @@ -90,7 +90,10 @@ module.exports = function (params) { try { const callbackParams = client.callbackParams(req); expectedState = transient.getOnce('state', req, res); - const max_age = parseInt(transient.getOnce('max_age', req, res), 10); + const max_age = parseInt( + transient.getOnce('max_age', req, res), + 10 + ); const code_verifier = transient.getOnce('code_verifier', req, res); const nonce = transient.getOnce('nonce', req, res); @@ -98,7 +101,7 @@ module.exports = function (params) { max_age, code_verifier, nonce, - state: expectedState + state: expectedState, }); } catch (err) { throw createError.BadRequest(err.message); @@ -113,7 +116,7 @@ module.exports = function (params) { access_token: tokenSet.access_token, refresh_token: tokenSet.refresh_token, token_type: tokenSet.token_type, - expires_at: tokenSet.expires_at + expires_at: tokenSet.expires_at, }); next(); @@ -121,22 +124,28 @@ module.exports = function (params) { next(err); } }, - (req, res) => res.redirect(req.openidState.returnTo || config.baseURL) + (req, res) => res.redirect(req.openidState.returnTo || config.baseURL), ]; debug.trace(`adding GET ${path} route`); router.get(path, ...callbackStack); debug.trace(`adding POST ${path} route`); - router.post(path, express.urlencoded({ extended: false }), ...callbackStack); + router.post( + path, + express.urlencoded({ extended: false }), + ...callbackStack + ); } if (config.authRequired) { - debug.trace('authentication is required for all routes this middleware is applied to'); + debug.trace( + 'authentication is required for all routes this middleware is applied to' + ); router.use(requiresAuth()); } else { debug.trace( 'authentication is not required for any of the routes this middleware is applied to ' + - 'see and apply `requiresAuth` middlewares to your protected resources' + 'see and apply `requiresAuth` middlewares to your protected resources' ); } diff --git a/middleware/requiresAuth.js b/middleware/requiresAuth.js index 4a1ba649..ef2fae11 100644 --- a/middleware/requiresAuth.js +++ b/middleware/requiresAuth.js @@ -4,23 +4,31 @@ const debug = require('../lib/debug'); const defaultRequiresLogin = (req) => !req.oidc.isAuthenticated(); /** -* Returns a middleware that checks whether an end-user is authenticated. -* If end-user is not authenticated `res.oidc.login()` is triggered for an HTTP -* request that can perform a redirect. -*/ + * Returns a middleware that checks whether an end-user is authenticated. + * If end-user is not authenticated `res.oidc.login()` is triggered for an HTTP + * request that can perform a redirect. + */ async function requiresLoginMiddleware(requiresLoginCheck, req, res, next) { if (!req.oidc) { - next(new Error('req.oidc is not found, did you include the auth middleware?')); + next( + new Error('req.oidc is not found, did you include the auth middleware?') + ); return; } if (requiresLoginCheck(req)) { if (!res.oidc.errorOnRequiredAuth) { - debug.trace('Authentication requirements not met with errorOnRequiredAuth() returning false, calling res.oidc.login()'); + debug.trace( + 'Authentication requirements not met with errorOnRequiredAuth() returning false, calling res.oidc.login()' + ); return res.oidc.login(); } - debug.trace('Authentication requirements not met with errorOnRequiredAuth() returning true, calling next() with an Unauthorized error'); - next(createError.Unauthorized('Authentication is required for this route.')); + debug.trace( + 'Authentication requirements not met with errorOnRequiredAuth() returning true, calling next() with an Unauthorized error' + ); + next( + createError.Unauthorized('Authentication is required for this route.') + ); return; } @@ -29,18 +37,28 @@ async function requiresLoginMiddleware(requiresLoginCheck, req, res, next) { next(); } -module.exports = function requiresAuth(requiresLoginCheck = defaultRequiresLogin) { +module.exports = function requiresAuth( + requiresLoginCheck = defaultRequiresLogin +) { return requiresLoginMiddleware.bind(undefined, requiresLoginCheck); }; -function checkJSONprimitive (value) { - if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' && value !== null) { +function checkJSONprimitive(value) { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' && + value !== null + ) { throw new TypeError('"expected" must be a string, number, boolean or null'); } } // TODO: find a better name -module.exports.withClaimEqualCheck = function withClaimEqualCheck (claim, expected) { +module.exports.withClaimEqualCheck = function withClaimEqualCheck( + claim, + expected +) { // check that claim is a string value if (typeof claims !== 'string') { throw new TypeError('"claim" must be a string'); @@ -67,7 +85,10 @@ module.exports.withClaimEqualCheck = function withClaimEqualCheck (claim, expect }; // TODO: find a better name -module.exports.withClaimIncluding = function withClaimIncluding (claim, ...expected) { +module.exports.withClaimIncluding = function withClaimIncluding( + claim, + ...expected +) { // check that claim is a string value if (typeof claims !== 'string') { throw new TypeError('"claim" must be a string'); @@ -100,7 +121,7 @@ module.exports.withClaimIncluding = function withClaimIncluding (claim, ...expec }; // TODO: find a better name -module.exports.custom = function custom (func) { +module.exports.custom = function custom(func) { // check that func is a function if (typeof func !== 'function' || func.constructor.name !== 'Function') { throw new TypeError('"function" must be a function'); diff --git a/middleware/unauthorizedHandler.js b/middleware/unauthorizedHandler.js index baafe1e6..85a1f670 100644 --- a/middleware/unauthorizedHandler.js +++ b/middleware/unauthorizedHandler.js @@ -4,7 +4,7 @@ * * This middleware needs to be included after your application * routes. -*/ + */ module.exports = function () { return (err, req, res, next) => { if (err.statusCode === 401) { diff --git a/package-lock.json b/package-lock.json index b5b91be6..c7ea2f28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "express-openid-connect", - "version": "1.0.2", + "version": "2.0.0-beta.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -529,11 +529,23 @@ "integrity": "sha512-4kPlzbljFcsttWEq6aBW0OZe6BDajAmyvr2xknBG92tejQnvdGtT9+kXSZ580DqpxY9qG2xeQVF9Dq0ymUTo5Q==", "dev": true }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, "@types/node": { "version": "13.1.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.7.tgz", "integrity": "sha512-HU0q9GXazqiKwviVxg9SI/+t/nAsGkvLDkIdxz+ObejG2nX6Si00TeLqHMoS+a/1tjH7a8YpKVQwtgHuMQsldg==" }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, "@types/qs": { "version": "6.9.3", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.3.tgz", @@ -665,6 +677,12 @@ "sprintf-js": "~1.0.2" } }, + "array-differ": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz", + "integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==", + "dev": true + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -677,6 +695,18 @@ "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", "dev": true }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "dev": true + }, "asn1": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", @@ -958,6 +988,12 @@ "readdirp": "~3.2.0" } }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -1059,6 +1095,12 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1114,6 +1156,19 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -1268,6 +1323,15 @@ "once": "^1.4.0" } }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, "es-abstract": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", @@ -1455,6 +1519,90 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "execa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-2.1.0.tgz", + "integrity": "sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^3.0.0", + "onetime": "^5.1.0", + "p-finally": "^2.0.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "get-stream": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.1.0.tgz", + "integrity": "sha512-EXr1FOzrzTfGeL0gQdeFEvOMm2mzMOglyiOXSTpPC+iAjAKftbr3jpCMWynogwYnM+eSj9sHGc6wjIcDvYiygw==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "onetime": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", + "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "express": { "version": "4.17.1", "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", @@ -1642,6 +1790,15 @@ "locate-path": "^3.0.0" } }, + "find-versions": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz", + "integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==", + "dev": true, + "requires": { + "semver-regex": "^2.0.0" + } + }, "flat": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.0.tgz", @@ -1973,6 +2130,76 @@ "sshpk": "^1.7.0" } }, + "husky": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/husky/-/husky-4.2.5.tgz", + "integrity": "sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "ci-info": "^2.0.0", + "compare-versions": "^3.6.0", + "cosmiconfig": "^6.0.0", + "find-versions": "^3.2.0", + "opencollective-postinstall": "^2.0.2", + "pkg-dir": "^4.2.0", + "please-upgrade-node": "^3.2.0", + "slash": "^3.0.0", + "which-pm-runs": "^1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -2068,6 +2295,12 @@ "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==", "dev": true }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2374,6 +2607,12 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=" }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", @@ -2443,6 +2682,12 @@ "type-check": "~0.3.2" } }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -2543,6 +2788,12 @@ "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", "dev": true }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -2674,11 +2925,30 @@ } } }, + "mri": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.5.tgz", + "integrity": "sha512-d2RKzMD4JNyHMbnbWnznPaa8vbdlq/4pNZ3IgdaGrVbBhebBsGUUE/6qorTMYNS6TwuH3ilfOlD2bf4Igh8CKg==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "multimatch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-4.0.0.tgz", + "integrity": "sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==", + "dev": true, + "requires": { + "@types/minimatch": "^3.0.3", + "array-differ": "^3.0.0", + "array-union": "^2.1.0", + "arrify": "^2.0.1", + "minimatch": "^3.0.4" + } + }, "mute-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", @@ -2787,6 +3057,23 @@ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" }, + "npm-run-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-3.1.0.tgz", + "integrity": "sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + }, + "dependencies": { + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + } + } + }, "nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", @@ -3090,6 +3377,12 @@ } } }, + "opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "dev": true + }, "openid-client": { "version": "3.15.3", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.15.3.tgz", @@ -3153,6 +3446,12 @@ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=" }, + "p-finally": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz", + "integrity": "sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==", + "dev": true + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -3232,6 +3531,18 @@ "callsites": "^3.0.0" } }, + "parse-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.0.tgz", + "integrity": "sha512-OOY5b7PAEFV0E2Fir1KOkxchnZNCdowAJgQ5NuxjpBKTRP3pQhwkrkxqQjeoKJ+fO7bCpmIZaogI4eZGDMEGOw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1", + "lines-and-columns": "^1.1.6" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3274,6 +3585,12 @@ "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=", "dev": true }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, "pathval": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", @@ -3337,6 +3654,15 @@ } } }, + "please-upgrade-node": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", + "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, + "requires": { + "semver-compare": "^1.0.0" + } + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -3348,6 +3674,68 @@ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=" }, + "prettier": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz", + "integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==", + "dev": true + }, + "pretty-quick": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-2.0.1.tgz", + "integrity": "sha512-y7bJt77XadjUr+P1uKqZxFWLddvj3SKY6EU4BuQtMxmmEFSMpbN132pUWdSG1g1mtUfO0noBvn7wBf0BVeomHg==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "execa": "^2.1.0", + "find-up": "^4.1.0", + "ignore": "^5.1.4", + "mri": "^1.1.4", + "multimatch": "^4.0.0" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "ignore": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", + "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + } + } + }, "process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -3621,6 +4009,18 @@ "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "dev": true }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=", + "dev": true + }, + "semver-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz", + "integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==", + "dev": true + }, "send": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", @@ -3726,6 +4126,12 @@ "supports-color": "^5.5.0" } }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, "slice-ansi": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", @@ -3878,6 +4284,12 @@ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, "strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -4133,6 +4545,12 @@ "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", "dev": true }, + "which-pm-runs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", + "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", + "dev": true + }, "wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", @@ -4224,6 +4642,12 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yaml": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz", + "integrity": "sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==", + "dev": true + }, "yargs": { "version": "13.3.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", diff --git a/package.json b/package.json index fca3eb30..68482647 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,13 @@ "chai-as-promised": "^7.1.1", "eslint": "^5.16.0", "express": "^4.17.1", + "husky": "^4.2.5", "lodash": "^4.17.15", "mocha": "^7.2.0", "nock": "^11.9.1", "nyc": "^15.1.0", + "prettier": "^2.0.5", + "pretty-quick": "^2.0.1", "request": "^2.88.2", "request-promise-native": "^1.0.8", "sinon": "^7.5.0" @@ -54,5 +57,10 @@ }, "engines": { "node": "^10.13.0 || >=12.0.0" + }, + "husky": { + "hooks": { + "pre-commit": "pretty-quick --staged" + } } } diff --git a/test/appSession.tests.js b/test/appSession.tests.js index b4685e1d..d41b8e34 100644 --- a/test/appSession.tests.js +++ b/test/appSession.tests.js @@ -2,7 +2,7 @@ const assert = require('chai').assert; const crypto = require('crypto'); const request = require('request-promise-native').defaults({ simple: false, - resolveWithFullResponse: true + resolveWithFullResponse: true, }); const sinon = require('sinon'); @@ -38,30 +38,46 @@ describe('appSession', () => { it('should not error for malformed sessions', async () => { server = await createServer(appSession(getConfig(defaultConfig))); - const res = await request.get('/session', { baseUrl, json: true, headers: { - cookie: 'appSession=__invalid_identity__' - }}); + const res = await request.get('/session', { + baseUrl, + json: true, + headers: { + cookie: 'appSession=__invalid_identity__', + }, + }); assert.equal(res.statusCode, 200); assert.isEmpty(res.body); }); it('should not error with JWEDecryptionFailed when using old secrets', async () => { - server = await createServer(appSession(getConfig({ - ...defaultConfig, - secret: 'another secret' - }))); - const res = await request.get('/session', { baseUrl, json: true, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); + server = await createServer( + appSession( + getConfig({ + ...defaultConfig, + secret: 'another secret', + }) + ) + ); + const res = await request.get('/session', { + baseUrl, + json: true, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); assert.equal(res.statusCode, 200); assert.isEmpty(res.body); }); it('should get an existing session', async () => { server = await createServer(appSession(getConfig(defaultConfig))); - const res = await request.get('/session', { baseUrl, json: true, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); + const res = await request.get('/session', { + baseUrl, + json: true, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); assert.equal(res.statusCode, 200); assert.equal(res.body.sub, '__test_sub__'); }); @@ -70,16 +86,23 @@ describe('appSession', () => { server = await createServer(appSession(getConfig(defaultConfig))); const jar = request.jar(); const random = crypto.randomBytes(4000).toString('base64'); - await request.post('/session', { baseUrl, jar, json: { - sub: '__test_sub__', - random - }}); - assert.deepEqual(jar.getCookies(baseUrl).map(({ key }) => key), [ 'appSession.0', 'appSession.1' ]); + await request.post('/session', { + baseUrl, + jar, + json: { + sub: '__test_sub__', + random, + }, + }); + assert.deepEqual( + jar.getCookies(baseUrl).map(({ key }) => key), + ['appSession.0', 'appSession.1'] + ); const res = await request.get('/session', { baseUrl, json: true, jar }); assert.equal(res.statusCode, 200); assert.deepEqual(res.body, { sub: '__test_sub__', - random + random, }); }); @@ -87,18 +110,34 @@ describe('appSession', () => { server = await createServer(appSession(getConfig(defaultConfig))); const jar = request.jar(); const random = crypto.randomBytes(4000).toString('base64'); - await request.post('/session', { baseUrl, jar, json: { - sub: '__test_sub__', - random - }}); + await request.post('/session', { + baseUrl, + jar, + json: { + sub: '__test_sub__', + random, + }, + }); const newJar = request.jar(); - jar.getCookies(baseUrl).reverse().forEach(({ key, value }) => newJar.setCookie(`${key}=${value}`, baseUrl)); - assert.deepEqual(newJar.getCookies(baseUrl).map(({ key }) => key), [ 'appSession.1', 'appSession.0' ]); - const res = await request.get('/session', { baseUrl, json: true, jar: newJar }); + jar + .getCookies(baseUrl) + .reverse() + .forEach(({ key, value }) => + newJar.setCookie(`${key}=${value}`, baseUrl) + ); + assert.deepEqual( + newJar.getCookies(baseUrl).map(({ key }) => key), + ['appSession.1', 'appSession.0'] + ); + const res = await request.get('/session', { + baseUrl, + json: true, + jar: newJar, + }); assert.equal(res.statusCode, 200); assert.deepEqual(res.body, { sub: '__test_sub__', - random + random, }); }); @@ -114,18 +153,21 @@ describe('appSession', () => { it('should set the default cookie options', async () => { server = await createServer(appSession(getConfig(defaultConfig))); const jar = request.jar(); - await request.get('/session', { baseUrl, json: true, jar, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); - const [ cookie ] = jar.getCookies(baseUrl); + await request.get('/session', { + baseUrl, + json: true, + jar, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); + const [cookie] = jar.getCookies(baseUrl); assert.deepInclude(cookie, { key: 'appSession', domain: 'localhost', path: '/', httpOnly: true, - extensions: [ - 'SameSite=Lax' - ] + extensions: ['SameSite=Lax'], }); const expDate = new Date(cookie.expires); const now = Date.now(); @@ -133,69 +175,99 @@ describe('appSession', () => { }); it('should set the custom cookie options', async () => { - server = await createServer(appSession(getConfig({ - ...defaultConfig, - session: { - cookie: { - httpOnly: false, - sameSite: 'Strict' - } - } - }))); + server = await createServer( + appSession( + getConfig({ + ...defaultConfig, + session: { + cookie: { + httpOnly: false, + sameSite: 'Strict', + }, + }, + }) + ) + ); const jar = request.jar(); - await request.get('/session', { baseUrl, json: true, jar, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); - const [ cookie ] = jar.getCookies(baseUrl); + await request.get('/session', { + baseUrl, + json: true, + jar, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); + const [cookie] = jar.getCookies(baseUrl); assert.deepInclude(cookie, { key: 'appSession', httpOnly: false, - extensions: [ - 'SameSite=Strict' - ] + extensions: ['SameSite=Strict'], }); }); it('should use a custom cookie name', async () => { - server = await createServer(appSession(getConfig({ - ...defaultConfig, - session: { name: 'customName' } - }))); + server = await createServer( + appSession( + getConfig({ + ...defaultConfig, + session: { name: 'customName' }, + }) + ) + ); const jar = request.jar(); - const res = await request.get('/session', { baseUrl, json: true, jar, headers: { - cookie: `customName=${sessionEncryption.encrypted}` - }}); - const [ cookie ] = jar.getCookies(baseUrl); + const res = await request.get('/session', { + baseUrl, + json: true, + jar, + headers: { + cookie: `customName=${sessionEncryption.encrypted}`, + }, + }); + const [cookie] = jar.getCookies(baseUrl); assert.equal(res.statusCode, 200); assert.equal(cookie.key, 'customName'); }); it('should set an ephemeral cookie', async () => { - server = await createServer(appSession(getConfig({ - ...defaultConfig, - session: { cookie: { transient: true } } - }))); + server = await createServer( + appSession( + getConfig({ + ...defaultConfig, + session: { cookie: { transient: true } }, + }) + ) + ); const jar = request.jar(); - const res = await request.get('/session', { baseUrl, json: true, jar, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); - const [ cookie ] = jar.getCookies(baseUrl); + const res = await request.get('/session', { + baseUrl, + json: true, + jar, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); + const [cookie] = jar.getCookies(baseUrl); assert.equal(res.statusCode, 200); assert.isFalse(cookie.hasOwnProperty('expires')); }); it('should not throw for expired cookies', async () => { - const twoWeeks = 2* 7 * 24 * 60 * 60 * 1000; + const twoWeeks = 2 * 7 * 24 * 60 * 60 * 1000; const clock = sinon.useFakeTimers({ now: Date.now(), - toFake: ['Date'] + toFake: ['Date'], }); server = await createServer(appSession(getConfig(defaultConfig))); const jar = request.jar(); clock.tick(twoWeeks); - const res = await request.get('/session', { baseUrl, json: true, jar, headers: { - cookie: `appSession=${sessionEncryption.encrypted}` - }}); + const res = await request.get('/session', { + baseUrl, + json: true, + jar, + headers: { + cookie: `appSession=${sessionEncryption.encrypted}`, + }, + }); assert.equal(res.statusCode, 200); clock.restore(); }); @@ -207,7 +279,10 @@ describe('appSession', () => { }); const res = await request.get('/session', { baseUrl, json: true }); assert.equal(res.statusCode, 500); - assert.equal(res.body.err.message, 'req[appSession] is already set, do you run this middleware twice?'); + assert.equal( + res.body.err.message, + 'req[appSession] is already set, do you run this middleware twice?' + ); }); it('should throw for reassigning session', async () => { diff --git a/test/auth.tests.js b/test/auth.tests.js index 3d3ae559..47c035c4 100644 --- a/test/auth.tests.js +++ b/test/auth.tests.js @@ -2,7 +2,7 @@ const assert = require('chai').assert; const url = require('url'); const request = require('request-promise-native').defaults({ simple: false, - resolveWithFullResponse: true + resolveWithFullResponse: true, }); const { decodeState } = require('../lib/hooks/getLoginState'); @@ -11,15 +11,16 @@ const { auth } = require('..'); const { create: createServer } = require('./fixture/server'); const filterRoute = (method, path) => { - return r => r.route && - r.route.path === path && - r.route.methods[method.toLowerCase()]; + return (r) => + r.route && r.route.path === path && r.route.methods[method.toLowerCase()]; }; const getCookieFromResponse = (res, cookieName) => { const cookieHeaders = res.headers['set-cookie']; - const foundHeader = cookieHeaders.filter(header => header.substring(0, 6) === cookieName + '=')[0]; + const foundHeader = cookieHeaders.filter( + (header) => header.substring(0, 6) === cookieName + '=' + )[0]; if (!foundHeader) { return false; } @@ -37,11 +38,10 @@ const defaultConfig = { clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://op.example.com', - authRequired: false + authRequired: false, }; describe('auth', () => { - let server; const baseUrl = 'http://localhost:3000'; @@ -66,8 +66,8 @@ describe('auth', () => { routes: { callback: 'custom-callback', login: 'custom-login', - logout: 'custom-logout' - } + logout: 'custom-logout', + }, }); server = await createServer(router); assert.ok(router.stack.some(filterRoute('GET', '/custom-login'))); @@ -97,13 +97,15 @@ describe('auth', () => { }); it('should redirect to the authorize url for /login in code flow', async () => { - server = await createServer(auth({ - ...defaultConfig, - clientSecret: '__test_client_secret__', - authorizationParams: { - response_type: 'code' - } - })); + server = await createServer( + auth({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code', + }, + }) + ); const res = await request.get('/login', { baseUrl, followRedirect: false }); assert.equal(res.statusCode, 302); @@ -125,12 +127,14 @@ describe('auth', () => { }); it('should redirect to the authorize url for /login in id_token flow', async () => { - server = await createServer(auth({ - ...defaultConfig, - authorizationParams: { - response_type: 'id_token' - } - })); + server = await createServer( + auth({ + ...defaultConfig, + authorizationParams: { + response_type: 'id_token', + }, + }) + ); const res = await request.get('/login', { baseUrl, followRedirect: false }); assert.equal(res.statusCode, 302); @@ -148,13 +152,15 @@ describe('auth', () => { }); it('should redirect to the authorize url for /login in hybrid flow', async () => { - server = await createServer(auth({ - ...defaultConfig, - clientSecret: '__test_client_secret__', - authorizationParams: { - response_type: 'code id_token' - } - })); + server = await createServer( + auth({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code id_token', + }, + }) + ); const res = await request.get('/login', { baseUrl, followRedirect: false }); assert.equal(res.statusCode, 302); @@ -172,27 +178,35 @@ describe('auth', () => { }); it('should redirect to the authorize url for custom login route', async () => { - server = await createServer(auth({ - ...defaultConfig, - routes: { - callback: 'custom-callback', - login: 'custom-login', - logout: 'custom-logout' - } - })); - const res = await request.get('/custom-login', { baseUrl, followRedirect: false }); + server = await createServer( + auth({ + ...defaultConfig, + routes: { + callback: 'custom-callback', + login: 'custom-login', + logout: 'custom-logout', + }, + }) + ); + const res = await request.get('/custom-login', { + baseUrl, + followRedirect: false, + }); assert.equal(res.statusCode, 302); const parsed = url.parse(res.headers.location, true); assert.equal(parsed.hostname, 'op.example.com'); assert.equal(parsed.pathname, '/authorize'); - assert.equal(parsed.query.redirect_uri, 'https://example.org/custom-callback'); + assert.equal( + parsed.query.redirect_uri, + 'https://example.org/custom-callback' + ); }); it('should allow custom login route with additional login params', async () => { const router = auth({ ...defaultConfig, - routes: { login: false } + routes: { login: false }, }); router.get('/login', (req, res) => { res.oidc.login({ @@ -200,8 +214,8 @@ describe('auth', () => { authorizationParams: { response_type: 'code', response_mode: 'query', - scope: 'openid email' - } + scope: 'openid email', + }, }); }); server = await createServer(router); @@ -229,28 +243,35 @@ describe('auth', () => { router.get('/login', (req, res) => { res.oidc.login({ authorizationParams: { - scope: 'email' - } + scope: 'email', + }, }); }); server = await createServer(router); const cookieJar = request.jar(); - const res = await request.get('/login', { cookieJar, baseUrl, json: true, followRedirect: false }); + const res = await request.get('/login', { + cookieJar, + baseUrl, + json: true, + followRedirect: false, + }); assert.equal(res.statusCode, 500); assert.equal(res.body.err.message, 'scope should contain "openid"'); }); it('should use a custom state builder', async () => { - server = await createServer(auth({ - ...defaultConfig, - getLoginState: (req, opts) => { - return { - returnTo: opts.returnTo + '/custom-page', - customProp: '__test_custom_prop__' - }; - } - })); + server = await createServer( + auth({ + ...defaultConfig, + getLoginState: (req, opts) => { + return { + returnTo: opts.returnTo + '/custom-page', + customProp: '__test_custom_prop__', + }; + }, + }) + ); const res = await request.get('/login', { baseUrl, followRedirect: false }); assert.equal(res.statusCode, 302); @@ -262,13 +283,15 @@ describe('auth', () => { }); it('should use PKCE when response_type includes code', async () => { - server = await createServer(auth({ - ...defaultConfig, - clientSecret: '__test_client_secret__', - authorizationParams: { - response_type: 'code id_token' - } - })); + server = await createServer( + auth({ + ...defaultConfig, + clientSecret: '__test_client_secret__', + authorizationParams: { + response_type: 'code id_token', + }, + }) + ); const res = await request.get('/login', { baseUrl, followRedirect: false }); assert.equal(res.statusCode, 302); @@ -279,5 +302,4 @@ describe('auth', () => { assert.isDefined(getCookieFromResponse(res, 'code_verifier')); }); - }); diff --git a/test/callback.tests.js b/test/callback.tests.js index bd499c6f..08362173 100644 --- a/test/callback.tests.js +++ b/test/callback.tests.js @@ -3,7 +3,7 @@ const sinon = require('sinon'); const jose = require('jose'); const request = require('request-promise-native').defaults({ simple: false, - resolveWithFullResponse: true + resolveWithFullResponse: true, }); const TransientCookieHandler = require('../lib/transientHandler'); @@ -19,13 +19,17 @@ const baseUrl = 'http://localhost:3000'; let server; const setup = async (params) => { - const authOpts = Object.assign({}, { - secret: '__test_session_secret__', - clientID: clientID, - baseURL: 'https://example.org', - issuerBaseURL: 'https://op.example.com', - authRequired: false - }, params.authOpts || {}); + const authOpts = Object.assign( + {}, + { + secret: '__test_session_secret__', + clientID: clientID, + baseURL: 'https://example.org', + issuerBaseURL: 'https://op.example.com', + authRequired: false, + }, + params.authOpts || {} + ); const router = expressOpenid.auth(authOpts); const transient = new TransientCookieHandler(authOpts); @@ -36,13 +40,18 @@ const setup = async (params) => { Object.keys(params.cookies).forEach(function (cookieName) { let value; - transient.store(cookieName, {}, { - cookie(key, ...args) { - if (key === cookieName) { - value = args[0]; - } - } - }, { value: params.cookies[cookieName]}); + transient.store( + cookieName, + {}, + { + cookie(key, ...args) { + if (key === cookieName) { + value = args[0]; + } + }, + }, + { value: params.cookies[cookieName] } + ); jar.setCookie( `${cookieName}=${value}; Max-Age=3600; Path=/; HttpOnly;`, @@ -50,9 +59,11 @@ const setup = async (params) => { ); }); - const { interceptors: [ interceptor ] } = nock('https://op.example.com', { allowUnmocked: true }) + const { + interceptors: [interceptor], + } = nock('https://op.example.com', { allowUnmocked: true }) .post('/oauth/token') - .reply(200, function(uri, requestBody) { + .reply(200, function (uri, requestBody) { tokenReqHeader = this.req.headers; tokenReqBody = requestBody; return { @@ -60,31 +71,53 @@ const setup = async (params) => { refresh_token: '__test_refresh_token__', id_token: params.body.id_token, token_type: 'Bearer', - expires_in: 86400 + expires_in: 86400, }; }); - const response = await request.post('/callback', { baseUrl, jar, json: params.body }); - const currentUser = await request.get('/user', { baseUrl, jar, json: true }).then(r => r.body); - const tokens = await request.get('/tokens', { baseUrl, jar, json: true }).then(r => r.body); + const response = await request.post('/callback', { + baseUrl, + jar, + json: params.body, + }); + const currentUser = await request + .get('/user', { baseUrl, jar, json: true }) + .then((r) => r.body); + const tokens = await request + .get('/tokens', { baseUrl, jar, json: true }) + .then((r) => r.body); nock.removeInterceptor(interceptor); - return { baseUrl, jar, response, currentUser, tokenReqHeader, tokenReqBody, tokens }; + return { + baseUrl, + jar, + response, + currentUser, + tokenReqHeader, + tokenReqBody, + tokens, + }; }; -function makeIdToken (payload) { - payload = Object.assign({ - nickname: '__test_nickname__', - sub: '__test_sub__', - iss: 'https://op.example.com/', - aud: clientID, - iat: Math.round(Date.now() / 1000), - exp: Math.round(Date.now() / 1000) + 60000, - nonce: '__test_nonce__' - }, payload); - - return jose.JWT.sign(payload, cert.key, { algorithm: 'RS256', header: { kid: cert.kid } }); +function makeIdToken(payload) { + payload = Object.assign( + { + nickname: '__test_nickname__', + sub: '__test_sub__', + iss: 'https://op.example.com/', + aud: clientID, + iat: Math.round(Date.now() / 1000), + exp: Math.round(Date.now() / 1000) + 60000, + nonce: '__test_nonce__', + }, + payload + ); + + return jose.JWT.sign(payload, cert.key, { + algorithm: 'RS256', + header: { kid: cert.kid }, + }); } // For the purpose of this test the fake SERVER returns the error message in the body directly @@ -92,7 +125,6 @@ function makeIdToken (payload) { // http://expressjs.com/en/guide/error-handling.html describe('callback response_mode: form_post', () => { - afterEach(() => { if (server) { server.close(); @@ -100,116 +132,161 @@ describe('callback response_mode: form_post', () => { }); it('should error when the body is empty', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { nonce: '__test_nonce__', - state: '__test_state__' + state: '__test_state__', }, - body: true + body: true, }); assert.equal(statusCode, 400); assert.equal(err.message, 'state missing from the response'); }); it('should error when the state is missing', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: {}, body: { state: '__test_state__', - id_token: '__invalid_token__' - } + id_token: '__invalid_token__', + }, }); assert.equal(statusCode, 400); assert.equal(err.message, 'checks.state argument is missing'); }); - it('should error when state doesn\'t match', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + it("should error when state doesn't match", async () => { + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { nonce: '__test_nonce__', - state: '__valid_state__' + state: '__valid_state__', }, body: { - state: '__invalid_state__' - } + state: '__invalid_state__', + }, }); assert.equal(statusCode, 400); assert.match(err.message, /state mismatch/i); }); - it('should error when id_token can\'t be parsed', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + it("should error when id_token can't be parsed", async () => { + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { nonce: '__test_nonce__', - state: '__test_state__' + state: '__test_state__', }, body: { state: '__test_state__', - id_token: '__invalid_token__' - } + id_token: '__invalid_token__', + }, }); assert.equal(statusCode, 400); - assert.equal(err.message, 'failed to decode JWT (JWTMalformed: JWTs must have three components)'); + assert.equal( + err.message, + 'failed to decode JWT (JWTMalformed: JWTs must have three components)' + ); }); it('should error when id_token has invalid alg', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { nonce: '__test_nonce__', - state: '__test_state__' + state: '__test_state__', }, body: { state: '__test_state__', - id_token: jose.JWT.sign({ sub: '__test_sub__' }, 'secret', { algorithm: 'HS256' }) - } + id_token: jose.JWT.sign({ sub: '__test_sub__' }, 'secret', { + algorithm: 'HS256', + }), + }, }); assert.equal(statusCode, 400); assert.match(err.message, /unexpected JWT alg received/i); }); it('should error when id_token is missing issuer', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { nonce: '__test_nonce__', - state: '__test_state__' + state: '__test_state__', }, body: { state: '__test_state__', - id_token: makeIdToken({ iss: undefined }) - } + id_token: makeIdToken({ iss: undefined }), + }, }); assert.equal(statusCode, 400); assert.match(err.message, /missing required JWT property iss/i); }); it('should error when nonce is missing from cookies', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ cookies: { - state: '__test_state__' + state: '__test_state__', }, body: { state: '__test_state__', - id_token: makeIdToken() - } + id_token: makeIdToken(), + }, }); assert.equal(statusCode, 400); assert.match(err.message, /nonce mismatch/i); }); it('should error when legacy samesite fallback is off', async () => { - const { response: { statusCode, body: { err } } } = await setup({ + const { + response: { + statusCode, + body: { err }, + }, + } = await setup({ authOpts: { // Do not check the fallback cookie value. - legacySameSiteCookie: false + legacySameSiteCookie: false, }, cookies: { // Only set the fallback cookie value. - _state: '__test_state__' + _state: '__test_state__', }, body: { state: '__test_state__', - id_token: '__invalid_token__' - } + id_token: '__invalid_token__', + }, }); assert.equal(statusCode, 400); assert.equal(err.message, 'checks.state argument is missing'); @@ -218,16 +295,16 @@ describe('callback response_mode: form_post', () => { it('should not strip claims when using custom claim filtering', async () => { const { currentUser } = await setup({ authOpts: { - identityClaimFilter: [] + identityClaimFilter: [], }, cookies: { _state: expectedDefaultState, - _nonce: '__test_nonce__' + _nonce: '__test_nonce__', }, body: { state: expectedDefaultState, - id_token: makeIdToken() - } + id_token: makeIdToken(), + }, }); assert.equal(currentUser.iss, 'https://op.example.com/'); assert.equal(currentUser.aud, clientID); @@ -238,15 +315,19 @@ describe('callback response_mode: form_post', () => { it('should expose the id token when id_token is valid', async () => { const idToken = makeIdToken(); - const { response: { statusCode, headers }, currentUser, tokens } = await setup({ + const { + response: { statusCode, headers }, + currentUser, + tokens, + } = await setup({ cookies: { _state: expectedDefaultState, - _nonce: '__test_nonce__' + _nonce: '__test_nonce__', }, body: { state: expectedDefaultState, - id_token: idToken - } + id_token: idToken, + }, }); assert.equal(statusCode, 302); assert.equal(headers.location, 'https://example.org'); @@ -263,13 +344,13 @@ describe('callback response_mode: form_post', () => { assert.isUndefined(tokens.refreshToken); assert.isUndefined(tokens.accessToken); assert.include(tokens.idTokenClaims, { - sub: '__test_sub__' + sub: '__test_sub__', }); }); - it('should expose all tokens when id_token is valid and response_type is \'code id_token\'', async () => { + it("should expose all tokens when id_token is valid and response_type is 'code id_token'", async () => { const idToken = makeIdToken({ - c_hash: '77QmUPtjPfzWtF2AnpK9RQ' + c_hash: '77QmUPtjPfzWtF2AnpK9RQ', }); const { tokens } = await setup({ @@ -278,18 +359,18 @@ describe('callback response_mode: form_post', () => { authorizationParams: { response_type: 'code id_token', audience: 'https://api.example.com/', - scope: 'openid profile email read:reports offline_access' - } + scope: 'openid profile email read:reports offline_access', + }, }, cookies: { _state: expectedDefaultState, - _nonce: '__test_nonce__' + _nonce: '__test_nonce__', }, body: { state: expectedDefaultState, id_token: idToken, code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y', - } + }, }); assert.equal(tokens.isAuthenticated, true); @@ -297,15 +378,15 @@ describe('callback response_mode: form_post', () => { assert.equal(tokens.refreshToken, '__test_refresh_token__'); assert.include(tokens.accessToken, { access_token: '__test_access_token__', - token_type: 'Bearer' + token_type: 'Bearer', }); assert.include(tokens.idTokenClaims, { - sub: '__test_sub__' + sub: '__test_sub__', }); }); it('should handle access token expiry', async () => { - const clock = sinon.useFakeTimers({ toFake: ['Date']}); + const clock = sinon.useFakeTimers({ toFake: ['Date'] }); const idToken = makeIdToken({ c_hash: '77QmUPtjPfzWtF2AnpK9RQ', }); @@ -317,32 +398,36 @@ describe('callback response_mode: form_post', () => { clientSecret: '__test_client_secret__', authorizationParams: { response_type: 'code', - } + }, }, cookies: { _state: expectedDefaultState, - _nonce: '__test_nonce__' + _nonce: '__test_nonce__', }, body: { state: expectedDefaultState, id_token: idToken, code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y', - } + }, }); assert.equal(tokens.accessToken.expires_in, 24 * hrSecs); clock.tick(4 * hrMs); - const tokens2 = await request.get('/tokens', { baseUrl, jar, json: true }).then(r => r.body); + const tokens2 = await request + .get('/tokens', { baseUrl, jar, json: true }) + .then((r) => r.body); assert.equal(tokens2.accessToken.expires_in, 20 * hrSecs); assert.isFalse(tokens2.accessTokenExpired); clock.tick(21 * hrMs); - const tokens3 = await request.get('/tokens', { baseUrl, jar, json: true }).then(r => r.body); + const tokens3 = await request + .get('/tokens', { baseUrl, jar, json: true }) + .then((r) => r.body); assert.isTrue(tokens3.accessTokenExpired); clock.restore(); }); it('should use basic auth on token endpoint when using code flow', async () => { const idToken = makeIdToken({ - c_hash: '77QmUPtjPfzWtF2AnpK9RQ' + c_hash: '77QmUPtjPfzWtF2AnpK9RQ', }); const { tokenReqBody, tokenReqHeader } = await setup({ @@ -351,22 +436,28 @@ describe('callback response_mode: form_post', () => { authorizationParams: { response_type: 'code id_token', audience: 'https://api.example.com/', - scope: 'openid profile email read:reports offline_access' - } + scope: 'openid profile email read:reports offline_access', + }, }, cookies: { _state: expectedDefaultState, - _nonce: '__test_nonce__' + _nonce: '__test_nonce__', }, body: { state: expectedDefaultState, id_token: idToken, code: 'jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y', - } + }, }); - const credentials = Buffer.from(tokenReqHeader.authorization.replace('Basic ', ''), 'base64'); + const credentials = Buffer.from( + tokenReqHeader.authorization.replace('Basic ', ''), + 'base64' + ); assert.equal(credentials, '__test_client_id__:__test_client_secret__'); - assert.match(tokenReqBody, /code=jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y/); + assert.match( + tokenReqBody, + /code=jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y/ + ); }); }); diff --git a/test/client.tests.js b/test/client.tests.js index 55768e0c..32aa837a 100644 --- a/test/client.tests.js +++ b/test/client.tests.js @@ -20,7 +20,7 @@ describe('client initialization', function () { clientID: '__test_client_id__', clientSecret: '__test_client_secret__', issuerBaseURL: 'https://op.example.com', - baseURL: 'https://example.org' + baseURL: 'https://example.org', }); let client; @@ -34,28 +34,40 @@ describe('client initialization', function () { }); it('should send the correct default headers', async function () { - const headers = await client.introspect('__test_token__', '__test_hint__'); + const headers = await client.introspect( + '__test_token__', + '__test_hint__' + ); const headerProps = Object.getOwnPropertyNames(headers); assert.include(headerProps, 'auth0-client'); - const decodedTelemetry = JSON.parse(Buffer.from(headers['auth0-client'], 'base64').toString('ascii')); + const decodedTelemetry = JSON.parse( + Buffer.from(headers['auth0-client'], 'base64').toString('ascii') + ); assert.equal('express-oidc', decodedTelemetry.name); assert.equal(pkg.version, decodedTelemetry.version); assert.equal(process.version, decodedTelemetry.env.node); assert.include(headerProps, 'user-agent'); - assert.equal(`express-openid-connect/${pkg.version}`, headers['user-agent']); + assert.equal( + `express-openid-connect/${pkg.version}`, + headers['user-agent'] + ); }); it('should not strip new headers', async function () { - const response = await client.requestResource('https://op.example.com/introspection', 'token', { - method: 'POST', - headers: { - Authorization: 'Bearer foo' + const response = await client.requestResource( + 'https://op.example.com/introspection', + 'token', + { + method: 'POST', + headers: { + Authorization: 'Bearer foo', + }, } - }); + ); const headerProps = Object.getOwnPropertyNames(JSON.parse(response.body)); assert.include(headerProps, 'authorization'); @@ -69,15 +81,18 @@ describe('client initialization', function () { clientSecret: '__test_client_secret__', issuerBaseURL: 'https://test-too.auth0.com', baseURL: 'https://example.org', - idTokenSigningAlg: 'RS256' + idTokenSigningAlg: 'RS256', }); it('should prefer user configuration regardless of idP discovery', async function () { nock('https://test-too.auth0.com') .get('/.well-known/openid-configuration') - .reply(200, Object.assign({}, wellKnown, { - id_token_signing_alg_values_supported: ['none', 'RS256'] - })); + .reply( + 200, + Object.assign({}, wellKnown, { + id_token_signing_alg_values_supported: ['none', 'RS256'], + }) + ); const client = await getClient(config); assert.equal(client.id_token_signed_response_alg, 'RS256'); diff --git a/test/config.tests.js b/test/config.tests.js index 5700c11b..8d5cc25e 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -6,13 +6,13 @@ const defaultConfig = { secret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://op.example.com', - baseURL: 'https://example.org' + baseURL: 'https://example.org', }; -const validateAuthorizationParams = (authorizationParams) => getConfig({ ...defaultConfig, authorizationParams }); +const validateAuthorizationParams = (authorizationParams) => + getConfig({ ...defaultConfig, authorizationParams }); describe('get config', () => { - afterEach(() => sinon.restore()); it('should get config for default config', () => { @@ -21,9 +21,9 @@ describe('get config', () => { authorizationParams: { response_type: 'id_token', response_mode: 'form_post', - scope: 'openid profile email' + scope: 'openid profile email', }, - authRequired: true + authRequired: true, }); }); @@ -40,9 +40,9 @@ describe('get config', () => { authorizationParams: { response_type: 'id_token', response_mode: 'form_post', - scope: 'openid profile email' + scope: 'openid profile email', }, - authRequired: true + authRequired: true, }); }); @@ -51,34 +51,38 @@ describe('get config', () => { ...defaultConfig, clientSecret: '__test_client_secret__', authorizationParams: { - response_type: 'code' - } + response_type: 'code', + }, }); assert.deepInclude(config, { authorizationParams: { response_type: 'code', - scope: 'openid profile email' + scope: 'openid profile email', }, - authRequired: true + authRequired: true, }); }); it('should require a fully qualified URL for issuer', () => { const config = { ...defaultConfig, - issuerBaseURL: 'www.example.com' + issuerBaseURL: 'www.example.com', }; - assert.throws(() => getConfig(config), TypeError, '"issuerBaseURL" must be a valid uri'); + assert.throws( + () => getConfig(config), + TypeError, + '"issuerBaseURL" must be a valid uri' + ); }); it('should set idpLogout to true when auth0Logout is true', () => { const config = getConfig({ ...defaultConfig, - auth0Logout: true + auth0Logout: true, }); assert.include(config, { auth0Logout: true, - idpLogout: true + idpLogout: true, }); }); @@ -86,18 +90,18 @@ describe('get config', () => { const config = getConfig(defaultConfig); assert.include(config, { auth0Logout: false, - idpLogout: false + idpLogout: false, }); }); it('should not set auth0Logout to true when idpLogout is true', () => { const config = getConfig({ ...defaultConfig, - idpLogout: true + idpLogout: true, }); assert.include(config, { auth0Logout: false, - idpLogout: true + idpLogout: true, }); }); @@ -116,8 +120,8 @@ describe('get config', () => { routes: { callback: '/custom-callback', login: '/custom-login', - logout: '/custom-logout' - } + logout: '/custom-logout', + }, }); assert.include(config.routes, { callback: '/custom-callback', @@ -135,7 +139,7 @@ describe('get config', () => { sameSite: 'Lax', httpOnly: true, transient: false, - } + }, }); }); @@ -151,9 +155,9 @@ describe('get config', () => { transient: true, httpOnly: false, secure: true, - sameSite: 'Strict' - } - } + sameSite: 'Strict', + }, + }, }); assert.deepInclude(config, { secret: ['__test_session_secret_1__', '__test_session_secret_2__'], @@ -168,8 +172,8 @@ describe('get config', () => { httpOnly: false, secure: true, sameSite: 'Strict', - } - } + }, + }, }); }); @@ -215,15 +219,18 @@ describe('get config', () => { ...defaultConfig, secret: '__test_session_secret__', session: { - rollingDuration: 3.14159 - } + rollingDuration: 3.14159, + }, }); }, '"session.rollingDuration" must be an integer'); }); it('should fail when app session secret is invalid', function () { assert.throws(() => { - getConfig(({ ...defaultConfig, secret: { key: '__test_session_secret__' } })); + getConfig({ + ...defaultConfig, + secret: { key: '__test_session_secret__' }, + }); }, '"secret" must be one of [string, binary, array]'); }); @@ -234,9 +241,9 @@ describe('get config', () => { secret: '__test_session_secret__', session: { cookie: { - httpOnly: '__invalid_httponly__' - } - } + httpOnly: '__invalid_httponly__', + }, + }, }); }, '"session.cookie.httpOnly" must be a boolean'); }); @@ -248,9 +255,9 @@ describe('get config', () => { secret: '__test_session_secret__', session: { cookie: { - secure: '__invalid_secure__' - } - } + secure: '__invalid_secure__', + }, + }, }); }, '"session.cookie.secure" must be a boolean'); }); @@ -262,9 +269,9 @@ describe('get config', () => { secret: '__test_session_secret__', session: { cookie: { - sameSite: '__invalid_samesite__' - } - } + sameSite: '__invalid_samesite__', + }, + }, }); }, '"session.cookie.sameSite" must be one of [Lax, Strict, None]'); }); @@ -276,37 +283,57 @@ describe('get config', () => { secret: '__test_session_secret__', session: { cookie: { - domain: false - } - } + domain: false, + }, + }, }); }, '"session.cookie.domain" must be a string'); }); - it('shouldn\'t allow a secret of less than 8 chars', () => { - assert.throws(() => getConfig({ ...defaultConfig, secret: 'short' }), TypeError, '"secret" does not match any of the allowed types'); - assert.throws(() => getConfig({ ...defaultConfig, secret: ['short', 'too'] }), TypeError, '"secret[0]" does not match any of the allowed types'); - assert.throws(() => getConfig({ ...defaultConfig, secret: Buffer.from('short') }), TypeError, '"secret" must be at least 8 bytes'); - }); - - it('shouldn\'t allow code flow without clientSecret', () => { + it("shouldn't allow a secret of less than 8 chars", () => { + assert.throws( + () => getConfig({ ...defaultConfig, secret: 'short' }), + TypeError, + '"secret" does not match any of the allowed types' + ); + assert.throws( + () => getConfig({ ...defaultConfig, secret: ['short', 'too'] }), + TypeError, + '"secret[0]" does not match any of the allowed types' + ); + assert.throws( + () => getConfig({ ...defaultConfig, secret: Buffer.from('short') }), + TypeError, + '"secret" must be at least 8 bytes' + ); + }); + + it("shouldn't allow code flow without clientSecret", () => { const config = { ...defaultConfig, authorizationParams: { - response_type: 'code' - } + response_type: 'code', + }, }; - assert.throws(() => getConfig(config), TypeError, '"clientSecret" is required for a response_type that includes code'); + assert.throws( + () => getConfig(config), + TypeError, + '"clientSecret" is required for a response_type that includes code' + ); }); - it('shouldn\'t allow hybrid flow without clientSecret', () => { + it("shouldn't allow hybrid flow without clientSecret", () => { const config = { ...defaultConfig, authorizationParams: { - response_type: 'code id_token' - } + response_type: 'code id_token', + }, }; - assert.throws(() => getConfig(config), TypeError, '"clientSecret" is required for a response_type that includes code'); + assert.throws( + () => getConfig(config), + TypeError, + '"clientSecret" is required for a response_type that includes code' + ); }); it('should require clientSecret for ID tokens with HMAC based algorithms', () => { @@ -314,10 +341,14 @@ describe('get config', () => { ...defaultConfig, idTokenSigningAlg: 'HS256', authorizationParams: { - response_type: 'id_token' - } + response_type: 'id_token', + }, }; - assert.throws(() => getConfig(config), TypeError, '"clientSecret" is required for ID tokens with HMAC based algorithms'); + assert.throws( + () => getConfig(config), + TypeError, + '"clientSecret" is required for ID tokens with HMAC based algorithms' + ); }); it('should require clientSecret for ID tokens in hybrid flow with HMAC based algorithms', () => { @@ -325,10 +356,14 @@ describe('get config', () => { ...defaultConfig, idTokenSigningAlg: 'HS256', authorizationParams: { - response_type: 'code id_token' - } + response_type: 'code id_token', + }, }; - assert.throws(() => getConfig(config), TypeError, '"clientSecret" is required for ID tokens with HMAC based algorithms'); + assert.throws( + () => getConfig(config), + TypeError, + '"clientSecret" is required for ID tokens with HMAC based algorithms' + ); }); it('should require clientSecret for ID tokens in code flow with HMAC based algorithms', () => { @@ -336,10 +371,14 @@ describe('get config', () => { ...defaultConfig, idTokenSigningAlg: 'HS256', authorizationParams: { - response_type: 'code' - } + response_type: 'code', + }, }; - assert.throws(() => getConfig(config), TypeError, '"clientSecret" is required for ID tokens with HMAC based algorithms'); + assert.throws( + () => getConfig(config), + TypeError, + '"clientSecret" is required for ID tokens with HMAC based algorithms' + ); }); it('should allow empty auth params', () => { @@ -348,63 +387,145 @@ describe('get config', () => { }); it('should not allow empty scope', () => { - assert.throws(() => validateAuthorizationParams({ scope: null }), TypeError, '"authorizationParams.scope" must be a string'); - assert.throws(() => validateAuthorizationParams({ scope: '' }), TypeError, '"authorizationParams.scope" is not allowed to be empty'); + assert.throws( + () => validateAuthorizationParams({ scope: null }), + TypeError, + '"authorizationParams.scope" must be a string' + ); + assert.throws( + () => validateAuthorizationParams({ scope: '' }), + TypeError, + '"authorizationParams.scope" is not allowed to be empty' + ); }); it('should not allow scope without openid', () => { - assert.throws(() => validateAuthorizationParams({ scope: 'profile email' }), TypeError, '"authorizationParams.scope" with value "profile email" fails to match the contains openid pattern'); + assert.throws( + () => validateAuthorizationParams({ scope: 'profile email' }), + TypeError, + '"authorizationParams.scope" with value "profile email" fails to match the contains openid pattern' + ); }); it('should allow scope with openid', () => { - assert.doesNotThrow(() => validateAuthorizationParams({ scope: 'openid read:users' })); - assert.doesNotThrow(() => validateAuthorizationParams({ scope: 'read:users openid' })); - assert.doesNotThrow(() => validateAuthorizationParams({ scope: 'read:users openid profile email' })); + assert.doesNotThrow(() => + validateAuthorizationParams({ scope: 'openid read:users' }) + ); + assert.doesNotThrow(() => + validateAuthorizationParams({ scope: 'read:users openid' }) + ); + assert.doesNotThrow(() => + validateAuthorizationParams({ scope: 'read:users openid profile email' }) + ); }); it('should not allow empty response_type', () => { - assert.throws(() => validateAuthorizationParams({ response_type: null }), TypeError, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); - assert.throws(() => validateAuthorizationParams({ response_type: '' }), TypeError, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); + assert.throws( + () => validateAuthorizationParams({ response_type: null }), + TypeError, + '"authorizationParams.response_type" must be one of [id_token, code id_token, code]' + ); + assert.throws( + () => validateAuthorizationParams({ response_type: '' }), + TypeError, + '"authorizationParams.response_type" must be one of [id_token, code id_token, code]' + ); }); it('should not allow invalid response_types', () => { - assert.throws(() => validateAuthorizationParams({ response_type: 'foo' }), TypeError, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); - assert.throws(() => validateAuthorizationParams({ response_type: 'foo id_token' }), TypeError, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); - assert.throws(() => validateAuthorizationParams({ response_type: 'id_token code' }), TypeError, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); + assert.throws( + () => validateAuthorizationParams({ response_type: 'foo' }), + TypeError, + '"authorizationParams.response_type" must be one of [id_token, code id_token, code]' + ); + assert.throws( + () => validateAuthorizationParams({ response_type: 'foo id_token' }), + TypeError, + '"authorizationParams.response_type" must be one of [id_token, code id_token, code]' + ); + assert.throws( + () => validateAuthorizationParams({ response_type: 'id_token code' }), + TypeError, + '"authorizationParams.response_type" must be one of [id_token, code id_token, code]' + ); }); it('should allow valid response_types', () => { const config = (authorizationParams) => ({ ...defaultConfig, clientSecret: 'foo', - authorizationParams + authorizationParams, }); - assert.doesNotThrow(() => validateAuthorizationParams({ response_type: 'id_token' })); + assert.doesNotThrow(() => + validateAuthorizationParams({ response_type: 'id_token' }) + ); assert.doesNotThrow(() => config({ response_type: 'code id_token' })); assert.doesNotThrow(() => config({ response_type: 'code' })); }); it('should not allow empty response_mode', () => { - assert.throws(() => validateAuthorizationParams({ response_mode: null }), TypeError, '"authorizationParams.response_mode" must be [form_post]'); - assert.throws(() => validateAuthorizationParams({ response_mode: '' }), TypeError, '"authorizationParams.response_mode" must be [form_post]'); - assert.throws(() => validateAuthorizationParams({ response_type: 'code', response_mode: '' }), TypeError, '"authorizationParams.response_mode" must be one of [query, form_post]'); + assert.throws( + () => validateAuthorizationParams({ response_mode: null }), + TypeError, + '"authorizationParams.response_mode" must be [form_post]' + ); + assert.throws( + () => validateAuthorizationParams({ response_mode: '' }), + TypeError, + '"authorizationParams.response_mode" must be [form_post]' + ); + assert.throws( + () => + validateAuthorizationParams({ + response_type: 'code', + response_mode: '', + }), + TypeError, + '"authorizationParams.response_mode" must be one of [query, form_post]' + ); }); it('should not allow response_type id_token and response_mode query', () => { - assert.throws(() => validateAuthorizationParams({ response_type: 'id_token', response_mode: 'query' }), TypeError, '"authorizationParams.response_mode" must be [form_post]'); - assert.throws(() => validateAuthorizationParams({ response_type: 'code id_token', response_mode: 'query' }), TypeError, '"authorizationParams.response_mode" must be [form_post]'); + assert.throws( + () => + validateAuthorizationParams({ + response_type: 'id_token', + response_mode: 'query', + }), + TypeError, + '"authorizationParams.response_mode" must be [form_post]' + ); + assert.throws( + () => + validateAuthorizationParams({ + response_type: 'code id_token', + response_mode: 'query', + }), + TypeError, + '"authorizationParams.response_mode" must be [form_post]' + ); }); it('should allow valid response_type response_mode combinations', () => { const config = (authorizationParams) => ({ ...defaultConfig, clientSecret: 'foo', - authorizationParams + authorizationParams, }); - assert.doesNotThrow(() => config({ response_type: 'code', response_mode: 'query' })); - assert.doesNotThrow(() => config({ response_type: 'code', response_mode: 'form_post' })); - assert.doesNotThrow(() => validateAuthorizationParams({ response_type: 'id_token', response_mode: 'form_post' })); - assert.doesNotThrow(() => config({ response_type: 'code id_token', response_mode: 'form_post' })); + assert.doesNotThrow(() => + config({ response_type: 'code', response_mode: 'query' }) + ); + assert.doesNotThrow(() => + config({ response_type: 'code', response_mode: 'form_post' }) + ); + assert.doesNotThrow(() => + validateAuthorizationParams({ + response_type: 'id_token', + response_mode: 'form_post', + }) + ); + assert.doesNotThrow(() => + config({ response_type: 'code id_token', response_mode: 'form_post' }) + ); }); - }); diff --git a/test/fixture/cert.js b/test/fixture/cert.js index 4fa01ef0..945300d7 100644 --- a/test/fixture/cert.js +++ b/test/fixture/cert.js @@ -2,16 +2,23 @@ const jose = require('jose'); const key = jose.JWK.asKey({ e: 'AQAB', - n: 'wQrThQ9HKf8ksCQEzqOu0ofF8DtLJgexeFSQBNnMQetACzt4TbHPpjhTWUIlD8bFCkyx88d2_QV3TewMtfS649Pn5hV6adeYW2TxweAA8HVJxskcqTSa_ktojQ-cD43HIStsbqJhHoFv0UY6z5pwJrVPT-yt38ciKo9Oc9IhEl6TSw-zAnuNW0zPOhKjuiIqpAk1lT3e6cYv83ahx82vpx3ZnV83dT9uRbIbcgIpK4W64YnYb5uDH7hGI8-4GnalZDfdApTu-9Y8lg_1v5ul-eQDsLCkUCPkqBaNiCG3gfZUAKp9rrFRE_cJTv_MJn-y_XSTMWILvTY7vdSMRMo4kQ', - d: 'EMHY1K8b1VhxndyykiGBVoM0uoLbJiT60eA9VD53za0XNSJncg8iYGJ5UcE9KF5v0lIQDIJfIN2tmpUIEW96HbbSZZWtt6xgbGaZ2eOREU6NJfVlSIbpgXOYUs5tFKiRBZ8YXY448gX4Z-k5x7W3UJTimqSH_2nw3FLuU32FI2vtf4ToUKEcoUdrIqoAwZ1et19E7Q_NCG2y1nez0LpD8PKgfeX1OVHdQm7434-9FS-R_eMcxqZ6mqZO2QDuign8SPHTR-KooAe8B-0MpZb7QF3YtMSQk8RlrMUcAYwv8R8dvFergCjauH0hOHvtKPq6Smj0VuimelEUZfp94r3pBQ', - p: '9i2D_PLFPnFfztYccTGxzgiXezRpMsXD2Z9PA7uxw0sXnkV1TjZkSc3V_59RxyiTtvYlNCbGYShds__ogXouuYqbWaC43_zj3eGqAWL3i5C-k1u4S3ekgKn8AkGjlqCObuyLRsPvDfBkv1wo2tfIAEoNg_sHYIIRkTq68g58if8', - q: 'yL6UUD_MB_pCHwf6LvNC2k0lfHHOxfW3lOo_XTqt9dg9yTO21OS4BF7Uce1kFJJIfuGrK6cMmusHKkSsJm1_khR3G9owokrBDFOZ_iSWvt3qIG5K3CNgl1_C8NqTeyKEVziCCiaL9CZpwfqHIVNnDCchGNkpVRqsfHmzPEnXnW8', - dp: 'rFf3FEn9rpZ-pXYeGVzaBszbCAUMNOBhGWS_U3S-oWNb2JD169iGY2j4DWpDPTN6Hle6egU_UtuIpjBdXO_l8D1KPvgXFbCc8kQ-2ZOojAu8b7uBjUvoXa8jX40Gcrhanut5IgSfwlluns1tSLBSM2mkhqZiZr0IgWzlXfqoU48', - dq: 'kihQC-2nO9e19Kn2OeDbt92bgXPLPM6ej0nOQK7MocaDlc6VO4QbhvMUcq6Iw4GOTvM3kVzbDKA6Y0gEnyXyUAWegyTlbARJchQcdrFlICqqoFotHwKS_SO352z9HBYRjP-TjphqJaUiMx2Y7WawDGUg79qNAW2eUDK7kRWiavk', - qi: '8hAW25CmPjLAXpzkMpXpXsvJKdgql0Zjt-OeSVwzQN5dLYmu-Q98Xl5n8H-Nfr8aOmPfHBQ8M9FOMpxbgg8gbqixpkrxcTIGjpuH8RFYXj_0TYSBkCSOoc7tAP7YjOUOGJMqFHDYZVD-gmsCuRwWx3jKFxRrWLS5b8kWzkON0bM', + n: + 'wQrThQ9HKf8ksCQEzqOu0ofF8DtLJgexeFSQBNnMQetACzt4TbHPpjhTWUIlD8bFCkyx88d2_QV3TewMtfS649Pn5hV6adeYW2TxweAA8HVJxskcqTSa_ktojQ-cD43HIStsbqJhHoFv0UY6z5pwJrVPT-yt38ciKo9Oc9IhEl6TSw-zAnuNW0zPOhKjuiIqpAk1lT3e6cYv83ahx82vpx3ZnV83dT9uRbIbcgIpK4W64YnYb5uDH7hGI8-4GnalZDfdApTu-9Y8lg_1v5ul-eQDsLCkUCPkqBaNiCG3gfZUAKp9rrFRE_cJTv_MJn-y_XSTMWILvTY7vdSMRMo4kQ', + d: + 'EMHY1K8b1VhxndyykiGBVoM0uoLbJiT60eA9VD53za0XNSJncg8iYGJ5UcE9KF5v0lIQDIJfIN2tmpUIEW96HbbSZZWtt6xgbGaZ2eOREU6NJfVlSIbpgXOYUs5tFKiRBZ8YXY448gX4Z-k5x7W3UJTimqSH_2nw3FLuU32FI2vtf4ToUKEcoUdrIqoAwZ1et19E7Q_NCG2y1nez0LpD8PKgfeX1OVHdQm7434-9FS-R_eMcxqZ6mqZO2QDuign8SPHTR-KooAe8B-0MpZb7QF3YtMSQk8RlrMUcAYwv8R8dvFergCjauH0hOHvtKPq6Smj0VuimelEUZfp94r3pBQ', + p: + '9i2D_PLFPnFfztYccTGxzgiXezRpMsXD2Z9PA7uxw0sXnkV1TjZkSc3V_59RxyiTtvYlNCbGYShds__ogXouuYqbWaC43_zj3eGqAWL3i5C-k1u4S3ekgKn8AkGjlqCObuyLRsPvDfBkv1wo2tfIAEoNg_sHYIIRkTq68g58if8', + q: + 'yL6UUD_MB_pCHwf6LvNC2k0lfHHOxfW3lOo_XTqt9dg9yTO21OS4BF7Uce1kFJJIfuGrK6cMmusHKkSsJm1_khR3G9owokrBDFOZ_iSWvt3qIG5K3CNgl1_C8NqTeyKEVziCCiaL9CZpwfqHIVNnDCchGNkpVRqsfHmzPEnXnW8', + dp: + 'rFf3FEn9rpZ-pXYeGVzaBszbCAUMNOBhGWS_U3S-oWNb2JD169iGY2j4DWpDPTN6Hle6egU_UtuIpjBdXO_l8D1KPvgXFbCc8kQ-2ZOojAu8b7uBjUvoXa8jX40Gcrhanut5IgSfwlluns1tSLBSM2mkhqZiZr0IgWzlXfqoU48', + dq: + 'kihQC-2nO9e19Kn2OeDbt92bgXPLPM6ej0nOQK7MocaDlc6VO4QbhvMUcq6Iw4GOTvM3kVzbDKA6Y0gEnyXyUAWegyTlbARJchQcdrFlICqqoFotHwKS_SO352z9HBYRjP-TjphqJaUiMx2Y7WawDGUg79qNAW2eUDK7kRWiavk', + qi: + '8hAW25CmPjLAXpzkMpXpXsvJKdgql0Zjt-OeSVwzQN5dLYmu-Q98Xl5n8H-Nfr8aOmPfHBQ8M9FOMpxbgg8gbqixpkrxcTIGjpuH8RFYXj_0TYSBkCSOoc7tAP7YjOUOGJMqFHDYZVD-gmsCuRwWx3jKFxRrWLS5b8kWzkON0bM', kty: 'RSA', use: 'sig', - alg: 'RS256' + alg: 'RS256', }); module.exports.jwks = new jose.JWKS.KeyStore(key).toJWKS(false); diff --git a/test/fixture/server.js b/test/fixture/server.js index 50eadb5f..0decf3a2 100644 --- a/test/fixture/server.js +++ b/test/fixture/server.js @@ -33,7 +33,9 @@ module.exports.create = function (router, protect, path) { idToken: req.oidc.idToken, refreshToken: req.oidc.refreshToken, accessToken: req.oidc.accessToken, - accessTokenExpired: req.oidc.accessToken ? req.oidc.accessToken.isExpired() : undefined, + accessTokenExpired: req.oidc.accessToken + ? req.oidc.accessToken.isExpired() + : undefined, idTokenClaims: req.oidc.idTokenClaims, }); }); @@ -46,8 +48,7 @@ module.exports.create = function (router, protect, path) { // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { - res.status(err.status || 500) - .json({ err: { message: err.message } }); + res.status(err.status || 500).json({ err: { message: err.message } }); }); let mainApp; diff --git a/test/fixture/sessionEncryption.js b/test/fixture/sessionEncryption.js index 058833b5..6f0e6d94 100644 --- a/test/fixture/sessionEncryption.js +++ b/test/fixture/sessionEncryption.js @@ -1,7 +1,7 @@ const { JWK, JWE } = require('jose'); const { encryption: deriveKey } = require('../../lib/hkdf'); -const epoch = () => Date.now() / 1000 | 0; +const epoch = () => (Date.now() / 1000) | 0; const key = JWK.asKey(deriveKey('__test_secret__')); const payload = JSON.stringify({ sub: '__test_sub__' }); @@ -13,13 +13,16 @@ const encryptOpts = { enc: 'A256GCM', uat: epochNow, iat: epochNow, - exp: epochNow + weekInSeconds + exp: epochNow + weekInSeconds, }; const jwe = JWE.encrypt(payload, key, encryptOpts); -const { cleartext } = JWE.decrypt(jwe, key, { complete: true, algorithms: [encryptOpts.enc] }); +const { cleartext } = JWE.decrypt(jwe, key, { + complete: true, + algorithms: [encryptOpts.enc], +}); module.exports = { encrypted: jwe, - decrypted: cleartext + decrypted: cleartext, }; diff --git a/test/fixture/well-known.json b/test/fixture/well-known.json index 4c628336..41c3e537 100644 --- a/test/fixture/well-known.json +++ b/test/fixture/well-known.json @@ -8,13 +8,57 @@ "registration_endpoint": "https://op.example.com/oidc/register", "revocation_endpoint": "https://op.example.com/oauth/revoke", "introspection_endpoint": "https://op.example.com/introspection", - "scopes_supported": ["openid", "profile", "offline_access", "name", "given_name", "family_name", "nickname", "email", "email_verified", "picture", "created_at", "identities", "phone", "address"], - "response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token", "none"], + "scopes_supported": [ + "openid", + "profile", + "offline_access", + "name", + "given_name", + "family_name", + "nickname", + "email", + "email_verified", + "picture", + "created_at", + "identities", + "phone", + "address" + ], + "response_types_supported": [ + "code", + "token", + "id_token", + "code token", + "code id_token", + "token id_token", + "code token id_token", + "none" + ], "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["HS256", "RS256"], - "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"], - "claims_supported": ["aud", "auth_time", "created_at", "email", "email_verified", "exp", "family_name", "given_name", "iat", "identities", "iss", "name", "nickname", "phone_number", "picture", "sub"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post" + ], + "claims_supported": [ + "aud", + "auth_time", + "created_at", + "email", + "email_verified", + "exp", + "family_name", + "given_name", + "iat", + "identities", + "iss", + "name", + "nickname", + "phone_number", + "picture", + "sub" + ], "request_uri_parameter_supported": false, "end_session_endpoint": "https://op.example.com/session/end" } diff --git a/test/invalid_response_type.tests.js b/test/invalid_response_type.tests.js index 6e4f1093..42c6fe3b 100644 --- a/test/invalid_response_type.tests.js +++ b/test/invalid_response_type.tests.js @@ -10,8 +10,8 @@ describe('with an unsupported response type', function () { baseURL: 'https://example.org', issuerBaseURL: 'https://op.example.com', authorizationParams: { - response_type: '__invalid_response_type__' - } + response_type: '__invalid_response_type__', + }, }); }, '"authorizationParams.response_type" must be one of [id_token, code id_token, code]'); }); diff --git a/test/logout.tests.js b/test/logout.tests.js index 0b4eb05f..3e9352c8 100644 --- a/test/logout.tests.js +++ b/test/logout.tests.js @@ -4,7 +4,7 @@ const { auth } = require('./..'); const request = require('request-promise-native').defaults({ simple: false, - resolveWithFullResponse: true + resolveWithFullResponse: true, }); const defaultConfig = { @@ -12,7 +12,7 @@ const defaultConfig = { baseURL: 'https://example.org', issuerBaseURL: 'https://op.example.com', secret: '__test_session_secret__', - authRequired: false + authRequired: false, }; const login = async (baseUrl = 'http://localhost:3000') => { @@ -20,18 +20,29 @@ const login = async (baseUrl = 'http://localhost:3000') => { await request.post({ uri: '/session', json: { - id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' + id_token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', }, - baseUrl, jar + baseUrl, + jar, }); - const session = (await request.get({uri: '/session', baseUrl, jar, json: true })).body; + const session = ( + await request.get({ uri: '/session', baseUrl, jar, json: true }) + ).body; return { jar, session }; }; const logout = async (jar, baseUrl = 'http://localhost:3000') => { - const response = await request.get({uri: '/logout', baseUrl, jar, followRedirect: false}); - const session = (await request.get({uri: '/session', baseUrl, jar, json: true})).body; + const response = await request.get({ + uri: '/logout', + baseUrl, + jar, + followRedirect: false, + }); + const session = ( + await request.get({ uri: '/session', baseUrl, jar, json: true }) + ).body; return { response, session }; }; @@ -45,68 +56,94 @@ describe('logout route', async () => { }); it('should perform a local logout', async () => { - server = await createServer(auth({ - ...defaultConfig, - idpLogout: false, - })); + server = await createServer( + auth({ + ...defaultConfig, + idpLogout: false, + }) + ); const { jar, session: loggedInSession } = await login(); assert.ok(loggedInSession.id_token); const { response, session: loggedOutSession } = await logout(jar); assert.notOk(loggedOutSession.id_token); assert.equal(response.statusCode, 302); - assert.include(response.headers, { - location: 'https://example.org' - }, 'should redirect to the base url'); + assert.include( + response.headers, + { + location: 'https://example.org', + }, + 'should redirect to the base url' + ); }); it('should perform a distributed logout', async () => { - server = await createServer(auth({ - ...defaultConfig, - idpLogout: true, - })); + server = await createServer( + auth({ + ...defaultConfig, + idpLogout: true, + }) + ); const { jar } = await login(); const { response, session: loggedOutSession } = await logout(jar); assert.notOk(loggedOutSession.id_token); assert.equal(response.statusCode, 302); - assert.include(response.headers, { - location: 'https://op.example.com/session/end?post_logout_redirect_uri=https%3A%2F%2Fexample.org&id_token_hint=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c' - }, 'should redirect to the identity provider'); + assert.include( + response.headers, + { + location: + 'https://op.example.com/session/end?post_logout_redirect_uri=https%3A%2F%2Fexample.org&id_token_hint=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + }, + 'should redirect to the identity provider' + ); }); it('should perform an auth0 logout', async () => { - server = await createServer(auth({ - ...defaultConfig, - issuerBaseURL: 'https://test.eu.auth0.com', - idpLogout: true, - auth0Logout: true, - })); + server = await createServer( + auth({ + ...defaultConfig, + issuerBaseURL: 'https://test.eu.auth0.com', + idpLogout: true, + auth0Logout: true, + }) + ); const { jar } = await login(); const { response, session: loggedOutSession } = await logout(jar); assert.notOk(loggedOutSession.id_token); assert.equal(response.statusCode, 302); - assert.include(response.headers, { - location: 'https://op.example.com/v2/logout?returnTo=https%3A%2F%2Fexample.org&client_id=__test_client_id__' - }, 'should redirect to the identity provider'); + assert.include( + response.headers, + { + location: + 'https://op.example.com/v2/logout?returnTo=https%3A%2F%2Fexample.org&client_id=__test_client_id__', + }, + 'should redirect to the identity provider' + ); }); it('should redirect to postLogoutRedirectUri', async () => { - server = await createServer(auth({ - ...defaultConfig, - routes: { - postLogoutRedirectUri: '/after-logout-in-auth-config', - }, - })); + server = await createServer( + auth({ + ...defaultConfig, + routes: { + postLogoutRedirectUri: '/after-logout-in-auth-config', + }, + }) + ); const { jar } = await login(); const { response, session: loggedOutSession } = await logout(jar); assert.notOk(loggedOutSession.id_token); assert.equal(response.statusCode, 302); - assert.include(response.headers, { - location: 'https://example.org/after-logout-in-auth-config' - }, 'should redirect to postLogoutRedirectUri'); + assert.include( + response.headers, + { + location: 'https://example.org/after-logout-in-auth-config', + }, + 'should redirect to postLogoutRedirectUri' + ); }); it('should logout when under a sub path', async () => { diff --git a/test/requiresAuth.tests.js b/test/requiresAuth.tests.js index ae0f71e8..bae7dad7 100644 --- a/test/requiresAuth.tests.js +++ b/test/requiresAuth.tests.js @@ -4,7 +4,7 @@ const { auth, requiresAuth } = require('./..'); const request = require('request-promise-native').defaults({ simple: false, resolveWithFullResponse: true, - followRedirect: false + followRedirect: false, }); const defaultConfig = { @@ -15,7 +15,6 @@ const defaultConfig = { }; describe('requiresAuth', () => { - let server; const baseUrl = 'http://localhost:3000'; @@ -26,12 +25,15 @@ describe('requiresAuth', () => { }); it('should ask anonymous user to login when visiting a protected route', async () => { - server = await createServer(auth({ - ...defaultConfig, - authRequired: false - }), requiresAuth()); + server = await createServer( + auth({ + ...defaultConfig, + authRequired: false, + }), + requiresAuth() + ); const response = await request({ baseUrl, url: '/protected' }); - const state = (new URL(response.headers.location)).searchParams.get('state'); + const state = new URL(response.headers.location).searchParams.get('state'); const decoded = Buffer.from(state, 'base64'); const parsed = JSON.parse(decoded); @@ -41,11 +43,14 @@ describe('requiresAuth', () => { }); it('should return 401 when anonymous user visits a protected route', async () => { - server = await createServer(auth({ - ...defaultConfig, - authRequired: false, - errorOnRequiredAuth: true - }), requiresAuth()); + server = await createServer( + auth({ + ...defaultConfig, + authRequired: false, + errorOnRequiredAuth: true, + }), + requiresAuth() + ); const response = await request({ baseUrl, url: '/protected' }); assert.equal(response.statusCode, 401); @@ -53,7 +58,12 @@ describe('requiresAuth', () => { it('should throw when no auth middleware', async () => { server = await createServer(null, requiresAuth()); - const { body: { err } } = await request({ baseUrl, url: '/protected', json: true }); - assert.equal(err.message, 'req.oidc is not found, did you include the auth middleware?'); + const { + body: { err }, + } = await request({ baseUrl, url: '/protected', json: true }); + assert.equal( + err.message, + 'req.oidc is not found, did you include the auth middleware?' + ); }); }); diff --git a/test/transientHandler.tests.js b/test/transientHandler.tests.js index 920af825..a3bebb58 100644 --- a/test/transientHandler.tests.js +++ b/test/transientHandler.tests.js @@ -14,10 +14,16 @@ describe('transientHandler', function () { let generateSignature; beforeEach(async function () { - transientHandler = new TransientCookieHandler({ secret, legacySameSiteCookie: true }); - generateSignature = (cookie, value) => JWS.sign.flattened( - Buffer.from(`${cookie}=${value}`), transientHandler.keyStore, { alg: 'HS256', b64: false, crit: ['b64'] } - ).signature; + transientHandler = new TransientCookieHandler({ + secret, + legacySameSiteCookie: true, + }); + generateSignature = (cookie, value) => + JWS.sign.flattened( + Buffer.from(`${cookie}=${value}`), + transientHandler.keyStore, + { alg: 'HS256', b64: false, crit: ['b64'] } + ).signature; res = { cookie: sinon.spy(), clearCookie: sinon.spy() }; }); @@ -36,16 +42,20 @@ describe('transientHandler', function () { }); it('should use the req.secure property to automatically set cookies secure when on https', function () { - transientHandler.store('test_key', { secure: true }, res, { sameSite: 'Lax' }); - transientHandler.store('test_key', { secure: false }, res, { sameSite: 'Lax' }); + transientHandler.store('test_key', { secure: true }, res, { + sameSite: 'Lax', + }); + transientHandler.store('test_key', { secure: false }, res, { + sameSite: 'Lax', + }); sinon.assert.calledWithMatch(res.cookie.firstCall, 'test_key', '', { sameSite: 'Lax', - secure: true + secure: true, }); sinon.assert.calledWithMatch(res.cookie.secondCall, 'test_key', '', { sameSite: 'Lax', - secure: false + secure: false, }); }); @@ -55,17 +65,20 @@ describe('transientHandler', function () { sinon.assert.calledWithMatch(res.cookie, 'test_key', '', { sameSite: 'None', secure: true, - httpOnly: true + httpOnly: true, }); sinon.assert.calledWithMatch(res.cookie, '_test_key', '', { sameSite: undefined, secure: undefined, - httpOnly: true + httpOnly: true, }); }); it('should turn off fallback', function () { - transientHandler = new TransientCookieHandler({ secret, legacySameSiteCookie: false }); + transientHandler = new TransientCookieHandler({ + secret, + legacySameSiteCookie: false, + }); transientHandler.store('test_key', {}, res); sinon.assert.calledWith(res.cookie, 'test_key'); @@ -76,13 +89,15 @@ describe('transientHandler', function () { transientHandler.store('test_key', {}, res, { sameSite: 'Lax' }); sinon.assert.calledWithMatch(res.cookie, 'test_key', '', { - sameSite: 'Lax' + sameSite: 'Lax', }); sinon.assert.calledOnce(res.cookie); }); it('should use the passed-in value', function () { - const value = transientHandler.store('test_key', {}, res, { value: '__test_value__' }); + const value = transientHandler.store('test_key', {}, res, { + value: '__test_value__', + }); assert.equal('__test_value__', value); const re = /^__test_value__\./; sinon.assert.calledWithMatch(res.cookie, 'test_key', re); @@ -92,14 +107,16 @@ describe('transientHandler', function () { describe('getOnce()', function () { it('should return undefined if there are no cookies', function () { - assert.isUndefined(transientHandler.getOnce('test_key', reqWithCookies(), res)); + assert.isUndefined( + transientHandler.getOnce('test_key', reqWithCookies(), res) + ); }); it('should return main value and delete both cookies by default', function () { const signature = generateSignature('test_key', 'foo'); const cookies = { test_key: `foo.${signature}`, - _test_key: `foo.${signature}` + _test_key: `foo.${signature}`, }; const req = reqWithCookies(cookies); const value = transientHandler.getOnce('test_key', req, res); @@ -111,7 +128,9 @@ describe('transientHandler', function () { }); it('should return fallback value and delete both cookies if main value not present', function () { - const cookies = { _test_key: `foo.${generateSignature('_test_key', 'foo')}` }; + const cookies = { + _test_key: `foo.${generateSignature('_test_key', 'foo')}`, + }; const req = reqWithCookies(cookies); const value = transientHandler.getOnce('test_key', req, res); @@ -125,10 +144,13 @@ describe('transientHandler', function () { const signature = generateSignature('test_key', 'foo'); const cookies = { test_key: `foo.${signature}`, - _test_key: `foo.${signature}` + _test_key: `foo.${signature}`, }; const req = reqWithCookies(cookies); - transientHandler = new TransientCookieHandler({ secret, legacySameSiteCookie: false }); + transientHandler = new TransientCookieHandler({ + secret, + legacySameSiteCookie: false, + }); const value = transientHandler.getOnce('test_key', req, res); assert.equal(value, 'foo'); @@ -137,10 +159,10 @@ describe('transientHandler', function () { sinon.assert.calledOnce(res.clearCookie); }); - it('should not throw when it can\'t verify the signature', function () { + it("should not throw when it can't verify the signature", function () { const cookies = { test_key: 'foo.bar', - _test_key: 'foo.bar' + _test_key: 'foo.bar', }; const req = reqWithCookies(cookies); const value = transientHandler.getOnce('test_key', req, res);