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

[SDK-3911] Add support for providing a custom callback route #438

Merged
merged 2 commits into from
Jan 24, 2023
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
26 changes: 24 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ Full example at [routes.js](./examples/routes.js), to run it: `npm run start:exa

## 3. Route customization

If you need to customize the provided login and logout routes, you can disable the default routes and write your own route handler and pass custom paths to mount the handler at that path:
If you need to customize the provided login, logout, and callback routes, you can disable the default routes and write your own route handler and pass custom paths to mount the handler at that path.

When overriding the callback route you should pass a `authorizationParams.redirect_uri` value on `res.oidc.login` and a `redirectUri` value on your `res.oidc.callback` call.

```js
app.use(
Expand All @@ -84,14 +86,34 @@ app.use(
// Pass a custom path to redirect users to a different
// path after logout.
postLogoutRedirect: '/custom-logout',
// Override the default callback route to use your own callback route as shown below
},
})
);

app.get('/login', (req, res) => res.oidc.login({ returnTo: '/profile' }));
app.get('/login', (req, res) =>
res.oidc.login({
returnTo: '/profile',
authorizationParams: {
redirect_uri: 'http://localhost:3000/callback',
},
})
);

app.get('/custom-logout', (req, res) => res.send('Bye!'));

app.get('/callback', (req, res) =>
res.oidc.callback({
redirectUri: 'http://localhost:3000/callback',
})
);

app.post('/callback', express.urlencoded({ extended: false }), (req, res) =>
res.oidc.callback({
redirectUri: 'http://localhost:3000/callback',
})
);

module.exports = app;
```

Expand Down
22 changes: 21 additions & 1 deletion examples/custom-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ app.use(
// Pass a custom path to the postLogoutRedirect to redirect users to a different
// path after login, this should be registered on your authorization server.
postLogoutRedirect: '/custom-logout',
callback: false,
},
})
);
Expand All @@ -23,8 +24,27 @@ app.get('/profile', requiresAuth(), (req, res) =>
res.send(`hello ${req.oidc.user.sub}`)
);

app.get('/login', (req, res) => res.oidc.login({ returnTo: '/profile' }));
app.get('/login', (req, res) =>
res.oidc.login({
returnTo: '/profile',
authorizationParams: {
redirect_uri: 'http://localhost:3000/callback',
},
})
Comment on lines +27 to +33

Check failure

Code scanning / CodeQL

Missing rate limiting

This route handler performs [authorization](1), but is not rate-limited.
);

app.get('/custom-logout', (req, res) => res.send('Bye!'));

app.get('/callback', (req, res) =>
res.oidc.callback({
redirectUri: 'http://localhost:3000/callback',
})
);

app.post('/callback', express.urlencoded({ extended: false }), (req, res) =>
res.oidc.callback({
redirectUri: 'http://localhost:3000/callback',
})
);

module.exports = app;
29 changes: 28 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@ interface ResponseContext {
* ```
*/
logout: (opts?: LogoutOptions) => Promise<void>;

/**
* Provided by default via the `/callback` route. Call this to override or have other
* callback routes with
*
* ```js
* app.get('/callback', (req, res) => {
* res.oidc.callback({ redirectUri: 'https://example.com/callback' });
* });
* ```
*/
callback: (opts?: CallbackOptions) => Promise<void>;
}

