Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
ALLOWED_CALLBACK_HOSTS_ARRAY: [
"acode.app",
...(process.env.NODE_ENV === 'development' ? ["localhost:5500"] : [])
]
}
39 changes: 34 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
"webpack-cli": "6.0.1"
},
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
"@google-cloud/storage": "^7.16.0",
"@googleapis/androidpublisher": "^29.3.0",
"@noble/ciphers": "^2.0.1",
"autosize": "^6.0.1",
"cookie-parser": "^1.4.7",
"core-js": "^3.45.0",
Expand All @@ -56,7 +58,8 @@
"marked": "^16.1.2",
"moment": "^2.30.1",
"nodemailer": "^7.0.7",
"sqlite3": "^5.1.7"
"sqlite3": "^5.1.7",
"zod": "^4.1.13"
},
"scripts": {
"prepare": "husky",
Expand Down
166 changes: 166 additions & 0 deletions server/apis/oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// TODO: Lookout for vulnerabilities.
const { Router } = require('express');
const OAuthProviderFactory = require('../services/oauth/OAuthProviderFactory');
const SessionStateServiceClass = require('../services/oauth/SessionStateService');
const { ALLOWED_CALLBACK_HOSTS_ARRAY } = require('../../constants')
// For single-instance use only. For clustering/horizontal scaling, inject a distributed store (e.g. Redis) into SessionStateService.
const sessionStateService = new SessionStateServiceClass();
const authenticateWithProvider = require('../lib/authenticateWithProvider');

const ERROR_REDIRECT_PATH = `/login`;
const SUCCESS_CALLBACK_URL = `/user`

function isValidCallbackUrl(url) {
if (!url) return false;
// Allow relative paths
if (url.startsWith('/') && !url.startsWith('//')) return true;
// Or validate against allowlist
try {
const parsed = new URL(url);
return ALLOWED_CALLBACK_HOSTS_ARRAY.includes(parsed.host);
} catch {
return false;
}
}

/**
* @template REQ
* @template RES
* @param {REQ} req
* @param {RES} res
* @returns
*/
async function handleOAuthSignIn (req, res) {
const { provider } = req.params;

const { callbackUrl } = (req?.query || req.body) || {};

try {
if (!provider) {
return res.status(400).send('No provider provided');
}

// Validate callback URL to prevent open redirect
if (callbackUrl && !isValidCallbackUrl(callbackUrl)) {
return res.status(400).json({ error: 'Invalid callback URL' });
}

const oAuthProvider = OAuthProviderFactory.getProvider(provider);
const state = await sessionStateService.generateState({ callbackUrl: `${isValidCallbackUrl(callbackUrl) ? callbackUrl : SUCCESS_CALLBACK_URL}` });
res.cookie('oauthProvider', provider, { secure: true, httpOnly: true, sameSite: 'lax' });
res.cookie('oauthState', state.state, { secure: true, signed: true, httpOnly: true, maxAge: 10 * 60 * 1000 });

const authURL = await oAuthProvider.getAuthorizationUrl({ state: state.state, codeVerifier: state.codeVerifier })
// console.log(authURL)
res.redirect(authURL);
} catch (e) {
console.error(`[OAuth Router] - OAuth initiation (route: ${req.path}) error:`, e);
res.status(400).json({ error: e.message });
}
}

async function handleOAuthCallback(req, res) {
const { provider } = req.params;
const { code, state, error, error_description } = (req?.query || req.body) || {};

try {
if (!provider) {
return res.status(400).send('No provider provided');
}

if(!state) {
console.error(`[OAuth Router] - Provider (${provider}) responded without a state: ${error}`);
return res.redirect(`${ERROR_REDIRECT_PATH}?error=missing_state`);
}

if(error) {
console.error(`[OAuth Router] - Provider (${provider}) responded with an error: ${error}`);
return res.redirect(`${ERROR_REDIRECT_PATH}?error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(error_description || '')}`);
}

if(!code) {
console.error(`[OAuth Router] - Provider (${provider}) responded without a code: ${code}`);
res.redirect(`${ERROR_REDIRECT_PATH}?error=missing_code`);
return;
}

const storedState = req.signedCookies.oauthState;
const storedProvider = req.cookies?.oauthProvider;

const verifiedState = await sessionStateService.verifyState(state)

if(!storedState || !verifiedState) {
res.clearCookie('oauthState');
// res.status(422).send("Invalid State")
res.redirect(`${ERROR_REDIRECT_PATH}?error=state_mismatch`);
return;
}

if(storedState !== state) {
console.log(`State mismatch between cookie & Callback State`, { storedState, state})
res.clearCookie('oauthState');
res.redirect(`${ERROR_REDIRECT_PATH}?error=state_mismatch`);
return;
}

if(storedProvider !== provider) {
console.error(`[OAuth Router] - Provider (${provider}) mismatch: ${storedProvider} !== ${provider}`);
// res.status(422).send("OAuth Provider mismatch");
res.redirect(`${ERROR_REDIRECT_PATH}?error=oauth_provider_mismatch`);
return;
}

// res.clearCookie('oauthProvider');
res.clearCookie('oauthState');

const OAuthProvider = OAuthProviderFactory.getProvider(provider);

const tokens = await OAuthProvider.getAccessToken({ code, codeVerifier: verifiedState.codeVerifier }).catch((e) => ({ error: e}));

if(!tokens?.accessToken || !tokens) {
res.status(401).send(tokens?.error || "Failed to retrieve access token");
return;
}

const profile = await OAuthProvider.getUserProfile(tokens.accessToken).catch((e) => {
res.status(401).send(e?.message || "Failed to retrieve user profile");
return null;
});

if(!profile) return;

console.log(`[OAuth Router] - Provider (${provider}) authentication successful for user ID: ${profile.id}`);

// return res.status(200).send(`Fetched From Github, Hello ${profile.username} (${profile.name})`);

const loginToken = await authenticateWithProvider(provider, profile, tokens);

if(!loginToken) {
res.status(500).send({ error: "Session Token issuing failed for Social Login."});
return;
}

res.cookie('token', loginToken, {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 1 week
});

return res.redirect(`${isValidCallbackUrl(verifiedState.callbackUrl) ? verifiedState.callbackUrl : SUCCESS_CALLBACK_URL}`);

} catch (e) {
console.error(`[OAuth Router] - OAuth callback (route: ${req.path}) error:`, e);
return res.sendStatus(500)
}
}

const router = Router();
router.get('/:provider', handleOAuthSignIn)

router.post('/:provider', handleOAuthSignIn)

router.get('/:provider/callback', handleOAuthCallback)

router.post('/:provider/callback', handleOAuthCallback)

module.exports = router;
60 changes: 60 additions & 0 deletions server/entities/authenticationProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const Entity = require('./entity');

const table = `create table if not exists authentication_provider (
id integer primary key,
user_id integer not null,
provider text not null,
provider_user_id text not null,
access_token text,
refresh_token text,
access_token_expires_at timestamp,
refresh_token_expires_at timestamp,
scope text,
created_at timestamp default current_timestamp,
updated_at timestamp default current_timestamp,
foreign key (user_id) references user(id),
unique(provider, provider_user_id)
);
create trigger if not exists update_authentication_provider_timestamp
after update on authentication_provider
for each row
when OLD.updated_at >= NEW.updated_at
begin
update authentication_provider set updated_at = current_timestamp where id = NEW.id;
end;`;

class AuthenticationProvider extends Entity {
ID = 'id';
USER_ID = 'user_id';
PROVIDER = 'provider';
PROVIDER_USER_ID = 'provider_user_id';
ACCESS_TOKEN = 'access_token';
REFRESH_TOKEN = 'refresh_token';
ACCESS_TOKEN_EXPIRES_AT = 'access_token_expires_at';
REFRESH_TOKEN_EXPIRES_AT = 'refresh_token_expires_at';
SCOPE = 'scope';
CREATED_AT = 'created_at';
UPDATED_AT = 'updated_at';

constructor() {
super(table);
}

get columns() {
return [
this.ID,
this.USER_ID,
this.PROVIDER,
this.PROVIDER_USER_ID,
this.ACCESS_TOKEN,
this.REFRESH_TOKEN,
this.ACCESS_TOKEN_EXPIRES_AT,
this.REFRESH_TOKEN_EXPIRES_AT,
this.SCOPE,
this.CREATED_AT,
this.UPDATED_AT,
];
}
}

module.exports = new AuthenticationProvider();
Loading