OAuth routes (Google/Facebook/Apple) + Android web export

- Google OAuth: /auth/google + /auth/google/callback
- Facebook OAuth: /auth/facebook + /auth/facebook/callback
- Apple Sign In placeholder
- Expo web export in mobile/dist/
- passport, passport-google-oauth20, passport-facebook installed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 16:13:17 +00:00
parent 4e4bf34393
commit acc88606fa
6 changed files with 412 additions and 15 deletions

108
api/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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" });

View File

@@ -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) => {

296
api/src/routes/oauth.js Normal file
View File

@@ -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;

View File

@@ -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"