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

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;