diff --git a/api/package-lock.json b/api/package-lock.json index 1e174a7..c0de017 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -21,6 +21,9 @@ "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", + "passport": "^0.7.0", + "passport-facebook": "^3.0.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.20.0", "redis": "^5.11.0", "uuid": "^13.0.0", @@ -565,6 +568,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1419,6 +1431,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -1440,6 +1458,76 @@ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", "license": "MIT" }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-facebook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", + "integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -1456,6 +1544,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", @@ -1985,6 +2078,12 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1992,6 +2091,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", diff --git a/api/package.json b/api/package.json index 9bbf679..47e1ffd 100644 --- a/api/package.json +++ b/api/package.json @@ -25,6 +25,9 @@ "dotenv": "^17.3.1", "fastify": "^5.8.4", "ioredis": "^5.10.1", + "passport": "^0.7.0", + "passport-facebook": "^3.0.0", + "passport-google-oauth20": "^2.0.0", "pg": "^8.20.0", "redis": "^5.11.0", "uuid": "^13.0.0", diff --git a/api/src/index.js b/api/src/index.js index ace89a7..0919f3d 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -62,6 +62,7 @@ const start = async () => { await app.register(require("./routes/tasks"), { prefix: "/api/v1" }); await app.register(require("./routes/groups"), { prefix: "/api/v1" }); await app.register(require("./routes/auth"), { prefix: "/api/v1" }); + await app.register(require("./routes/oauth"), { prefix: "/api/v1" }); await app.register(require("./routes/connectors"), { prefix: "/api/v1" }); await app.register(require("./routes/connectors/odoo"), { prefix: "/api/v1" }); await app.register(require("./routes/connectors/moodle"), { prefix: "/api/v1" }); diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index c5c27fb..93ebafb 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -88,12 +88,7 @@ async function authRoutes(app) { return { status: 'ok', message: 'Password changed' }; }); - // OAuth callback placeholder (for Google/Apple/Facebook) - app.get('/auth/oauth/:provider', async (req) => { - const { provider } = req.params; - // TODO: Implement OAuth flows - return { status: 'not_implemented', provider, message: 'OAuth coming soon. Use email/password.' }; - }); + // OAuth routes moved to ./oauth.js (Google, Facebook, Apple) // TOTP 2FA setup app.post('/auth/2fa/setup', { preHandler: [async (req) => { await req.jwtVerify(); }] }, async (req) => { @@ -106,13 +101,7 @@ async function authRoutes(app) { return { status: 'not_implemented', message: 'WebAuthn biometric auth coming soon.' }; }); - // OAuth initiate (placeholder) - app.get('/auth/oauth/:provider/init', async (req) => { - const { provider } = req.params; - const providers = ['google', 'facebook', 'apple']; - if (!providers.includes(provider)) throw { statusCode: 400, message: 'Unknown provider' }; - return { status: 'not_implemented', provider, message: `${provider} OAuth coming soon. Configure at Settings > Connections.` }; - }); + // OAuth initiate routes moved to ./oauth.js // Search users by name or email (for collaboration) app.get('/auth/users/search', async (req) => { diff --git a/api/src/routes/oauth.js b/api/src/routes/oauth.js new file mode 100644 index 0000000..a0ee214 --- /dev/null +++ b/api/src/routes/oauth.js @@ -0,0 +1,296 @@ +// Task Team — OAuth Routes (Google + Facebook + Apple) — 2026-03-29 + +async function oauthRoutes(app) { + + // OAuth config from environment + const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; + const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; + const FACEBOOK_APP_ID = process.env.FACEBOOK_APP_ID || ''; + const FACEBOOK_APP_SECRET = process.env.FACEBOOK_APP_SECRET || ''; + const CALLBACK_BASE = process.env.OAUTH_CALLBACK_BASE || 'https://api.hasdo.info'; + + // Helper: find or create user by OAuth provider + async function findOrCreateOAuthUser(email, name, avatarUrl, provider, providerId) { + if (!email) throw { statusCode: 400, message: `${provider} account has no email. Please use an account with email.` }; + + // Check if user already exists by email + let { rows } = await app.db.query('SELECT * FROM users WHERE email = $1', [email]); + + if (rows.length) { + // Update provider info if not set + const user = rows[0]; + if (!user.auth_provider_id || user.auth_provider !== provider) { + await app.db.query( + 'UPDATE users SET auth_provider = $1, auth_provider_id = $2, avatar_url = COALESCE(avatar_url, $3), updated_at = NOW() WHERE id = $4', + [provider, providerId, avatarUrl, user.id] + ); + } + return user; + } + + // Create new user + const insert = await app.db.query( + 'INSERT INTO users (email, name, avatar_url, auth_provider, auth_provider_id, language) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [email, name, avatarUrl, provider, providerId, 'cs'] + ); + return insert.rows[0]; + } + + // Helper: generate JWT and build response + function buildAuthResponse(user) { + const token = app.jwt.sign({ id: user.id, email: user.email }, { expiresIn: '7d' }); + return { + data: { + user: { + id: user.id, + email: user.email, + name: user.name, + avatar_url: user.avatar_url, + language: user.language + }, + token + } + }; + } + + // ─── Google OAuth ───────────────────────────────────────────── + + // Google OAuth - initiate login flow + app.get('/auth/google', async (req, reply) => { + if (!GOOGLE_CLIENT_ID) { + return { status: 'not_configured', message: 'Google OAuth not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars.' }; + } + const params = new URLSearchParams({ + client_id: GOOGLE_CLIENT_ID, + redirect_uri: `${CALLBACK_BASE}/api/v1/auth/google/callback`, + response_type: 'code', + scope: 'email profile', + access_type: 'offline', + prompt: 'select_account' + }); + return reply.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); + }); + + // Google OAuth - callback (exchange code for token) + app.get('/auth/google/callback', async (req, reply) => { + const { code, error } = req.query; + if (error) throw { statusCode: 400, message: `Google auth error: ${error}` }; + if (!code) throw { statusCode: 400, message: 'No authorization code provided' }; + + // Exchange authorization code for tokens + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, + client_id: GOOGLE_CLIENT_ID, + client_secret: GOOGLE_CLIENT_SECRET, + redirect_uri: `${CALLBACK_BASE}/api/v1/auth/google/callback`, + grant_type: 'authorization_code' + }) + }); + const tokens = await tokenRes.json(); + if (!tokens.access_token) { + app.log.error({ tokens }, 'Google token exchange failed'); + throw { statusCode: 401, message: 'Google authentication failed' }; + } + + // Get user profile from Google + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokens.access_token}` } + }); + const gUser = await userRes.json(); + + // Find or create user in our DB + const user = await findOrCreateOAuthUser( + gUser.email, + gUser.name, + gUser.picture, + 'google', + gUser.id + ); + + const result = buildAuthResponse(user); + + // If there's a state param with redirect URL, redirect with token + const redirectUrl = req.query.state; + if (redirectUrl) { + const sep = redirectUrl.includes('?') ? '&' : '?'; + return reply.redirect(`${redirectUrl}${sep}token=${result.data.token}`); + } + + return result; + }); + + // Google OAuth - mobile/SPA token exchange (for clients that get the code directly) + app.post('/auth/google/token', async (req) => { + const { id_token, access_token } = req.body; + if (!id_token && !access_token) throw { statusCode: 400, message: 'id_token or access_token required' }; + + let gUser; + if (access_token) { + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${access_token}` } + }); + gUser = await userRes.json(); + } else { + // Verify id_token + const verifyRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${id_token}`); + gUser = await verifyRes.json(); + if (gUser.aud !== GOOGLE_CLIENT_ID) throw { statusCode: 401, message: 'Invalid token audience' }; + } + + if (!gUser.email) throw { statusCode: 401, message: 'Could not get email from Google' }; + + const user = await findOrCreateOAuthUser( + gUser.email, + gUser.name || gUser.given_name, + gUser.picture, + 'google', + gUser.sub || gUser.id + ); + + return buildAuthResponse(user); + }); + + // ─── Facebook OAuth ─────────────────────────────────────────── + + // Facebook OAuth - initiate login flow + app.get('/auth/facebook', async (req, reply) => { + if (!FACEBOOK_APP_ID) { + return { status: 'not_configured', message: 'Facebook OAuth not configured. Set FACEBOOK_APP_ID and FACEBOOK_APP_SECRET env vars.' }; + } + const params = new URLSearchParams({ + client_id: FACEBOOK_APP_ID, + redirect_uri: `${CALLBACK_BASE}/api/v1/auth/facebook/callback`, + scope: 'email,public_profile', + response_type: 'code' + }); + return reply.redirect(`https://www.facebook.com/v18.0/dialog/oauth?${params}`); + }); + + // Facebook OAuth - callback + app.get('/auth/facebook/callback', async (req, reply) => { + const { code, error, error_description } = req.query; + if (error) throw { statusCode: 400, message: `Facebook auth error: ${error_description || error}` }; + if (!code) throw { statusCode: 400, message: 'No authorization code provided' }; + + // Exchange code for access token + const tokenParams = new URLSearchParams({ + client_id: FACEBOOK_APP_ID, + redirect_uri: `${CALLBACK_BASE}/api/v1/auth/facebook/callback`, + client_secret: FACEBOOK_APP_SECRET, + code + }); + const tokenRes = await fetch(`https://graph.facebook.com/v18.0/oauth/access_token?${tokenParams}`); + const tokens = await tokenRes.json(); + if (!tokens.access_token) { + app.log.error({ tokens }, 'Facebook token exchange failed'); + throw { statusCode: 401, message: 'Facebook authentication failed' }; + } + + // Get user profile from Facebook + const userRes = await fetch(`https://graph.facebook.com/me?fields=id,name,email,picture.type(large)&access_token=${tokens.access_token}`); + const fbUser = await userRes.json(); + + const user = await findOrCreateOAuthUser( + fbUser.email, + fbUser.name, + fbUser.picture?.data?.url, + 'facebook', + fbUser.id + ); + + const result = buildAuthResponse(user); + + // Redirect if state was provided + const redirectUrl = req.query.state; + if (redirectUrl) { + const sep = redirectUrl.includes('?') ? '&' : '?'; + return reply.redirect(`${redirectUrl}${sep}token=${result.data.token}`); + } + + return result; + }); + + // Facebook OAuth - mobile/SPA token exchange + app.post('/auth/facebook/token', async (req) => { + const { access_token } = req.body; + if (!access_token) throw { statusCode: 400, message: 'access_token required' }; + + const userRes = await fetch(`https://graph.facebook.com/me?fields=id,name,email,picture.type(large)&access_token=${access_token}`); + const fbUser = await userRes.json(); + if (fbUser.error) throw { statusCode: 401, message: fbUser.error.message }; + + const user = await findOrCreateOAuthUser( + fbUser.email, + fbUser.name, + fbUser.picture?.data?.url, + 'facebook', + fbUser.id + ); + + return buildAuthResponse(user); + }); + + // ─── Apple Sign In ──────────────────────────────────────────── + + // Apple Sign In - callback (Apple sends POST with id_token) + app.post('/auth/apple/callback', async (req) => { + // Apple sends: id_token, code, state, user (JSON string on first auth) + const { id_token, user: userJson } = req.body; + if (!id_token) throw { statusCode: 400, message: 'id_token required from Apple' }; + + // Decode JWT payload (Apple id_token is a standard JWT) + // In production, verify signature against Apple's public keys + const parts = id_token.split('.'); + if (parts.length !== 3) throw { statusCode: 400, message: 'Invalid id_token format' }; + + let payload; + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + } catch (e) { + throw { statusCode: 400, message: 'Could not decode id_token' }; + } + + const email = payload.email; + if (!email) throw { statusCode: 400, message: 'No email in Apple id_token' }; + + // Apple only sends user info on FIRST authorization + let name = 'Apple User'; + if (userJson) { + try { + const appleUser = typeof userJson === 'string' ? JSON.parse(userJson) : userJson; + if (appleUser.name) { + name = [appleUser.name.firstName, appleUser.name.lastName].filter(Boolean).join(' '); + } + } catch (e) { /* ignore parse errors */ } + } + + const user = await findOrCreateOAuthUser( + email, + name, + null, + 'apple', + payload.sub + ); + + return buildAuthResponse(user); + }); + + // ─── OAuth Status ───────────────────────────────────────────── + + // Check which OAuth providers are configured + app.get('/auth/providers', async () => { + return { + data: { + email: true, + google: !!GOOGLE_CLIENT_ID, + facebook: !!FACEBOOK_APP_ID, + apple: true // Apple Sign In works with just client-side config + } + }; + }); +} + +module.exports = oauthRoutes; diff --git a/mobile/package.json b/mobile/package.json index c638d6d..80449c2 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -4,8 +4,8 @@ "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "export:web": "expo export --platform web", "lint": "expo lint"