Skip to content

Commit 478f449

Browse files
authored
feat: support OAuth token revocation during unlink or account delete (sahat#1546)
Add best-effort OAuth token revocation (RFC 7009-style and provider-specific variants) when users unlink an OAuth provider or delete their account, to reduce lingering third-party access. - Introduces a token revocation helper (config/token-revocation.js) and a provider revocation registry (providerRevocationConfig) in config/passport.js. -- The providerRevocationConfig is in config/passport.js to keep them in the same file as other provider specific OAuth related config/meta data. - Hooks revocation into /account/unlink/:provider and /account/delete flows. - Adds unit tests covering multiple revocation auth-method variants and documents the new helper in README.
1 parent d3526e1 commit 478f449

File tree

5 files changed

+469
-0
lines changed

5 files changed

+469
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ I also tried to make it as **generic** and **reusable** as possible to cover mos
7979
- Verify Email
8080
- **Two-Factor Authentication (2FA)** Email codes and Authenticator apps
8181
- Link multiple OAuth provider accounts to one account
82+
- OAuth token revocation
8283
- Delete Account
8384
- Contact Form (powered by SMTP via Mailgun, AWS SES, etc.)
8485
- File upload
@@ -555,6 +556,7 @@ The metadata for Open Graph is only set up for the home page (`home.pug`). Updat
555556
| **config**/morgan.js | Configuration for request logging with morgan. |
556557
| **config**/nodemailer.js | Configuration and helper function for sending email with nodemailer. |
557558
| **config**/passport.js | Passport Local and OAuth strategies, plus login middleware. |
559+
| **config**/token-revocation.js | Helper for revoking OAuth tokens. |
558560
| **controllers**/ai.js | Controller for /ai route and all ai examples and boilerplates. |
559561
| **controllers**/api.js | Controller for /api route and all api examples. |
560562
| **controllers**/contact.js | Controller for contact form. |

config/passport.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,77 @@ const discordStrategyConfig = new OAuth2Strategy(
841841
passport.use('discord', discordStrategyConfig);
842842
refresh.use('discord', discordStrategyConfig);
843843

844+
/**
845+
* Token Revocation Config
846+
*
847+
* Providers with a revocation endpoint. Used by config/token-revocation.js
848+
* to revoke tokens on unlink or account deletion.
849+
*
850+
* authMethod values:
851+
* 'body' – client_id + client_secret + token in form-encoded body
852+
* 'basic' – HTTP Basic auth (client_id:client_secret) + token in form body
853+
* 'token_only' – only the token in form-encoded body
854+
* 'client_id_only' – client_id + token in body (no client_secret)
855+
* 'json_body' – JSON body with token, client_id, client_secret
856+
* 'trakt' – JSON body + trakt-api-key / trakt-api-version headers
857+
* 'facebook' – HTTP DELETE with access_token as query param
858+
* 'github' – HTTP DELETE with Basic auth + JSON body
859+
* 'oauth1' – OAuth 1.0a signed POST (needs consumerKey/consumerSecret)
860+
*/
861+
const providerRevocationConfig = {
862+
google: {
863+
revokeURL: 'https://oauth2.googleapis.com/revoke',
864+
authMethod: 'token_only',
865+
},
866+
facebook: {
867+
revokeURL: 'https://graph.facebook.com/me/permissions',
868+
authMethod: 'facebook',
869+
},
870+
github: {
871+
revokeURL: `https://api.github.com/applications/${process.env.GITHUB_ID}/token`,
872+
clientId: process.env.GITHUB_ID,
873+
clientSecret: process.env.GITHUB_SECRET,
874+
authMethod: 'github',
875+
},
876+
x: {
877+
revokeURL: 'https://api.x.com/1.1/oauth/invalidate_token',
878+
consumerKey: process.env.X_KEY,
879+
consumerSecret: process.env.X_SECRET,
880+
authMethod: 'oauth1',
881+
},
882+
linkedin: {
883+
revokeURL: 'https://www.linkedin.com/oauth/v2/revoke',
884+
clientId: process.env.LINKEDIN_ID,
885+
clientSecret: process.env.LINKEDIN_SECRET,
886+
authMethod: 'body',
887+
},
888+
discord: {
889+
revokeURL: 'https://discord.com/api/oauth2/token/revoke',
890+
clientId: process.env.DISCORD_CLIENT_ID,
891+
clientSecret: process.env.DISCORD_CLIENT_SECRET,
892+
authMethod: 'body',
893+
},
894+
twitch: {
895+
revokeURL: 'https://id.twitch.tv/oauth2/revoke',
896+
clientId: process.env.TWITCH_CLIENT_ID,
897+
authMethod: 'client_id_only',
898+
},
899+
trakt: {
900+
revokeURL: 'https://api.trakt.tv/oauth/revoke',
901+
clientId: process.env.TRAKT_ID,
902+
clientSecret: process.env.TRAKT_SECRET,
903+
authMethod: 'trakt',
904+
},
905+
quickbooks: {
906+
revokeURL: 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke',
907+
clientId: process.env.QUICKBOOKS_CLIENT_ID,
908+
clientSecret: process.env.QUICKBOOKS_CLIENT_SECRET,
909+
authMethod: 'basic',
910+
},
911+
};
912+
913+
exports.providerRevocationConfig = providerRevocationConfig;
914+
844915
/**
845916
* Login Required middleware.
846917
*/

config/token-revocation.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
const crypto = require('node:crypto');
2+
const { providerRevocationConfig } = require('./passport');
3+
4+
function generateOAuth1Header(method, url, consumerKey, consumerSecret, token, tokenSecret) {
5+
const nonce = crypto.randomBytes(16).toString('hex');
6+
const timestamp = Math.floor(Date.now() / 1000).toString();
7+
const params = {
8+
oauth_consumer_key: consumerKey,
9+
oauth_nonce: nonce,
10+
oauth_signature_method: 'HMAC-SHA1',
11+
oauth_timestamp: timestamp,
12+
oauth_token: token,
13+
oauth_version: '1.0',
14+
};
15+
const paramStr = Object.keys(params)
16+
.sort()
17+
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
18+
.join('&');
19+
const baseStr = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(paramStr)}`;
20+
const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret || '')}`;
21+
const signature = crypto.createHmac('sha1', signingKey).update(baseStr).digest('base64');
22+
params.oauth_signature = signature;
23+
return `OAuth ${Object.keys(params)
24+
.sort()
25+
.map((k) => `${encodeURIComponent(k)}="${encodeURIComponent(params[k])}"`)
26+
.join(', ')}`;
27+
}
28+
29+
const REQUIRED_FIELDS = {
30+
basic: ['clientId', 'clientSecret'],
31+
body: ['clientId', 'clientSecret'],
32+
json_body: ['clientId', 'clientSecret'],
33+
trakt: ['clientId', 'clientSecret'],
34+
client_id_only: ['clientId'],
35+
github: ['clientId', 'clientSecret'],
36+
oauth1: ['consumerKey', 'consumerSecret'],
37+
token_only: [],
38+
facebook: [],
39+
};
40+
41+
const REVOKE_TIMEOUT_MS = 8000;
42+
43+
async function revokeToken(revokeURL, token, tokenTypeHint, config, tokenSecret) {
44+
let timeout;
45+
try {
46+
const required = REQUIRED_FIELDS[config.authMethod];
47+
if (required) {
48+
const missing = required.filter((f) => !config[f]);
49+
if (missing.length > 0) {
50+
console.warn(`Token revocation: skipping ${config.authMethod} — missing config: ${missing.join(', ')}`);
51+
return false;
52+
}
53+
}
54+
const controller = new AbortController();
55+
timeout = setTimeout(() => controller.abort(), REVOKE_TIMEOUT_MS);
56+
const headers = {};
57+
let body;
58+
let method = 'POST';
59+
let finalURL = revokeURL;
60+
switch (config.authMethod) {
61+
case 'basic': {
62+
const credentials = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
63+
headers.Authorization = `Basic ${credentials}`;
64+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
65+
body = new URLSearchParams({ token, token_type_hint: tokenTypeHint });
66+
break;
67+
}
68+
case 'body': {
69+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
70+
body = new URLSearchParams({ token, token_type_hint: tokenTypeHint, client_id: config.clientId, client_secret: config.clientSecret });
71+
break;
72+
}
73+
case 'token_only': {
74+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
75+
body = new URLSearchParams({ token });
76+
break;
77+
}
78+
case 'client_id_only': {
79+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
80+
body = new URLSearchParams({ token, client_id: config.clientId });
81+
break;
82+
}
83+
case 'json_body': {
84+
headers['Content-Type'] = 'application/json';
85+
body = JSON.stringify({ token, client_id: config.clientId, client_secret: config.clientSecret });
86+
break;
87+
}
88+
case 'trakt': {
89+
headers['Content-Type'] = 'application/json';
90+
headers['trakt-api-key'] = config.clientId;
91+
headers['trakt-api-version'] = '2';
92+
body = JSON.stringify({ token, client_id: config.clientId, client_secret: config.clientSecret });
93+
break;
94+
}
95+
case 'facebook': {
96+
method = 'DELETE';
97+
finalURL = `${revokeURL}?access_token=${encodeURIComponent(token)}`;
98+
break;
99+
}
100+
case 'github': {
101+
method = 'DELETE';
102+
const creds = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');
103+
headers.Authorization = `Basic ${creds}`;
104+
headers.Accept = 'application/vnd.github+json';
105+
headers['X-GitHub-Api-Version'] = '2022-11-28';
106+
headers['Content-Type'] = 'application/json';
107+
body = JSON.stringify({ access_token: token });
108+
break;
109+
}
110+
case 'oauth1': {
111+
headers.Authorization = generateOAuth1Header('POST', revokeURL, config.consumerKey, config.consumerSecret, token, tokenSecret);
112+
break;
113+
}
114+
default:
115+
console.warn(`Token revocation: unknown authMethod '${config.authMethod}'`);
116+
return false;
117+
}
118+
const response = await fetch(finalURL, { method, headers, body, signal: controller.signal });
119+
if (response.ok) return true;
120+
console.warn(`Token revocation: ${revokeURL} responded with HTTP ${response.status}`);
121+
return false;
122+
} catch (err) {
123+
console.warn(`Token revocation: request to ${revokeURL} failed — ${err.message}`);
124+
return false;
125+
} finally {
126+
clearTimeout(timeout);
127+
}
128+
}
129+
130+
async function revokeProviderTokens(providerName, tokenData) {
131+
const config = providerRevocationConfig[providerName];
132+
if (!config || !tokenData) return;
133+
const tasks = [];
134+
if (tokenData.refreshToken) {
135+
tasks.push(revokeToken(config.revokeURL, tokenData.refreshToken, 'refresh_token', config, tokenData.tokenSecret));
136+
}
137+
if (tokenData.accessToken) {
138+
tasks.push(revokeToken(config.revokeURL, tokenData.accessToken, 'access_token', config, tokenData.tokenSecret));
139+
}
140+
await Promise.allSettled(tasks);
141+
}
142+
143+
async function revokeAllProviderTokens(tokens) {
144+
if (!tokens || tokens.length === 0) return;
145+
const tasks = tokens.filter((t) => providerRevocationConfig[t.kind]).map((t) => revokeProviderTokens(t.kind, t));
146+
await Promise.allSettled(tasks);
147+
}
148+
149+
module.exports = { revokeProviderTokens, revokeAllProviderTokens };

controllers/user.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const User = require('../models/User');
77
const Session = require('../models/Session');
88
const nodemailerConfig = require('../config/nodemailer');
99
const aiAgentController = require('./ai-agent');
10+
const { revokeProviderTokens, revokeAllProviderTokens } = require('../config/token-revocation');
1011

1112
/**
1213
* GET /login
@@ -396,6 +397,8 @@ exports.postUpdatePassword = async (req, res, next) => {
396397
exports.postDeleteAccount = async (req, res, next) => {
397398
try {
398399
const userId = req.user.id;
400+
// Best-effort: revoke OAuth tokens at provider endpoints before deleting
401+
await revokeAllProviderTokens(req.user.tokens);
399402
await aiAgentController.deleteUserAIAgentData(userId); // Delete user's AI agent chat history
400403
await User.deleteOne({ _id: userId });
401404
req.logout((err) => {
@@ -421,6 +424,7 @@ exports.getOauthUnlink = async (req, res, next) => {
421424
provider = validator.escape(provider);
422425
const user = await User.findById(req.user.id);
423426
user[provider.toLowerCase()] = undefined;
427+
const tokenToRevoke = user.tokens.find((token) => token.kind === provider.toLowerCase());
424428
const tokensWithoutProviderToUnlink = user.tokens.filter((token) => token.kind !== provider.toLowerCase());
425429

426430
// Remove provider's picture entry
@@ -457,6 +461,9 @@ exports.getOauthUnlink = async (req, res, next) => {
457461
});
458462
return res.redirect('/account');
459463
}
464+
465+
// Best-effort: revoke the OAuth token at the provider's endpoint before unlinking
466+
await revokeProviderTokens(provider.toLowerCase(), tokenToRevoke);
460467
user.tokens = tokensWithoutProviderToUnlink;
461468
await user.save();
462469
req.flash('info', {

0 commit comments

Comments
 (0)