diff --git a/constants.js b/constants.js new file mode 100644 index 0000000..046c9ff --- /dev/null +++ b/constants.js @@ -0,0 +1,6 @@ +module.exports = { + ALLOWED_CALLBACK_HOSTS_ARRAY: [ + "acode.app", + ...(process.env.NODE_ENV === 'development' ? ["localhost:5500"] : []) + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 063fc78..860b63c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.1", "license": "MIT", "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", @@ -30,7 +32,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" }, "devDependencies": { "@babel/core": "^7.28.0", @@ -1611,6 +1614,11 @@ "node": ">=6.9.0" } }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.18.tgz", + "integrity": "sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==" + }, "node_modules/@biomejs/biome": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.1.4.tgz", @@ -1910,6 +1918,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.0.1.tgz", + "integrity": "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", @@ -3505,6 +3525,16 @@ "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -9847,10 +9877,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 4a32818..5a58d0d 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/server/apis/oauth.js b/server/apis/oauth.js new file mode 100644 index 0000000..a7b702f --- /dev/null +++ b/server/apis/oauth.js @@ -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; \ No newline at end of file diff --git a/server/entities/authenticationProvider.js b/server/entities/authenticationProvider.js new file mode 100644 index 0000000..77afd25 --- /dev/null +++ b/server/entities/authenticationProvider.js @@ -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(); \ No newline at end of file diff --git a/server/lib/authenticateWithProvider.js b/server/lib/authenticateWithProvider.js new file mode 100644 index 0000000..a95d52c --- /dev/null +++ b/server/lib/authenticateWithProvider.js @@ -0,0 +1,84 @@ +const moment = require('moment'); +const authenticationProvider = require('../entities/authenticationProvider'); +const login = require('../entities/login'); +const User = require('../entities/user'); +// Tokens are encrypted while they're stored, and SHOULD BE decrypted +// When it's being used as values (i.e API with the tokens retrieved from the DB). +const { encryptToken } = require('../lib/tokenCrypto'); + +async function authenticateWithProvider(providerType, profile, tokens) { + const { id: providerUserId, email, name, username } = profile; + const { accessToken, refreshToken, expiresIn } = tokens; + + if (!email) { + throw new Error('Email not provided by OAuth provider'); + } + + // Calculate token expiry + const tokenExpiresAt = expiresIn + ? new Date(Date.now() + expiresIn * 1000) + : null; + + let userId; + + const existingLink = await authenticationProvider.get( + [authenticationProvider.ID, authenticationProvider.USER_ID], + [authenticationProvider.PROVIDER, providerType], + [authenticationProvider.PROVIDER_USER_ID, providerUserId] + ); + + if(existingLink.length > 0) { + userId = existingLink[0].user_id; + + + await authenticationProvider.update( + [ + [authenticationProvider.ID, existingLink[0].id], + [authenticationProvider.ACCESS_TOKEN, await encryptToken(accessToken)], + [authenticationProvider.REFRESH_TOKEN, refreshToken ? await encryptToken(refreshToken) : null], + [authenticationProvider.ACCESS_TOKEN_EXPIRES_AT, tokenExpiresAt], + [authenticationProvider.SCOPE, tokens.scope || null] + ] + ) + } else { + // Atomic, race-safe upsert pattern for user + await User.insertOrIgnore( + [User.NAME, name], + [User.EMAIL, email], + [User.PASSWORD, ""], + [User.WEBSITE, null], + [User.GITHUB, providerType === "github" ? username : null], + ); + // Always fetch the user after insert - ensures you get the correct id whether existing or new + const userRes = await User.get([User.EMAIL, email]); + if (!userRes || userRes.length === 0) { + throw new Error(`Failed to retrieve user`); + } + userId = userRes[0].id; + + await authenticationProvider.insert( + [authenticationProvider.USER_ID, userId], + [authenticationProvider.PROVIDER, providerType], + [authenticationProvider.PROVIDER_USER_ID, providerUserId], + [authenticationProvider.ACCESS_TOKEN, await encryptToken(accessToken)], + [authenticationProvider.REFRESH_TOKEN, refreshToken ? await encryptToken(refreshToken) : null], + [authenticationProvider.ACCESS_TOKEN_EXPIRES_AT, tokenExpiresAt], + [authenticationProvider.REFRESH_TOKEN_EXPIRES_AT, null], + [authenticationProvider.SCOPE, tokens.scope || null] + ); + } + + // break this into a Function, if it gets too repetitive throughout the whole codebase. + const sessionToken = require('node:crypto').randomBytes(64).toString('hex'); + const sessionExpiredAt = moment().add(1, 'week').format('YYYY-MM-DD HH:mm:ss.sss'); + + await login.insert( + [login.USER_ID, userId], + [login.TOKEN, sessionToken], + [login.EXPIRED_AT, sessionExpiredAt] + ) + + return sessionToken; +} + +module.exports = authenticateWithProvider; \ No newline at end of file diff --git a/server/lib/tokenCrypto.js b/server/lib/tokenCrypto.js new file mode 100644 index 0000000..0853a61 --- /dev/null +++ b/server/lib/tokenCrypto.js @@ -0,0 +1,39 @@ +import { xchacha20poly1305 } from '@noble/ciphers/chacha.js'; +import { bytesToHex, hexToBytes, utf8ToBytes, managedNonce } from '@noble/ciphers/utils.js'; +import { subtle } from 'node:crypto'; +import { TextEncoder } from 'node:util'; +const KEY_HEX = process.env.TOKEN_ENCRYPTION_KEY; +if (!KEY_HEX || hexToBytes(KEY_HEX).length !== 32) { + throw new Error('TOKEN_ENCRYPTION_KEY must be set to 64 hex chars (32 bytes)'); +} + +export async function encryptToken(plaintext) { + try { + const encoder = new TextEncoder() + const data = encoder.encode(KEY_HEX); + const KEY = await subtle.digest('SHA-256', data); + const cipher = managedNonce(xchacha20poly1305)(new Uint8Array(KEY)); // 24 bytes + const msgBytes = utf8ToBytes(plaintext); + const ciphertext = cipher.encrypt(msgBytes); + + return bytesToHex(ciphertext) + } catch (e) { + console.error('Token encryption failed', e); + throw new Error('Token encryption failed'); + } +} + +export async function decryptToken(ciphertext) { + try { + const encoder = new TextEncoder() + const data = encoder.encode(KEY_HEX); + const KEY = await subtle.digest('SHA-256', data); + const ct = hexToBytes(ciphertext); + const cipher = managedNonce(xchacha20poly1305)(new Uint8Array(KEY)); + const plaintext = cipher.decrypt(ct); + return new TextDecoder().decode(plaintext); + } catch (e) { + console.error('Token decryption failed', e); + throw new Error('Token decryption failed'); + } +} diff --git a/server/main.js b/server/main.js index 08eb3dd..b6bdcdf 100644 --- a/server/main.js +++ b/server/main.js @@ -35,7 +35,7 @@ async function main() { next(); }); - app.use(cookieParser()); + app.use(cookieParser(process.env.COOKIE_SECRET)); app.use( fileUpload({ limits: { diff --git a/server/routes/apis.js b/server/routes/apis.js index a5fb8ac..148d9e6 100644 --- a/server/routes/apis.js +++ b/server/routes/apis.js @@ -13,6 +13,7 @@ apis.use(['/comments', '/comment'], require('../apis/comment')); apis.use('/faqs', require('../apis/faqs')); apis.use('/admin', require('../apis/admin')); apis.use(['/sponsor', '/sponsors'], require('../apis/sponsor')); +apis.use('/oauth', require('../apis/oauth')); // apis.use('/completion', require('../apis/completion')); apis.get('/status', (_req, res) => { diff --git a/server/services/oauth/OAuthProviderFactory.js b/server/services/oauth/OAuthProviderFactory.js new file mode 100644 index 0000000..43578f8 --- /dev/null +++ b/server/services/oauth/OAuthProviderFactory.js @@ -0,0 +1,34 @@ +const githubOAuthProvider = require('./providers/github'); + +class OAuthProviderFactory { + constructor() { + this.providers = new Map(); + this.initializeProviders(); + } + + /** + * Function responsible to initialize and + * register OAuth Providers. + */ + initializeProviders() { + this.providers.set('github', new githubOAuthProvider()); + } + + getProvider(providerName) { + if (!providerName) { + throw new Error('Provider name is required'); + } + const provider = this.providers.get(providerName.toLowerCase()); + if (!provider) { + throw new Error(`OAuth provider '${providerName}' not supported`); + } + return provider; + } + + getSupportedProviders() { + return Array.from(this.providers.keys()); + } +} + +// Singleton instance +module.exports = new OAuthProviderFactory(); \ No newline at end of file diff --git a/server/services/oauth/OAuthService.js b/server/services/oauth/OAuthService.js new file mode 100644 index 0000000..e584914 --- /dev/null +++ b/server/services/oauth/OAuthService.js @@ -0,0 +1,211 @@ +const { betterFetch } = require('@better-fetch/fetch'); +const { TextEncoder } = require('node:util'); +const { z } = require('zod'); + +/** + * @typedef OAuthServiceConfig + * @property {string} clientId Required for OAuth. + * @property {string} clientSecret Required for OAuth. + * @property {string} [redirectUri] optional, Redirect URI, which the user/client is redirected after completion. + * @property {string} authorizationUrl Authorization Server URL **Required** to Redirect the User to the URL to connect their accounts with Authorization Server. + * @property {string} tokenUrl Required used for retrieving Access, (sometimes Also) Refresh Tokens. + * @property {string} userInfoUrl Required for Retrieve User's Profile Data. + * @property {string} scopes Required To request permissions for read/write on User's Profile. + * @property {string} providerName Good Name For The Provider. + * @property {string} [prompt] + */ + +class OAuthService { + /** + * @param config {OAuthServiceConfig} + */ + constructor(config) { + + this.clientId = config.clientId; + this.clientSecret = config.clientSecret; + this.redirectUri = config.redirectUri; + this.authorizationUrl = config.authorizationUrl; + this.tokenUrl = config.tokenUrl; + this.userInfoUrl = config.userInfoUrl; + this.scopes = config.scopes; + this.providerName = config.providerName; + this.prompt = config.prompt || ""; + + this.callbackUrl = "/user"; + + // biome-ignore lint/complexity/noForEach: ignore + ;["clientId", "clientSecret", "authorizationUrl", "tokenUrl", "userInfoUrl", "scopes", "providerName"].forEach(p=> { + if(!this[p]) throw TypeError(`"${p}" is required, but not specified in OAuthService Class Configuration`); + }) + } + + // Generate authorization URL + async getAuthorizationUrl({ state, codeVerifier }) { + if(!state) throw ReferenceError("state is missing for generating Authorization URL"); + /** Section 4.2 of RFC 7636 -> https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + * S256 + * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + **/ + const code_challenge = await this.#generateCodeChallenge(codeVerifier); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.clientId, + state: state, // CSRF protection + scope: this.scopes.join(' '), + }); + + if(this.redirectUri) params.append('redirect_uri', this.redirectUri); + if(codeVerifier) { + // Section 4.2 of RFC 7636 -> https://datatracker.ietf.org/doc/html/rfc7636#section-4 + params.append('code_challenge', code_challenge); + params.append('code_challenge_method', 'S256'); + } + + return `${this.authorizationUrl}?${params.toString()}`; + } + + // Exchange authorization code for access token + async getAccessToken({ code, codeVerifier }) { + if(!code) { + throw Error(`[${this.providerName} - getAccessToken] Code is required: ${code}`); + } + try { + + const body = { + client_id: this.clientId, + client_secret: this.clientSecret, + code: code, + grant_type: 'authorization_code' + } + + if(codeVerifier) { + body.code_verifier = codeVerifier; + } + + if(this.redirectUri) { + body.redirect_uri = this.redirectUri; + } + + const { data: response, error } = await betterFetch(this.tokenUrl, { + method: "POST", + body: JSON.stringify(body), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + output: z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + scope: z.string().optional(), + token_type: z.string(), + expires_in: z.number().optional(), + }).or( + z.object({ + error: z.string(), + error_description: z.string().optional(), + error_hint: z.string().optional(), + }) + ) + }) + + if(!response || response.error || error) { + console.error(`[${this.providerName} - getAccessToken] `, response|| error); + throw response || error; + } + + console.log(`[${this.providerName} - getAccessToken]`, response); + + return this.normalizeTokenResponse(response); + } catch (e) { + console.error(`${this.providerName} token exchange error:`, e); + throw (e.error ? e : new Error(`Failed to exchange code for token with ${this.providerName}`)) + } + } + + // Normalize token response across providers + normalizeTokenResponse(data) { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + expiresIn: data.expires_in || null, + tokenType: data.token_type || 'Bearer', + scope: data.scope + }; + } + + // Fetch user profile (to be overridden by specific providers) + // biome-ignore lint/correctness/noUnusedFunctionParameters: unused here as it is overridden. + async getUserProfile(accessToken) { + throw new Error('getUserProfile must be implemented by provider-specific service'); + } + + // Refresh access token + async refreshAccessToken(refreshToken) { + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + try { + + const { data: response, error } = await betterFetch( + this.tokenUrl, + { + method: 'POST', + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + output: z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + scope: z.string().optional(), + token_type: z.string(), + expires_in: z.number().optional(), + }).or( + z.object({ + error: z.string(), + error_description: z.string().optional(), + error_hint: z.string().optional(), + }) + ) + } + ) + + if(!response || response.error || error) { + console.error(`[${this.providerName} - refreshAccessToken] Token refresh failed`, error || response); + throw response || error; + } + + return this.normalizeTokenResponse(response); + } catch (error) { + console.error(`${this.providerName} token refresh error:`, error); + throw new Error(`Failed to refresh token with ${this.providerName}`); + } + } + /** + * + * @param {string} codeVerifier + * @returns {Promise} + */ + async #generateCodeChallenge(codeVerifier) { + if(!codeVerifier) throw ReferenceError("codeVerifier is required for generating Code Challenge"); + /** Section 4.2 of RFC 7636 -> https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + * S256 + * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + **/ + + const textEncoder = new TextEncoder(); + const encodedData = textEncoder.encode(codeVerifier); + const hash = await crypto.subtle.digest('SHA-256', encodedData); + return Buffer.from(new Uint8Array(hash)).toString('base64url'); + } +} + +module.exports = OAuthService; \ No newline at end of file diff --git a/server/services/oauth/SessionStateService.js b/server/services/oauth/SessionStateService.js new file mode 100644 index 0000000..8c46d31 --- /dev/null +++ b/server/services/oauth/SessionStateService.js @@ -0,0 +1,97 @@ +const crypto = require('node:crypto'); + +// --- Injectable state store abstraction --- +class StateStore { + async set(key, value) { this._map.set(key, value); } + async get(key) { return this._map.get(key); } + async delete(key) { this._map.delete(key); } + async entries() { return Array.from(this._map.entries()); } + async getAndDelete(key) { + const value = this._map.get(key); + this._map.delete(key); + return value; + } + constructor() { this._map = new Map(); } +} +// TODO: Implement a Redis/Memcached version for distributed scaling support. + +// --- Main SessionStateService --- +class SessionStateService { + /** + * @param {object} options + * - store: implements set/get/delete/entries (default: in-memory map), swap with Redis/Memcached for horizontal scaling + * - cleanupIntervalMs: how frequently to clean up expired states (default: 60s) + */ + constructor({ store, cleanupIntervalMs = 60000 } = {}) { + /** + * Use an injectable store. Use in-memory only for single-instance/dev. For cluster/horizontal scale, use a Redis/Memcached implementation! + * @type {StateStore} + */ + this.states = store || new StateStore(); + // Clean up expired states periodically + this._cleanupTimer = setInterval(() => this.cleanup(), cleanupIntervalMs); + this._cleanupTimer.unref(); // Don't prevent process exit + } + + // Generate a random state token for CSRF protection + async generateState({ callbackUrl }) { + const codeVerifier = this.#makeToken(128) + const state = crypto.randomBytes(32).toString('hex'); + + // Only stateData should be stored in cookie, IF IT's Encrypted, + // stateData SHOULD NOT BE Stored in the cookie, IF IT's not Encrypted, then it should be stored in the DB. + const stateData = { + createdAt: Date.now(), + callbackUrl: callbackUrl, + codeVerifier: codeVerifier, + }; + + await this.states.set(state, stateData); // Remove 'used' + // No per-generation cleanup, timer handles it + return { state, codeVerifier, stateData }; + } + + // Verify and consume a state token + async verifyState(state) { + // Proactive cleanup: remove expired states before checking + // await this.cleanup(); + const stateData = await this.states.getAndDelete(state); + if (!stateData) return false; + const tenMinutes = 10 * 60 * 1000; + if (Date.now() - stateData.createdAt > tenMinutes) { + // Already deleted: nothing left to clean + return false; + } + return stateData; + } + + // Clean up old states + async cleanup() { + const tenMinutes = 10 * 60 * 1000; + const now = Date.now(); + const entries = await this.states.entries(); + for (const [state, data] of entries) { + if (now - data.createdAt > tenMinutes) { + await this.states.delete(state); + } + } + } + + // Call this to stop the cleanup timer, e.g. on app shutdown + stopCleanupTimer() { + clearInterval(this._cleanupTimer); + } + + #makeToken(length) { + return crypto.randomBytes(Math.ceil((length * 3) / 4)) + .toString("base64url") + .slice(0, length); + } +} + +/** + * WARNING: The default in-memory state store works ONLY for single-instance applications. + * Deployments with horizontal scaling (multiple app/server instances) must inject a Redis/Memcached store + * implementing the async set/get/delete/entries contract. + */ +module.exports = SessionStateService; \ No newline at end of file diff --git a/server/services/oauth/providers/github.js b/server/services/oauth/providers/github.js new file mode 100644 index 0000000..edc2da2 --- /dev/null +++ b/server/services/oauth/providers/github.js @@ -0,0 +1,87 @@ +const OAuthService = require('../OAuthService'); + +class githubOAuthProvider extends OAuthService { + constructor() { + super({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + redirectUri: process.env.GITHUB_CALLBACK_URL, + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + userInfoUrl: 'https://api.github.com/user', + scopes: ['read:user', 'user:email'], + providerName: 'github' + }); + } + + async getUserProfile(accessToken) { + try { + const userProfileResponse = await fetch(this.userInfoUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!userProfileResponse.ok) { + const errorData = await userProfileResponse.json().catch(() => ({})); + // ignore warning, Rethrown after catching.... + throw new Error(`Failed to fetch user profile, err message: ${errorData.message || 'Unknown error'}`); + } + + const profile = await userProfileResponse.json(); // GitHub may not return email in profile, fetch separately + let email = profile.email; + if (!email) { + const emailResponse = await fetch('https://api.github.com/user/emails', { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json' + } + }); + + if(!emailResponse.ok) { + // ignore warning, Rethrown after catching.... + throw Error(`Failed to fetch User Email (response status: ${emailResponse.status}, response msg: ${(await emailResponse.json().catch(() => ({})))?.message || 'Unknown error'})`) + } + + emailResponse.data = await emailResponse.json(); + + // Find primary verified email + const primaryEmail = emailResponse?.data?.find(e => e.primary && e.verified); + email = primaryEmail ? primaryEmail.email : emailResponse.data[0]?.email; + + if(!email) { + // ignore warning, Rethrown after catching.... + throw Error(`${emailResponse.data?.error_description || "No verified email address found for GitHub user"} ${emailResponse?.data?.error_uri ? `(See: ${emailResponse.data.error_uri})` : ""}`); + } + } + + return { + id: profile.id.toString(), + email: email, + name: profile.name || profile.login, + username: profile.login, + avatar_url: profile.avatar_url, + profile_url: profile.html_url, + bio: profile.bio, + raw: profile + }; + + } catch (e) { + console.error('[githubOAuthProvider - getUserProfile] GitHub profile fetch error:', e?.response?.data || e?.message); + throw e; + } + } + + // GitHub OAuth App tokens don't expire + calculateTokenExpiry(expiresIn) { + return null; + } + + // GitHub OAuth Apps don't support refresh tokens + async refreshAccessToken(refreshToken) { + throw new Error('GitHub OAuth Apps do not support token refresh'); + } +} + +module.exports = githubOAuthProvider; \ No newline at end of file