Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

attemptSilentLogin feature #121

Merged
merged 3 commits into from
Aug 7, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ interface ResponseContext {
* });
* ```
*/
login: (opts: LoginOptions) => Promise<void>;
login: (opts?: LoginOptions) => Promise<void>;

/**
* Provided by default via the `/logout` route. Call this to override or have other
Expand All @@ -152,7 +152,7 @@ interface ResponseContext {
* });
* ```
*/
logout: (opts: LogoutOptions) => Promise<void>;
logout: (opts?: LogoutOptions) => Promise<void>;
}

/**
Expand Down Expand Up @@ -296,6 +296,16 @@ interface ConfigParams {
*/
errorOnRequiredAuth?: boolean;

/**
* Attempt silent login (`prompt: 'none'`) on the first unauthenticated route the user visits.
* For protected routes this can be useful if your Identity Provider does not default to
* `prompt: 'none'` and you'd like to attempt this before requiring the user to interact with a login prompt.
* For unprotected routes this can be useful if you want to check the user's logged in state on their IDP, to
* show them a login/logout button for example.
* Default is `false`
*/
attemptSilentLogin?: boolean;

/**
* Function that returns an object with URL-safe state values for `res.oidc.login()`.
* Used for passing custom state parameters to your authorization server.
Expand Down Expand Up @@ -558,3 +568,21 @@ export function claimIncludes(
export function claimCheck(
checkFn: (req: OpenidRequest, claims: IdTokenClaims) => boolean
): RequestHandler;

/**
* Use this MW to attempt silent login (`prompt=none`) but not require authentication.
*
* See {@link ConfigParams.attemptSilentLogin attemptSilentLogin}
*
* ```js
* const { attemptSilentLogin } = require('express-openid-connect');
*
* app.get('/', attemptSilentLogin(), (req, res) => {
* res.render('homepage', {
* isAuthenticated: req.isAuthenticated() // show a login or logout button
* });
* });
*
* ```
*/
export function attemptSilentLogin(): RequestHandler;
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const auth = require('./middleware/auth');
const requiresAuth = require('./middleware/requiresAuth');
const attemptSilentLogin = require('./middleware/attemptSilentLogin');