/**
Expand All @@ -197,7 +209,9 @@ declare global {
*/
interface LoginOptions {
/**
* Override the default {@link ConfigParams.authorizationParams authorizationParams}
* Override the default {@link ConfigParams.authorizationParams authorizationParams}, if also passing a custom callback
* route then {@link AuthorizationParameters.redirect_uri redirect_uri} must be provided here or in
* {@link ConfigParams.authorizationParams config}
*/
authorizationParams?: AuthorizationParameters;

Expand Down Expand Up @@ -227,6 +241,19 @@ interface LogoutOptions {
logoutParams?: { [key: string]: any };
}

interface CallbackOptions {
/**
* This is useful to specify in addition to {@link ConfigParams.baseURL} when your app runs on multiple domains,
* it should match {@link LoginOptions.authorizationParams.redirect_uri}
*/
redirectUri: string;

/**
* Additional request body properties to be sent to the `token_endpoint.
*/
tokenEndpointParams?: TokenParameters;
}

/**
* Configuration parameters passed to the `auth()` middleware.
*
Expand Down
5 changes: 4 additions & 1 deletion lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,10 @@ const paramsSchema = Joi.object({
Joi.string().uri({ relativeOnly: true }),
Joi.boolean().valid(false),
]).default('/logout'),
callback: Joi.string().uri({ relativeOnly: true }).default('/callback'),
callback: Joi.alternatives([
Joi.string().uri({ relativeOnly: true }),
Joi.boolean().valid(false),
]).default('/callback'),
postLogoutRedirect: Joi.string().uri({ allowRelative: true }).default(''),
})
.default()
Expand Down
87 changes: 84 additions & 3 deletions lib/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ const urlJoin = require('url-join');
const { TokenSet } = require('openid-client');
const clone = require('clone');
const { strict: assert } = require('assert');
const createError = require('http-errors');

const debug = require('./debug')('context');
const { once } = require('./once');
const { get: getClient } = require('./client');
const { encodeState } = require('../lib/hooks/getLoginState');
const { cancelSilentLogin } = require('../middleware/attemptSilentLogin');
const { encodeState, decodeState } = require('../lib/hooks/getLoginState');
const {
cancelSilentLogin,
resumeSilentLogin,
} = require('../middleware/attemptSilentLogin');
const weakRef = require('./weakCache');
const {
regenerateSessionStoreId,
replaceSession,
} = require('../lib/appSession');

function isExpired() {
return tokenSet.call(this).expired();
Expand Down Expand Up @@ -160,7 +168,9 @@ class ResponseContext {

getRedirectUri() {
const { config } = weakRef(this);
return urlJoin(config.baseURL, config.routes.callback);
if (config.routes.callback) {
return urlJoin(config.baseURL, config.routes.callback);
}
}

silentLogin(options = {}) {
Expand Down Expand Up @@ -306,6 +316,77 @@ class ResponseContext {
debug('logging out of identity provider, redirecting to %s', returnURL);
res.redirect(returnURL);
}

async callback(options = {}) {
let { config, req, res, transient, next } = weakRef(this);
next = once(next);
try {
const client = await getClient(config);
const redirectUri = options.redirectUri || this.getRedirectUri();

let tokenSet;
try {
const callbackParams = client.callbackParams(req);
const authVerification = transient.getOnce(
config.transactionCookie.name,
req,
res
);

const checks = authVerification ? JSON.parse(authVerification) : {};

req.openidState = decodeState(checks.state);

tokenSet = await client.callback(redirectUri, callbackParams, checks, {
exchangeBody: {
...(config && config.tokenEndpointParams),
...options.tokenEndpointParams,
},
});
} catch (error) {
throw createError(400, error.message, {
error: error.error,
error_description: error.error_description,
});
}

let session = Object.assign({}, tokenSet); // Remove non-enumerable methods from the TokenSet

if (config.afterCallback) {
session = await config.afterCallback(
req,
res,
session,
req.openidState
);
}

if (req.oidc.isAuthenticated()) {
if (req.oidc.user.sub === tokenSet.claims().sub) {
// If it's the same user logging in again, just update the existing session.
Object.assign(req[config.session.name], session);
} else {
// If it's a different user, replace the session to remove any custom user
// properties on the session
replaceSession(req, session, config);
// And regenerate the session id so the previous user wont know the new user's session id
regenerateSessionStoreId(req, config);
}
} else {
// If a new user is replacing an anonymous session, update the existing session to keep
// any anonymous session state (eg. checkout basket)
Object.assign(req[config.session.name], session);
// But update the session store id so a previous anonymous user wont know the new user's session id
regenerateSessionStoreId(req, config);
}
resumeSilentLogin(req, res);
} catch (err) {
if (!req.openidState || !req.openidState.attemptingSilentLogin) {
return next(err);
}
}
res.redirect(req.openidState.returnTo || config.baseURL);
}
}

module.exports = { RequestContext, ResponseContext };
Loading