diff --git a/src/backend/src/config.js b/src/backend/src/config.js
index 6fa4d4652b..681269b460 100644
--- a/src/backend/src/config.js
+++ b/src/backend/src/config.js
@@ -24,6 +24,28 @@ let config = {};
// Static defaults
config.servers = [];
+config.services = config.services ?? {};
+
+{
+ const defaultGoogleOAuthConfig = {
+ enabled: false,
+ client_id: null,
+ client_secret: null,
+ redirect_uri: null,
+ callback_path: '/auth/google/callback',
+ scopes: ['openid', 'email', 'profile'],
+ access_type: 'online',
+ prompt: 'select_account',
+ allowed_domains: [],
+ allow_signup: true,
+ };
+
+ const existing = config.services['google-oauth'] ?? {};
+ config.services['google-oauth'] = {
+ ...defaultGoogleOAuthConfig,
+ ...existing,
+ };
+}
config.disable_user_signup = false;
config.default_user_group = '78b1b1dd-c959-44d2-b02c-8735671f9997';
diff --git a/src/backend/src/routers/auth/google.js b/src/backend/src/routers/auth/google.js
new file mode 100644
index 0000000000..8561b85486
--- /dev/null
+++ b/src/backend/src/routers/auth/google.js
@@ -0,0 +1,521 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+"use strict";
+
+const express = require('express');
+const crypto = require('crypto');
+const { v4: uuidv4 } = require('uuid');
+
+const config = require('../../config');
+const { DB_WRITE } = require('../../services/database/consts');
+const { username_exists, invalidate_cached_user_by_id } = require('../../helpers');
+const { generate_identifier } = require('../../util/identifier');
+
+const router = new express.Router();
+
+const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
+const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
+const GOOGLE_TOKENINFO_URL = 'https://oauth2.googleapis.com/tokeninfo';
+
+const getGoogleConfig = () => config.services?.['google-oauth'] ?? {};
+
+const getOriginUrl = () => new URL(config.origin);
+
+const formatScopes = scopes => {
+ if ( Array.isArray(scopes) && scopes.length > 0 ) {
+ return scopes.join(' ');
+ }
+ if ( typeof scopes === 'string' && scopes.trim() !== '' ) {
+ return scopes;
+ }
+ return 'openid email profile';
+};
+
+const resolveRedirectTarget = raw => {
+ const fallback = config.origin;
+ if ( ! raw ) return fallback;
+ try {
+ const base = getOriginUrl();
+ const candidate = new URL(raw, base);
+ if ( candidate.origin !== base.origin ) {
+ return fallback;
+ }
+ return candidate.toString();
+ } catch ( e ) {
+ return fallback;
+ }
+};
+
+const appendQueryParams = (target, params) => {
+ let url;
+ try {
+ url = new URL(target);
+ } catch ( e ) {
+ url = new URL(config.origin);
+ }
+ for ( const [key, value] of Object.entries(params) ) {
+ if ( typeof value === 'undefined' || value === null ) continue;
+ url.searchParams.set(key, value);
+ }
+ return url.toString();
+};
+
+const buildStatePayload = ({ redirect, referral_code }) => {
+ const payload = {
+ nonce: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'),
+ redirect,
+ };
+ if ( referral_code ) {
+ payload.referral_code = referral_code;
+ }
+ return payload;
+};
+
+const sanitizeUsernameCandidate = candidate => {
+ if ( ! candidate ) return '';
+ return candidate
+ .toLowerCase()
+ .replace(/[^a-z0-9_]/g, '')
+ .replace(/_{2,}/g, '_')
+ .replace(/^_+/, '')
+ .slice(0, config.username_max_length);
+};
+
+const randomUsername = () => sanitizeUsernameCandidate(generate_identifier());
+
+const generateUsernameFromProfile = async (profile, email) => {
+ const candidates = [];
+ if ( profile?.preferred_username ) candidates.push(profile.preferred_username);
+ if ( profile?.given_name && profile?.family_name ) {
+ candidates.push(`${profile.given_name}.${profile.family_name}`);
+ candidates.push(`${profile.given_name}${profile.family_name}`);
+ }
+ if ( profile?.given_name ) candidates.push(profile.given_name);
+ if ( profile?.name ) candidates.push(profile.name);
+ if ( typeof email === 'string' && email.includes('@') ) {
+ candidates.push(email.split('@')[0]);
+ }
+
+ for ( const candidate of candidates ) {
+ let base = sanitizeUsernameCandidate(candidate);
+ if ( base.length < 3 ) continue;
+
+ let attempt = 0;
+ let username = base;
+ while ( await username_exists(username) ) {
+ attempt += 1;
+ if ( attempt > 5 ) break;
+ const suffix = crypto.randomInt(0, 10_000).toString().padStart(4, '0');
+ username = sanitizeUsernameCandidate(`${base}_${suffix}`);
+ if ( username.length < 3 ) {
+ username = sanitizeUsernameCandidate(`${base}${suffix}`);
+ }
+ if ( username === '' ) {
+ username = randomUsername();
+ }
+ }
+
+ if ( username.length >= 3 && ! await username_exists(username) ) {
+ return username;
+ }
+ }
+
+ // Fallback strategy
+ let fallback = randomUsername();
+ while ( await username_exists(fallback) ) {
+ fallback = randomUsername();
+ }
+ return fallback;
+};
+
+const coerceMetadata = existing => {
+ if ( ! existing ) return {};
+ if ( typeof existing === 'string' ) {
+ try {
+ return JSON.parse(existing);
+ } catch ( _ ) {
+ return {};
+ }
+ }
+ if ( typeof existing === 'object' ) return { ...existing };
+ return {};
+};
+
+const mergeGoogleMetadata = (existing, tokenInfo, tokenResponse) => {
+ const metadata = coerceMetadata(existing);
+ metadata.oauth_accounts = metadata.oauth_accounts ?? {};
+ metadata.oauth_accounts.google = {
+ sub: tokenInfo.sub,
+ email: tokenInfo.email,
+ picture: tokenInfo.picture ?? null,
+ name: tokenInfo.name ?? null,
+ given_name: tokenInfo.given_name ?? null,
+ family_name: tokenInfo.family_name ?? null,
+ hd: tokenInfo.hd ?? null,
+ locale: tokenInfo.locale ?? null,
+ scope: tokenResponse.scope ?? null,
+ last_sign_in_at: new Date().toISOString(),
+ };
+ return metadata;
+};
+
+const domainFromEmail = email => {
+ if ( typeof email !== 'string' ) return null;
+ const parts = email.split('@');
+ if ( parts.length !== 2 ) return null;
+ return parts[1].toLowerCase();
+};
+
+const isDomainAllowed = (email, allowedDomains) => {
+ if ( ! Array.isArray(allowedDomains) || allowedDomains.length === 0 ) return true;
+ const cleaned = allowedDomains
+ .filter(Boolean)
+ .map(v => v.toLowerCase());
+ if ( cleaned.length === 0 ) return true;
+ const emailDomain = domainFromEmail(email);
+ if ( ! emailDomain ) return false;
+ return cleaned.includes(emailDomain);
+};
+
+const computeRedirectUri = providerConfig => {
+ if ( typeof providerConfig.redirect_uri === 'string' && providerConfig.redirect_uri.trim() ) {
+ return providerConfig.redirect_uri.trim();
+ }
+ const callbackPath = providerConfig.callback_path ?? '/auth/google/callback';
+ const base = getOriginUrl();
+ return new URL(callbackPath, base).toString();
+};
+
+const fetchJson = async (...args) => {
+ const response = await fetch(...args);
+ let body;
+ try {
+ body = await response.json();
+ } catch ( _ ) {
+ body = {};
+ }
+ return { response, body };
+};
+
+router.get('/auth/google', async (req, res) => {
+ const provider = getGoogleConfig();
+
+ if ( provider.enabled !== true ) {
+ return res.status(404).send('Google SSO is not enabled.');
+ }
+
+ if ( ! provider.client_id || ! provider.client_secret ) {
+ return res.status(500).send('Google SSO is misconfigured.');
+ }
+
+ const redirect = resolveRedirectTarget(req.query.redirect);
+ const referral_code = typeof req.query.referral_code === 'string'
+ ? req.query.referral_code
+ : undefined;
+ const statePayload = buildStatePayload({ redirect, referral_code });
+
+ const svc_token = req.services.get('token');
+ const stateToken = svc_token.sign('oauth-state', statePayload, { expiresIn: '10m' });
+
+ const authUrl = new URL(GOOGLE_AUTH_URL);
+ authUrl.searchParams.set('client_id', provider.client_id);
+ authUrl.searchParams.set('redirect_uri', computeRedirectUri(provider));
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('scope', formatScopes(provider.scopes));
+ authUrl.searchParams.set('state', stateToken);
+
+ if ( provider.access_type ) {
+ authUrl.searchParams.set('access_type', provider.access_type);
+ }
+ if ( provider.prompt ) {
+ authUrl.searchParams.set('prompt', provider.prompt);
+ }
+
+ const allowed = Array.isArray(provider.allowed_domains)
+ ? provider.allowed_domains.filter(Boolean)
+ : [];
+ if ( allowed.length === 1 ) {
+ authUrl.searchParams.set('hd', allowed[0]);
+ } else if ( typeof provider.hosted_domain === 'string' && provider.hosted_domain.trim() ) {
+ authUrl.searchParams.set('hd', provider.hosted_domain.trim());
+ }
+
+ return res.redirect(authUrl.toString());
+});
+
+router.get('/auth/google/callback', async (req, res) => {
+ const provider = getGoogleConfig();
+
+ const sendError = (redirectTarget, code, message) => {
+ const target = appendQueryParams(redirectTarget, {
+ error: code,
+ message,
+ });
+ return res.redirect(target);
+ };
+
+ if ( provider.enabled !== true ) {
+ return res.status(404).send('Google SSO is not enabled.');
+ }
+
+ if ( ! provider.client_id || ! provider.client_secret ) {
+ return res.status(500).send('Google SSO is misconfigured.');
+ }
+
+ const svc_token = req.services.get('token');
+
+ let statePayload;
+ try {
+ statePayload = svc_token.verify('oauth-state', req.query.state);
+ } catch ( e ) {
+ const fallback = resolveRedirectTarget(req.query.redirect);
+ return sendError(fallback, 'google_state_invalid', 'Authentication state is invalid or expired.');
+ }
+
+ const redirectTarget = resolveRedirectTarget(statePayload.redirect);
+
+ if ( ! req.query.code ) {
+ return sendError(redirectTarget, 'google_missing_code', 'Missing authorization code.');
+ }
+
+ const redirectUri = computeRedirectUri(provider);
+
+ const tokenRequestBody = new URLSearchParams({
+ code: req.query.code,
+ client_id: provider.client_id,
+ client_secret: provider.client_secret,
+ redirect_uri: redirectUri,
+ grant_type: 'authorization_code',
+ });
+
+ let tokenData;
+ try {
+ const { response, body } = await fetchJson(GOOGLE_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: tokenRequestBody,
+ });
+ tokenData = body;
+
+ if ( ! response.ok ) {
+ const description = body.error_description ?? 'Failed to exchange authorization code.';
+ return sendError(redirectTarget, 'google_token_exchange_failed', description);
+ }
+ } catch ( e ) {
+ return sendError(redirectTarget, 'google_token_exchange_failed', 'Failed to exchange authorization code.');
+ }
+
+ if ( typeof tokenData.id_token !== 'string' ) {
+ return sendError(redirectTarget, 'google_missing_id_token', 'The identity token was not provided.');
+ }
+
+ let tokenInfo;
+ try {
+ const { response, body } = await fetchJson(`${GOOGLE_TOKENINFO_URL}?id_token=${encodeURIComponent(tokenData.id_token)}`);
+ if ( ! response.ok ) {
+ return sendError(redirectTarget, 'google_token_validation_failed', 'Unable to validate the Google identity token.');
+ }
+ tokenInfo = body;
+ } catch ( e ) {
+ return sendError(redirectTarget, 'google_token_validation_failed', 'Unable to validate the Google identity token.');
+ }
+
+ if ( tokenInfo.aud !== provider.client_id ) {
+ return sendError(redirectTarget, 'google_audience_mismatch', 'Google authentication response is intended for a different application.');
+ }
+
+ const email = tokenInfo.email;
+ const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === 'true';
+
+ if ( ! email || ! emailVerified ) {
+ return sendError(redirectTarget, 'google_email_unverified', 'Google account email is not verified.');
+ }
+
+ if ( ! isDomainAllowed(email, provider.allowed_domains) ) {
+ return sendError(redirectTarget, 'google_domain_not_allowed', 'Google account domain is not allowed.');
+ }
+
+ const svc_cleanEmail = req.services.get('clean-email');
+ const cleanEmail = svc_cleanEmail.clean(email);
+
+ if ( ! await svc_cleanEmail.validate(cleanEmail) ) {
+ return sendError(redirectTarget, 'google_email_invalid', 'Google account email is not allowed.');
+ }
+
+ const svc_getUser = req.services.get('get-user');
+
+ let user = await svc_getUser.get_user({ email, cached: false });
+
+ if ( ! user ) {
+ const db = req.services.get('database').get(DB_WRITE, 'auth');
+ const existing = await db.pread('SELECT id FROM `user` WHERE `clean_email` = ? LIMIT 1', [cleanEmail]);
+ if ( Array.isArray(existing) && existing[0] ) {
+ user = await svc_getUser.get_user({ id: existing[0].id, cached: false, force: true });
+ }
+ }
+
+ const db = req.services.get('database').get(DB_WRITE, 'auth');
+ const svc_auth = req.services.get('auth');
+
+ const metadata = mergeGoogleMetadata(user?.metadata, tokenInfo, tokenData);
+
+ if ( user ) {
+ if ( user.suspended ) {
+ return sendError(redirectTarget, 'google_account_suspended', 'This account is suspended.');
+ }
+
+ const emailMatches = (
+ (user.email && user.email.toLowerCase() === email.toLowerCase()) ||
+ (user.clean_email && user.clean_email.toLowerCase() === cleanEmail.toLowerCase())
+ );
+
+ if ( ! emailMatches ) {
+ return sendError(redirectTarget, 'google_account_mismatch', 'Google account email does not match your Puter account.');
+ }
+
+ const updateFields = [];
+ const values = [];
+
+ if ( ! user.email || user.email.toLowerCase() !== email.toLowerCase() ) {
+ updateFields.push('email = ?');
+ values.push(email);
+ }
+
+ if ( ! user.clean_email || user.clean_email.toLowerCase() !== cleanEmail.toLowerCase() ) {
+ updateFields.push('clean_email = ?');
+ values.push(cleanEmail);
+ }
+
+ updateFields.push('email_confirmed = 1');
+ updateFields.push('requires_email_confirmation = 0');
+
+ updateFields.push('metadata = ?');
+ values.push(JSON.stringify(metadata));
+
+ values.push(user.id);
+
+ await db.write(
+ `UPDATE \`user\` SET ${updateFields.join(', ')} WHERE id = ?`,
+ values
+ );
+
+ invalidate_cached_user_by_id(user.id);
+ user = await svc_getUser.get_user({ id: user.id, cached: false, force: true });
+ } else {
+ if ( provider.allow_signup !== true ) {
+ return sendError(redirectTarget, 'google_signup_disabled', 'Creating accounts via Google is disabled.');
+ }
+
+ const username = await generateUsernameFromProfile(tokenInfo, email);
+ const userUuid = uuidv4();
+ const emailConfirmToken = uuidv4();
+ const emailConfirmCode = Math.floor(100000 + Math.random() * 900000);
+
+ let referredById = null;
+ const referralCode = typeof statePayload.referral_code === 'string'
+ ? statePayload.referral_code
+ : undefined;
+
+ if ( referralCode ) {
+ const referredBy = await svc_getUser.get_user({ referral_code: referralCode, cached: false });
+ if ( referredBy ) {
+ referredById = referredBy.id;
+ }
+ }
+
+ const audit_metadata = {
+ ip: req.connection.remoteAddress,
+ ip_fwd: req.headers['x-forwarded-for'],
+ user_agent: req.headers['user-agent'],
+ origin: req.headers['origin'],
+ server: config.server_id,
+ provider: 'google',
+ };
+
+ const insertResult = await db.write(`INSERT INTO user
+ (
+ username, email, clean_email, password, uuid, referrer,
+ email_confirm_code, email_confirm_token, free_storage,
+ referred_by, audit_metadata, signup_ip, signup_ip_forwarded,
+ signup_user_agent, signup_origin, signup_server
+ )
+ VALUES
+ (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ username,
+ email,
+ cleanEmail,
+ null,
+ userUuid,
+ null,
+ '' + emailConfirmCode,
+ emailConfirmToken,
+ config.storage_capacity,
+ referredById,
+ JSON.stringify(audit_metadata),
+ req.connection.remoteAddress ?? null,
+ req.headers['x-forwarded-for'] ?? null,
+ req.headers['user-agent'] ?? null,
+ req.headers['origin'] ?? null,
+ config.server_id ?? null,
+ ]);
+
+ const userId = insertResult.insertId;
+
+ await db.write(
+ 'UPDATE `user` SET email_confirmed = 1, requires_email_confirmation = 0, metadata = ? WHERE id = ?',
+ [JSON.stringify(metadata), userId]
+ );
+
+ const svc_group = req.services.get('group');
+ await svc_group.add_users({
+ uid: config.default_user_group,
+ users: [username],
+ });
+
+ invalidate_cached_user_by_id(userId);
+ user = await svc_getUser.get_user({ id: userId, cached: false, force: true });
+
+ const svc_user = req.services.get('user');
+ await svc_user.generate_default_fsentries({ user });
+
+ try {
+ const svc_event = req.services.get('event');
+ if ( svc_event ) {
+ svc_event.emit('user.save_account', { user });
+ }
+ } catch ( _ ) {
+ // Event service is optional; ignore if unavailable.
+ }
+ }
+
+ const { token } = await svc_auth.create_session_token(user, { req });
+
+ res.cookie(config.cookie_name, token, {
+ sameSite: 'none',
+ secure: true,
+ httpOnly: true,
+ });
+
+ return res.redirect(redirectTarget);
+});
+
+module.exports = router;
diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js
index d3b588fc7e..4591b697f6 100644
--- a/src/backend/src/services/PuterAPIService.js
+++ b/src/backend/src/services/PuterAPIService.js
@@ -50,6 +50,7 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/auth/list-sessions'))
app.use(require('../routers/auth/revoke-session'))
app.use(require('../routers/auth/check-app'))
+ app.use(require('../routers/auth/google'))
app.use(require('../routers/auth/app-uid-from-origin'))
app.use(require('../routers/auth/create-access-token'))
app.use(require('../routers/auth/delete-own-user'))
diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js
index dcf2dff7ec..f3b9a1bcdb 100644
--- a/src/backend/src/services/PuterHomepageService.js
+++ b/src/backend/src/services/PuterHomepageService.js
@@ -169,6 +169,7 @@ class PuterHomepageService extends BaseService {
co_isolation_enabled: req.co_isolation_enabled,
// Add captcha requirements to GUI parameters
captchaRequired: captchaRequired,
+ google_oauth_enabled: config.services?.['google-oauth']?.enabled === true,
turnstileSiteKey: turnstileSiteKey,
},
}));
diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js
index 463ae61a1f..8b90c8b8a6 100644
--- a/src/gui/src/UI/UIWindowLogin.js
+++ b/src/gui/src/UI/UIWindowLogin.js
@@ -73,6 +73,9 @@ async function UIWindowLogin(options){
h += ``;
// login
h += ``;
+ if(window.gui_params?.google_oauth_enabled){
+ h += ``;
+ }
// password recovery
h += `
${i18n('forgot_pass_c2a')}
`;
h += ``;
@@ -141,6 +144,26 @@ async function UIWindowLogin(options){
});
})
+ if(window.gui_params?.google_oauth_enabled){
+ $(el_window).find('.login-google-btn').on('click', function(){
+ let redirectTarget = window.location.href;
+ try {
+ redirectTarget = new URL(window.location.href).toString();
+ } catch ( e ) {
+ redirectTarget = window.gui_origin;
+ }
+
+ const authUrl = new URL('/auth/google', window.gui_origin);
+ authUrl.searchParams.set('redirect', redirectTarget);
+
+ if ( window.referral_code ) {
+ authUrl.searchParams.set('referral_code', window.referral_code);
+ }
+
+ window.location.href = authUrl.toString();
+ });
+ }
+
$(el_window).find('.login-btn').on('click', function(e){
// Prevent default button behavior (important for async requests)
e.preventDefault();