module.exports = {
auth,
...requiresAuth,
attemptSilentLogin,
};
2 changes: 1 addition & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ const paramsSchema = Joi.object({
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),
attemptSilentLogin: Joi.boolean().optional().default(false),
getLoginState: Joi.function()
.optional()
.default(() => getLoginState),
Expand Down
10 changes: 10 additions & 0 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { strict: assert } = require('assert');
const debug = require('./debug')('context');
const { get: getClient } = require('./client');
const { encodeState } = require('../lib/hooks/getLoginState');
const { cancelSilentLogin } = require('../middleware/attemptSilentLogin');
const weakRef = require('./weakCache');

function isExpired() {
Expand Down Expand Up @@ -127,6 +128,13 @@ class ResponseContext {
return urlJoin(config.baseURL, config.routes.callback);
}

silentLogin(options) {
return this.login({
...options,
prompt: 'none',
});
}

async login(options = {}) {
let { config, req, res, next, transient } = weakRef(this);
next = cb(next).once();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO: Clear cookie on login

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Expand Down Expand Up @@ -229,6 +237,8 @@ class ResponseContext {
returnURL = urlJoin(config.baseURL, returnURL);
}

cancelSilentLogin(req, res);

if (!req.oidc.isAuthenticated()) {
debug('end-user already logged out, redirecting to %s', returnURL);
return res.redirect(returnURL);
Expand Down
62 changes: 62 additions & 0 deletions middleware/attemptSilentLogin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const debug = require('../lib/debug')('attemptSilentLogin');
const COOKIES = require('../lib/cookies');
const weakRef = require('../lib/weakCache');

const COOKIE_NAME = 'skipSilentLogin';

const cancelSilentLogin = (req, res) => {
const {
config: {
session: { cookie: cookieConfig },
},
} = weakRef(req.oidc);
res.cookie(COOKIE_NAME, true, {
httpOnly: true,
secure:
typeof cookieConfig.secure === 'boolean'
? cookieConfig.secure
: req.secure,
});
};

const resumeSilentLogin = (req, res) => {
const {
config: {
session: { cookie: cookieConfig },
},
} = weakRef(req.oidc);
res.clearCookie(COOKIE_NAME, {
httpOnly: true,
secure:
typeof cookieConfig.secure === 'boolean'
? cookieConfig.secure
: req.secure,
});
};

module.exports = function attemptSilentLogin() {
return (req, res, next) => {
if (!req.oidc) {
next(
new Error('req.oidc is not found, did you include the auth middleware?')
);
return;
}

const silentLoginAttempted = !!(req[COOKIES] || {})[COOKIE_NAME];

if (
!silentLoginAttempted &&
!req.oidc.isAuthenticated() &&
req.accepts('html')
) {
debug('Attempting silent login');
cancelSilentLogin(req, res);
return res.oidc.silentLogin();
}
next();
};
};

module.exports.cancelSilentLogin = cancelSilentLogin;
module.exports.resumeSilentLogin = resumeSilentLogin;
7 changes: 7 additions & 0 deletions middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const debug = require('../lib/debug')('auth');
const { get: getConfig } = require('../lib/config');
const { get: getClient } = require('../lib/client');
const { requiresAuth } = require('./requiresAuth');
const attemptSilentLogin = require('./attemptSilentLogin');
const TransientCookieHandler = require('../lib/transientHandler');
const { RequestContext, ResponseContext } = require('../lib/context');
const appSession = require('../lib/appSession');
Expand Down Expand Up @@ -116,6 +117,8 @@ module.exports = function (params) {
expires_at: tokenSet.expires_at,
});

attemptSilentLogin.resumeSilentLogin(req, res);

next();
} catch (err) {
next(err);
Expand Down Expand Up @@ -145,6 +148,10 @@ module.exports = function (params) {
'see and apply `requiresAuth` middlewares to your protected resources'
);
}
if (config.attemptSilentLogin) {
debug("silent login will be attempted on end-user's initial HTML request");
router.use(attemptSilentLogin());
}

return router;
};
2 changes: 1 addition & 1 deletion middleware/requiresAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async function requiresLoginMiddleware(requiresLoginCheck, req, res, next) {
}

if (requiresLoginCheck(req)) {
if (!res.oidc.errorOnRequiredAuth) {
if (!res.oidc.errorOnRequiredAuth && req.accepts('html')) {
adamjmcgrath marked this conversation as resolved.
Show resolved Hide resolved
debug(
'authentication requirements not met with errorOnRequiredAuth() returning false, calling res.oidc.login()'
);
Expand Down
158 changes: 158 additions & 0 deletions test/attemptSilentLogin.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
const { assert } = require('chai');
const { create: createServer } = require('./fixture/server');
const { makeIdToken } = require('./fixture/cert');
const { auth, attemptSilentLogin } = require('./..');
const request = require('request-promise-native').defaults({
simple: false,
resolveWithFullResponse: true,
followRedirect: false,
});

const baseUrl = 'http://localhost:3000';

const defaultConfig = {
secret: '__test_session_secret__',
clientID: '__test_client_id__',
baseURL: 'https://example.org',
issuerBaseURL: 'https://op.example.com',
};

const login = async (claims) => {
const jar = request.jar();
await request.post('/session', {
baseUrl,
jar,
json: {
id_token: makeIdToken(claims),
},
});
return jar;
};

describe('attemptSilentLogin', () => {
let server;

afterEach(async () => {
if (server) {
server.close();
}
});

it("should attempt silent login on user's first route", async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = request.jar();
const response = await request({ baseUrl, jar, url: '/protected' });

assert.equal(response.statusCode, 302);
assert.include(jar.getCookies(baseUrl)[0], {
key: 'skipSilentLogin',
value: 'true',
httpOnly: true,
});
});

it('should not attempt silent login for non html requests', async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = request.jar();
const response = await request({
baseUrl,
jar,
url: '/protected',
json: true,
});

assert.equal(response.statusCode, 200);
});

it("should not attempt silent login on user's subsequent routes", async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = request.jar();
const response = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response.statusCode, 302);
const response2 = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response2.statusCode, 200);
const response3 = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response3.statusCode, 200);
});

it('should not attempt silent login for authenticated user', async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = await login();
const response = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response.statusCode, 200);
});

it('should not attempt silent login after first anonymous request after logout', async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = await login();
await request({ baseUrl, jar, url: '/protected' });
await request.get({
uri: '/logout',
baseUrl,
jar,
followRedirect: false,
});
const response = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response.statusCode, 200);
});

it('should not attempt silent login after first request is to logout', async () => {
server = await createServer(
auth({
...defaultConfig,
authRequired: false,
}),
attemptSilentLogin()
);
const jar = await login();
await request.get({
uri: '/logout',
baseUrl,
jar,
followRedirect: false,
});
const response = await request({ baseUrl, jar, url: '/protected' });
assert.equal(response.statusCode, 200);
});

it("should throw when there's no auth middleware", async () => {
server = await createServer(attemptSilentLogin());
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?'
);
});
});
22 changes: 21 additions & 1 deletion test/callback.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const setup = async (params) => {
const router = expressOpenid.auth(authOpts);
const transient = new TransientCookieHandler(authOpts);

const jar = request.jar();
const jar = params.jar || request.jar();
server = await createServer(router);
let tokenReqHeader;
let tokenReqBody;
Expand Down Expand Up @@ -440,4 +440,24 @@ describe('callback response_mode: form_post', () => {
/code=jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y/
);
});

it('should resume silent logins when user successfully logs in', async () => {
const idToken = makeIdToken();
const jar = request.jar();
jar.setCookie('skipSilentLogin=true', baseUrl);
await setup({
cookies: {
_state: expectedDefaultState,
_nonce: '__test_nonce__',
skipSilentLogin: '1',
},
body: {
state: expectedDefaultState,
id_token: idToken,
},
jar,
});
const cookies = jar.getCookies(baseUrl);
assert.notOk(cookies.find(({ key }) => key === 'skipSilentLogin'));
});
});
Loading