diff --git a/eslint.config.js b/eslint.config.js index 442ff39aa..996c17fcf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -65,6 +65,7 @@ export const rules = { }], '@stylistic/array-bracket-spacing': ['error', 'never'], '@stylistic/linebreak-style': ['error', 'unix'], + 'no-useless-computed-key': 'error', 'no-sequences': [ 'error', { allowInParentheses: false, diff --git a/extensions/whoami/routes.js b/extensions/whoami/routes.js index 9184ce50e..391904595 100644 --- a/extensions/whoami/routes.js +++ b/extensions/whoami/routes.js @@ -76,6 +76,7 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { } } + const oidc_only = req.user.password === null; const details = { username: req.user.username, uuid: req.user.uuid, @@ -88,6 +89,23 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => { desktop_bg_color: req.user.desktop_bg_color, desktop_bg_fit: req.user.desktop_bg_fit, is_temp: (req.user.password === null && req.user.email === null), + oidc_only, + ...(oidc_only ? await (async () => { + try { + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + const origin = (svc_oidc.global_config?.origin || '').replace(/\/$/, ''); + const provider = providers && providers[0]; + if ( provider ) { + return { + oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}`, + }; + } + return {}; + } catch ( _e ) { + return {}; + } + })() : {}), taskbar_items: await get_taskbar_items(req.user, { ...(req.query.icon_size ? { icon_size: req.query.icon_size } @@ -216,6 +234,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => { } } + const oidc_only = req.user.password === null; // send user object res.send(Object.assign({ username: req.user.username, @@ -228,6 +247,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => { desktop_bg_color: req.user.desktop_bg_color, desktop_bg_fit: req.user.desktop_bg_fit, is_temp: (req.user.password === null && req.user.email === null), + oidc_only, taskbar_items: await get_taskbar_items(req.user), desktop_items: desktop_items, referral_code: req.user.referral_code, diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index f8a8270bd..702b00c0e 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -269,6 +269,12 @@ const install = async ({ context, services, app, useapi, modapi }) => { const { OTPService } = require('./services/auth/OTPService'); services.registerService('otp', OTPService); + const { OIDCService } = require('./services/auth/OIDCService'); + services.registerService('oidc', OIDCService); + + const { SignupService } = require('./services/auth/SignupService'); + services.registerService('signup', SignupService); + const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService'); services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); diff --git a/src/backend/src/ExtensionService.js b/src/backend/src/ExtensionService.js index e0bfbffca..197f44543 100644 --- a/src/backend/src/ExtensionService.js +++ b/src/backend/src/ExtensionService.js @@ -153,7 +153,7 @@ class ExtensionService extends BaseService { this.state.extension.emit('preinit'); } - async ['__on_boot.consolidation'] (...a) { + async '__on_boot.consolidation' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('init', {}, { @@ -161,7 +161,7 @@ class ExtensionService extends BaseService { }); }); } - async ['__on_boot.activation'] (...a) { + async '__on_boot.activation' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('activate', {}, { @@ -169,7 +169,7 @@ class ExtensionService extends BaseService { }); }); } - async ['__on_boot.ready'] (...a) { + async '__on_boot.ready' (...a) { const svc_su = this.services.get('su'); await svc_su.sudo(async () => { await this.state.extension.emit('ready', {}, { @@ -178,7 +178,7 @@ class ExtensionService extends BaseService { }); } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { if ( ! this.state ) debugger; for ( const thing of this.state.expressThings_ ) { if ( thing.type === 'endpoint' ) { diff --git a/src/backend/src/Kernel.js b/src/backend/src/Kernel.js index d8b0adafd..6f0de7254 100644 --- a/src/backend/src/Kernel.js +++ b/src/backend/src/Kernel.js @@ -128,7 +128,7 @@ class Kernel extends AdvancedBase { extensionInfo: this.extensionInfo, registry: this.registry, args, - ['runtime-modules']: this.runtimeModuleRegistry, + 'runtime-modules': this.runtimeModuleRegistry, }, 'app'); globalThis.root_context = root_context; @@ -148,7 +148,7 @@ class Kernel extends AdvancedBase { services.registerModule(module_.constructor.name, module_); const mod_context = this._create_mod_context(Context.get(), { name: module_.constructor.name, - ['module']: module_, + 'module': module_, external: false, }); await module_.install(mod_context); @@ -435,7 +435,7 @@ class Kernel extends AdvancedBase { const mod_context = this._create_mod_context(mod_install_root_context, { name: mod_name, - ['module']: mod, + 'module': mod, external: true, mod_path, }); diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 9b0879bdb..d8cb3a3e1 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -453,6 +453,10 @@ class APIError { status: 403, message: 'This endpoint must be requested with a user session', }, + 'session_required': { + status: 403, + message: 'This endpoint requires a full session (e.g. change password cannot be done with a GUI token).', + }, 'temporary_accounts_not_allowed': { status: 403, message: 'Temporary accounts cannot perform this action', @@ -465,6 +469,10 @@ class APIError { status: 403, message: 'Password does not match.', }, + 'oidc_revalidation_required': { + status: 403, + message: 'Re-validate by signing in with your linked account (e.g. Google).', + }, // Object Mapping 'field_not_allowed_for_create': { diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 95d988a28..1d3a5673e 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -59,6 +59,12 @@ config.captcha = { difficulty: 'medium', // Default difficulty level }; +// OIDC/OAuth2 providers (e.g. Google). Keys in config only, not env vars. +// Example: config.oidc.providers.google = { client_id, client_secret } +config.oidc = { + providers: {}, +}; + config.monitor = { metricsInterval: 60000, windowSize: 30, diff --git a/src/backend/src/filesystem/hl_operations/hl_stat.js b/src/backend/src/filesystem/hl_operations/hl_stat.js index 034555ac7..71a350500 100644 --- a/src/backend/src/filesystem/hl_operations/hl_stat.js +++ b/src/backend/src/filesystem/hl_operations/hl_stat.js @@ -24,7 +24,7 @@ const { NodeUIDSelector } = require('../node/selectors'); class HLStat extends HLFilesystemOperation { static MODULES = { - ['mime-types']: require('mime-types'), + 'mime-types': require('mime-types'), }; async _run () { diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index 724bcc68d..73e793694 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -34,6 +34,7 @@ import { Context } from './util/context.js'; import { ManagedError } from './util/errorutil.js'; import { kv } from './util/kvSingleton.js'; import { spanify } from './util/otelutil.js'; +import { generate_identifier } from './util/identifier.js'; export * from './validation.js'; @@ -1487,6 +1488,14 @@ export async function username_exists (username) { } } +export async function generate_random_username () { + let username; + do { + username = generate_identifier(); + } while ( await username_exists(username) ); + return username; +} + export async function app_name_exists (name) { /** @type BaseDatabaseAccessService */ const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem'); diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index e65fdb279..c2e854318 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -20,6 +20,7 @@ const APIError = require('../api/APIError'); const config = require('../config'); const { LegacyTokenError } = require('../services/auth/AuthService'); const { Context } = require('../util/context'); +const jwt = require('jsonwebtoken'); // The "/whoami" endpoint is a special case where we want to allow // a legacy token to be used for authentication. The "/whoami" @@ -47,7 +48,7 @@ const configurable_auth = options => async (req, res, next) => { const optional = options?.optional; // Request might already have been authed (PreAuthService) - if ( req.actor ) next(); + if ( req.actor ) return next(); // === Getting the Token === // This step came from jwt_auth in src/helpers.js @@ -55,15 +56,18 @@ const configurable_auth = options => async (req, res, next) => { // auth middleware, it makes more sense to put it here. let token; + let tokenSource; // Auth token in body if ( req.body && req.body.auth_token ) { token = req.body.auth_token; + tokenSource = 'body'; } // HTTML Auth header else if ( req.header && req.header('Authorization') && !req.header('Authorization').startsWith('Basic ') && req.header('Authorization') !== 'Bearer' ) { // Bearer with no space is something office does token = req.header('Authorization'); token = token.replace('Bearer ', '').trim(); + tokenSource = 'header'; if ( token === 'undefined' ) { APIError.create('unexpected_undefined', null, { msg: 'The Authorization token cannot be the string "undefined"', @@ -74,16 +78,19 @@ const configurable_auth = options => async (req, res, next) => { else if ( req.cookies && req.cookies[config.cookie_name] ) { token = req.cookies[config.cookie_name]; + tokenSource = 'cookie'; } // Auth token in URL else if ( req.query && req.query.auth_token ) { token = req.query.auth_token; + tokenSource = 'query'; } // Socket else if ( req.handshake && req.handshake.query && req.handshake.query.auth_token ) { token = req.handshake.query.auth_token; + tokenSource = 'socket'; } if ( !token || token.startsWith('Basic ') ) { @@ -134,7 +141,8 @@ const configurable_auth = options => async (req, res, next) => { throw APIError.create('forbidden'); } - res.cookie(config.cookie_name, new_info.token, { + // Use session token in cookie so cookie-based requests have hasHttpOnlyCookie; client gets GUI token in response + res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, { sameSite: 'none', secure: true, httpOnly: true, diff --git a/src/backend/src/modules/apps/AppIconService.js b/src/backend/src/modules/apps/AppIconService.js index 3983e3297..4b3e1a24a 100644 --- a/src/backend/src/modules/apps/AppIconService.js +++ b/src/backend/src/modules/apps/AppIconService.js @@ -17,13 +17,13 @@ * along with this program. If not, see . */ -import config from '../../config.js'; import { createRequire } from 'node:module'; +import config from '../../config.js'; +import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js'; import { HLWrite } from '../../filesystem/hl_operations/hl_write.js'; import { LLMkdir } from '../../filesystem/ll_operations/ll_mkdir.js'; import { LLRead } from '../../filesystem/ll_operations/ll_read.js'; import { NodePathSelector } from '../../filesystem/node/selectors.js'; -import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js'; import { get_app, get_user } from '../../helpers.js'; import BaseService from '../../services/BaseService.js'; import { DB_WRITE } from '../../services/database/consts.js'; @@ -69,7 +69,7 @@ export class AppIconService extends BaseService { * endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size * which serve the app icon at the requested size. */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { const handler = async (req, res) => { // Validate parameters let { app_uid: appUid, size } = req.params; @@ -686,7 +686,7 @@ export class AppIconService extends BaseService { * `/system/app_icons` directory if it does not exist, * and then to register the event listener for `app.new-icon`. */ - async ['__on_user.system-user-ready'] () { + async '__on_user.system-user-ready' () { const svcSu = this.services.get('su'); const svcUser = this.services.get('user'); diff --git a/src/backend/src/modules/apps/OldAppNameService.js b/src/backend/src/modules/apps/OldAppNameService.js index ca52acab6..8e00acec6 100644 --- a/src/backend/src/modules/apps/OldAppNameService.js +++ b/src/backend/src/modules/apps/OldAppNameService.js @@ -29,7 +29,7 @@ class OldAppNameService extends BaseService { this.db = this.services.get('database').get(DB_READ, 'old-app-name'); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); svc_event.on('app.rename', async (_, { app_uid, old_name }) => { this.log.info('GOT EVENT', { app_uid, old_name }); diff --git a/src/backend/src/modules/apps/RecommendedAppsService.js b/src/backend/src/modules/apps/RecommendedAppsService.js index 322a88466..5e06daf39 100644 --- a/src/backend/src/modules/apps/RecommendedAppsService.js +++ b/src/backend/src/modules/apps/RecommendedAppsService.js @@ -70,7 +70,7 @@ export default class RecommendedAppsService extends BaseService { this.app_names = new Set(RecommendedAppsService.APP_NAMES); } - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const svc_appIcon = this.services.get('app-icon'); const svc_event = this.services.get('event'); svc_event.on('apps.invalidate', async (_, { app }) => { diff --git a/src/backend/src/modules/broadcast/BroadcastService.js b/src/backend/src/modules/broadcast/BroadcastService.js index e2d3a4539..fb11cfb47 100644 --- a/src/backend/src/modules/broadcast/BroadcastService.js +++ b/src/backend/src/modules/broadcast/BroadcastService.js @@ -98,7 +98,7 @@ class BroadcastService extends BaseService { } } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { const svc_web = this.services.get('web-server'); svc_web.allow_undefined_origin('/broadcast/webhook'); @@ -253,7 +253,7 @@ class BroadcastService extends BaseService { } } - async ['__on_install.websockets'] () { + async '__on_install.websockets' () { const svc_event = this.services.get('event'); const svc_webServer = this.services.get('web-server'); diff --git a/src/backend/src/modules/captcha/services/CaptchaService.js b/src/backend/src/modules/captcha/services/CaptchaService.js index 3919f50ce..74dccf40b 100644 --- a/src/backend/src/modules/captcha/services/CaptchaService.js +++ b/src/backend/src/modules/captcha/services/CaptchaService.js @@ -60,7 +60,7 @@ class CaptchaService extends BaseService { this.endpointsRegistered = false; } - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { // Add express middleware app.use(checkCaptcha({ svc_captcha: this })); } diff --git a/src/backend/src/modules/core/AlarmService.js b/src/backend/src/modules/core/AlarmService.js index cc8bb2715..ddbe8ebcb 100644 --- a/src/backend/src/modules/core/AlarmService.js +++ b/src/backend/src/modules/core/AlarmService.js @@ -61,7 +61,7 @@ class AlarmService extends BaseService { * AlarmService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._register_commands(this.services.get('commands')); } diff --git a/src/backend/src/modules/core/ExpectationService.js b/src/backend/src/modules/core/ExpectationService.js index ebe91e524..8506ea454 100644 --- a/src/backend/src/modules/core/ExpectationService.js +++ b/src/backend/src/modules/core/ExpectationService.js @@ -52,7 +52,7 @@ class ExpectationService extends BaseService { * ExpectationService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const commands = this.services.get('commands'); commands.registerCommands('expectations', [ { diff --git a/src/backend/src/modules/core/LogService.js b/src/backend/src/modules/core/LogService.js index 365ac0254..5edd2bc7e 100644 --- a/src/backend/src/modules/core/LogService.js +++ b/src/backend/src/modules/core/LogService.js @@ -398,7 +398,7 @@ class LogService extends BaseService { /** * Registers logging commands with the command service. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const commands = this.services.get('commands'); commands.registerCommands('logs', [ { diff --git a/src/backend/src/modules/core/PagerService.js b/src/backend/src/modules/core/PagerService.js index 1f4fc6aad..648fba2ca 100644 --- a/src/backend/src/modules/core/PagerService.js +++ b/src/backend/src/modules/core/PagerService.js @@ -44,7 +44,7 @@ class PagerService extends BaseService { * PagerService registers its commands at the consolidation phase because * the '_init' method of CommandService may not have been called yet. */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._register_commands(this.services.get('commands')); } diff --git a/src/backend/src/modules/core/ParameterService.js b/src/backend/src/modules/core/ParameterService.js index 84f11c7d8..ccd4206d8 100644 --- a/src/backend/src/modules/core/ParameterService.js +++ b/src/backend/src/modules/core/ParameterService.js @@ -97,7 +97,7 @@ class ParameterService extends BaseService { * for parameter management. * @private */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { this._registerCommands(this.services.get('commands')); } diff --git a/src/backend/src/modules/data-access/AppService.js b/src/backend/src/modules/data-access/AppService.js index 6e1e0d95a..bca8060f7 100644 --- a/src/backend/src/modules/data-access/AppService.js +++ b/src/backend/src/modules/data-access/AppService.js @@ -265,7 +265,7 @@ export default class AppService extends BaseService { static WRITE_ALL_OWNER_PERMISSION = 'system:es:write-all-owners'; static IMPLEMENTS = { - ['crud-q']: { + 'crud-q': { async create ({ object, options }) { return await this.#create({ object, options }); }, diff --git a/src/backend/src/modules/development/LocalTerminalService.js b/src/backend/src/modules/development/LocalTerminalService.js index 8a1f93cf2..e9c246bff 100644 --- a/src/backend/src/modules/development/LocalTerminalService.js +++ b/src/backend/src/modules/development/LocalTerminalService.js @@ -35,7 +35,7 @@ class LocalTerminalService extends BaseService { } get_profiles () { return { - ['api-test']: { + 'api-test': { cwd: path_.join(__dirname, '../../../../../', 'tools/api-tester'), @@ -48,7 +48,7 @@ class LocalTerminalService extends BaseService { }, }; }; - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const r_group = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/modules/domain/TXTVerifyService.js b/src/backend/src/modules/domain/TXTVerifyService.js index 33cebe08b..e5ff7e2ad 100644 --- a/src/backend/src/modules/domain/TXTVerifyService.js +++ b/src/backend/src/modules/domain/TXTVerifyService.js @@ -3,7 +3,7 @@ const BaseService = require('../../services/BaseService'); const { atimeout } = require('../../util/asyncutil'); class TXTVerifyService extends BaseService { - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { const svc_dns = this.services.get('dns'); const dns = svc_dns.get_client(); diff --git a/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js index 08236b408..3b0f3ce0b 100644 --- a/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js +++ b/src/backend/src/modules/entitystore/EntityStoreInterfaceService.js @@ -30,7 +30,7 @@ class EntityStoreInterfaceService extends BaseService { * Service class for managing Entity Store interface registrations. * Extends the base service to provide entity storage interface management. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js index cfb4e7f69..a10a8a402 100644 --- a/src/backend/src/modules/kvstore/KVStoreInterfaceService.js +++ b/src/backend/src/modules/kvstore/KVStoreInterfaceService.js @@ -87,7 +87,7 @@ class KVStoreInterfaceService extends BaseService { * Service class for managing KVStore interface registrations. * Extends the base service to provide key-value store interface management. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/modules/puterfs/MountpointService.js b/src/backend/src/modules/puterfs/MountpointService.js index 5250d8daf..56bfdc980 100644 --- a/src/backend/src/modules/puterfs/MountpointService.js +++ b/src/backend/src/modules/puterfs/MountpointService.js @@ -46,7 +46,7 @@ class MountpointService extends BaseService { this.#mounters[name] = mounter; } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { // Emit event for registering filesystem types const svc_event = this.services.get('event'); const event = {}; diff --git a/src/backend/src/modules/puterfs/SizeService.js b/src/backend/src/modules/puterfs/SizeService.js index 544d92c06..8f615d99a 100644 --- a/src/backend/src/modules/puterfs/SizeService.js +++ b/src/backend/src/modules/puterfs/SizeService.js @@ -41,7 +41,7 @@ class SizeService extends BaseService { } - ['__on_boot.consolidate'] () { + '__on_boot.consolidate' () { const svc_commands = this.services.get('commands'); svc_commands.registerCommands('size', [ { diff --git a/src/backend/src/modules/selfhosted/DefaultUserService.js b/src/backend/src/modules/selfhosted/DefaultUserService.js index ed7f9c1a8..c8a4c30ee 100644 --- a/src/backend/src/modules/selfhosted/DefaultUserService.js +++ b/src/backend/src/modules/selfhosted/DefaultUserService.js @@ -71,7 +71,7 @@ class DefaultUserService extends BaseService { async _init () { this._register_commands(this.services.get('commands')); } - async ['__on_ready.webserver'] () { + async '__on_ready.webserver' () { // check if a user named `admin` exists let user = await get_user({ username: USERNAME, cached: false }); if ( ! user ) { diff --git a/src/backend/src/modules/selfhosted/DevWatcherService.js b/src/backend/src/modules/selfhosted/DevWatcherService.js index 073304340..193c76cbd 100644 --- a/src/backend/src/modules/selfhosted/DevWatcherService.js +++ b/src/backend/src/modules/selfhosted/DevWatcherService.js @@ -67,7 +67,7 @@ class DevWatcherService extends BaseService { // port is set to `auto` - you have no idea how confusing // this was to debug the first time, like Ahhhhhh!! // but hey at least we have this convenient event listener. - async ['__on_ready.webserver'] () { + async '__on_ready.webserver' () { const svc_process = this.services.get('process'); let { root, commands, webpack } = this.args; diff --git a/src/backend/src/modules/selfhosted/ServeSingeFileService.js b/src/backend/src/modules/selfhosted/ServeSingeFileService.js index 5f631e5fe..9723c0a04 100644 --- a/src/backend/src/modules/selfhosted/ServeSingeFileService.js +++ b/src/backend/src/modules/selfhosted/ServeSingeFileService.js @@ -23,7 +23,7 @@ class ServeSingleFileService extends BaseService { this.route = args.route; this.path = args.path; } - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); app.get(this.route, (req, res) => { diff --git a/src/backend/src/modules/selfhosted/ServeStaticFilesService.js b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js index baa1520ad..8408bf957 100644 --- a/src/backend/src/modules/selfhosted/ServeStaticFilesService.js +++ b/src/backend/src/modules/selfhosted/ServeStaticFilesService.js @@ -23,7 +23,7 @@ class ServeStaticFilesService extends BaseService { this.directories = args.directories; } - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); for ( const { prefix, path } of this.directories ) { diff --git a/src/backend/src/modules/template/TemplateService.js b/src/backend/src/modules/template/TemplateService.js index 1cbd042ba..520f7f72e 100644 --- a/src/backend/src/modules/template/TemplateService.js +++ b/src/backend/src/modules/template/TemplateService.js @@ -45,7 +45,7 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to provide an example endpoint */ - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { this.log.info('TemplateService get the event for installing endpoint.'); Endpoint({ route: '/example-endpoint', @@ -61,7 +61,7 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to provide an example event */ - ['__on_boot.consolidation'] () { + '__on_boot.consolidation' () { // At this stage, all services have been initialized and it is // safe to start emitting events. this.log.info('TemplateService sees consolidation boot phase.'); @@ -81,14 +81,14 @@ class TemplateService extends BaseService { /** * TemplateService listens to this event to show you that it's here */ - ['__on_boot.activation'] () { + '__on_boot.activation' () { this.log.info('TemplateService sees activation boot phase.'); } /** * TemplateService listens to this event to show you that it's here */ - ['__on_start.webserver'] () { + '__on_start.webserver' () { this.log.info("TemplateService sees it's time to start web servers."); } } diff --git a/src/backend/src/modules/test-drivers/TestAssetHostService.js b/src/backend/src/modules/test-drivers/TestAssetHostService.js index 93167de62..f367db232 100644 --- a/src/backend/src/modules/test-drivers/TestAssetHostService.js +++ b/src/backend/src/modules/test-drivers/TestAssetHostService.js @@ -20,7 +20,7 @@ const BaseService = require('../../services/BaseService'); class TestAssetHostService extends BaseService { - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); const path_ = require('node:path'); diff --git a/src/backend/src/modules/test-drivers/TestImageService.js b/src/backend/src/modules/test-drivers/TestImageService.js index f646794c1..36034207c 100644 --- a/src/backend/src/modules/test-drivers/TestImageService.js +++ b/src/backend/src/modules/test-drivers/TestImageService.js @@ -31,7 +31,7 @@ const PUBLIC_DOMAIN_IMAGES = [ ]; class TestImageService extends BaseService { - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -68,12 +68,12 @@ class TestImageService extends BaseService { } static IMPLEMENTS = { - ['version']: { + 'version': { get_version () { return 'v1.0.0'; }, }, - ['test-image']: { + 'test-image': { async echo_image ({ source, }) { diff --git a/src/backend/src/modules/web/SocketioService.js b/src/backend/src/modules/web/SocketioService.js index fcd75df83..08669b2a2 100644 --- a/src/backend/src/modules/web/SocketioService.js +++ b/src/backend/src/modules/web/SocketioService.js @@ -31,7 +31,7 @@ class SocketioService extends BaseService { * * @evtparam server The server to attach socket.io to. */ - ['__on_install.socketio'] (_, { server }) { + '__on_install.socketio' (_, { server }) { /** * @type {import('socket.io').Server} */ diff --git a/src/backend/src/modules/web/WebServerService.js b/src/backend/src/modules/web/WebServerService.js index e1219e8c9..cf23ee80c 100644 --- a/src/backend/src/modules/web/WebServerService.js +++ b/src/backend/src/modules/web/WebServerService.js @@ -46,7 +46,7 @@ class WebServerService extends BaseService { helmet: require('helmet'), cookieParser: require('cookie-parser'), compression: require('compression'), - ['on-finished']: require('on-finished'), + 'on-finished': require('on-finished'), morgan: require('morgan'), }; @@ -64,7 +64,7 @@ class WebServerService extends BaseService { * @private */ // comment above line 44 in WebServerService.js - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const app = this.app; const services = this.services; await services.emit('install.middlewares.early', { app }); @@ -115,7 +115,7 @@ class WebServerService extends BaseService { * * @returns {Promise} A promise that resolves once the server is started. */ - async ['__on_boot.activation'] () { + async '__on_boot.activation' () { const services = this.services; await services.emit('start.webserver'); await services.emit('ready.webserver'); @@ -130,7 +130,7 @@ class WebServerService extends BaseService { * * @return {Promise} A promise that resolves when the server is up and running. */ - async ['__on_start.webserver'] () { + async '__on_start.webserver' () { // error handling middleware goes last, as per the // expressjs documentation: // https://expressjs.com/en/guide/error-handling.html @@ -337,6 +337,35 @@ class WebServerService extends BaseService { next(); }); + // When the user visits the main origin (not api/dav subdomain) with ?auth_token= + // (e.g. QR login), set the HTTP-only session cookie so user-protected endpoints work. + app.use(async (req, res, next) => { + const has_subdomain = req.hostname.slice(0, -1 * (config.domain.length + 1)) !== ''; + if ( has_subdomain ) return next(); + + const token = req.query?.auth_token; + if ( !token || typeof token !== 'string' ) return next(); + + try { + const svc_auth = req.services.get('auth'); + const cleanToken = token.replace('Bearer ', '').trim(); + const actor = await svc_auth.authenticate_from_token(cleanToken); + const session_token = svc_auth.create_session_token_for_session( + actor.type.user, + actor.type.session, + ); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + } catch ( e ) { + console.log('query auth token (QR Code login probably) failed'); + console.error(e); + } + next(); + }); + // Measure data transfer amounts app.use(measure()); @@ -627,7 +656,7 @@ class WebServerService extends BaseService { req.co_isolation_enabled ; - if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') ) { + if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') || req.path.startsWith('/auth/oidc') ) { res.setHeader('Access-Control-Allow-Origin', origin ?? '*'); } // Website(s) to allow to connect diff --git a/src/backend/src/om/proptypes/__all__.js b/src/backend/src/om/proptypes/__all__.js index 995212f3a..a48ca7519 100644 --- a/src/backend/src/om/proptypes/__all__.js +++ b/src/backend/src/om/proptypes/__all__.js @@ -282,7 +282,7 @@ module.exports = { return is_valid_uuid4(value); }, }, - ['puter-uuid']: { + 'puter-uuid': { from: 'string', validate (value, { descriptor }) { const prefix = `${descriptor.prefix }-`; @@ -297,7 +297,7 @@ module.exports = { return prefix + uuid; }, }, - ['image-base64']: { + 'image-base64': { from: 'string', is_set (value) { return typeof value === 'string' && value.trim().length > 0; @@ -382,7 +382,7 @@ module.exports = { datetime: { from: 'base', }, - ['puter-node']: { + 'puter-node': { // from: 'base', async sql_reference (value) { if ( value === null ) return null; diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js new file mode 100644 index 000000000..2e1fca2dc --- /dev/null +++ b/src/backend/src/routers/auth/oidc.js @@ -0,0 +1,254 @@ +/* + * 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 . + */ +import express from 'express'; +import jwt from 'jsonwebtoken'; +import config from '../../config.js'; +import { get_user, subdomain } from '../../helpers.js'; +const router = express.Router(); + +const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; +const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes + +const MISSING_CODE_OR_STATE = Symbol('MISSING_CODE_OR_STATE'); +const INVALID_OR_EXPIRED_STATE = Symbol('INVALID_OR_EXPIRED_STATE'); +const TOKEN_EXCHANGE_FAILED = Symbol('TOKEN_EXCHANGE_FAILED'); +const COULD_NOT_GET_USER_INFO = Symbol('COULD_NOT_GET_USER_INFO'); + +const OIDC_CALLBACK_ERROR_RESPONSES = { + [MISSING_CODE_OR_STATE]: { status: 400, message: 'Missing code or state.' }, + [INVALID_OR_EXPIRED_STATE]: { status: 400, message: 'Invalid or expired state.' }, + [TOKEN_EXCHANGE_FAILED]: { status: 401, message: 'Token exchange failed.' }, + [COULD_NOT_GET_USER_INFO]: { status: 401, message: 'Could not get user info.' }, +}; + +/** Returns { session_token, target } for the caller to set cookie and redirect. */ +const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { + const svc_auth = req.services.get('auth'); + const { token: session_token } = await svc_auth.create_session_token(user, { req }); + let target = stateDecoded.redirect_uri || config.origin || '/'; + const origin = config.origin || ''; + if ( target && origin && !target.startsWith(origin) ) { + target = origin; + } + return { session_token, target }; +}; + +/** Exchange code for tokens, get userinfo. Returns { provider, userinfo, stateDecoded } or { error } (symbol). */ +const processOIDCCallbackRequest_ = async (req, callbackRedirectUri) => { + const svc_oidc = req.services.get('oidc'); + const code = req.query.code; + const state = req.query.state; + if ( !code || !state ) { + return { error: MISSING_CODE_OR_STATE }; + } + const stateDecoded = svc_oidc.verifyState(state); + if ( !stateDecoded || !stateDecoded.provider ) { + return { error: INVALID_OR_EXPIRED_STATE }; + } + const provider = stateDecoded.provider; + const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri); + if ( !tokens || !tokens.access_token ) { + return { error: TOKEN_EXCHANGE_FAILED }; + } + const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); + if ( !userinfo || !userinfo.sub ) { + return { error: COULD_NOT_GET_USER_INFO }; + } + return { provider, userinfo, stateDecoded }; +}; + +// GET /auth/oidc/providers - list enabled provider ids for frontend +router.get('/auth/oidc/providers', async (req, res) => { + if ( subdomain(req) !== 'api' ) { + return res.status(404).end(); + } + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + return res.json({ providers }); +}); + +// GET /auth/oidc/:provider/start - redirect to IdP authorization +router.get('/auth/oidc/:provider/start', async (req, res) => { + if ( subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('oidc-general') ) { + return res.status(429).send('Too many requests.'); + } + const provider = req.params.provider; + const svc_oidc = req.services.get('oidc'); + const cfg = await svc_oidc.getProviderConfig(provider); + if ( ! cfg ) { + return res.status(404).send('Provider not configured.'); + } + const flow = req.query.flow ? String(req.query.flow) : undefined; + const flowRedirects = { + login: config.origin || '/', + signup: config.origin || '/', + revalidate: `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`, + }; + const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/'); + const statePayload = { provider, redirect_uri: appRedirectUri }; + if ( flow === 'revalidate' ) { + const user_id = req.query.user_id; + if ( ! user_id ) { + return res.status(400).send('user_id required for revalidate flow.'); + } + statePayload.user_id = Number(user_id); + statePayload.flow = 'revalidate'; + } + const state = svc_oidc.signState(statePayload); + const url = await svc_oidc.getAuthorizationUrl(provider, state, flow); + if ( ! url ) { + return res.status(502).send('Could not build authorization URL.'); + } + return res.redirect(302, url); +}); + +// GET /auth/oidc/callback/login - login only: existing account or abort. Never creates a user. +router.get('/auth/oidc/callback/login', async (req, res) => { + if ( subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('oidc-general') ) { + return res.status(429).send('Too many requests.'); + } + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login'); + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; + const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( ! user ) { + return res.status(400).send('No account found. Sign up first.'); + } + if ( user.suspended ) { + return res.status(401).send('This account is suspended.'); + } + const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.redirect(302, target); +}); + +// GET /auth/oidc/callback/signup - signup only: create new account or abort. Never logs in to existing account. +router.get('/auth/oidc/callback/signup', async (req, res) => { + if ( subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('oidc-general') ) { + return res.status(429).send('Too many requests.'); + } + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup'); + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; + const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( existingUser ) { + return res.status(400).send('Account already exists. Log in instead.'); + } + const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo); + if ( outcome.failed ) { + return res.status(400).send(outcome.userMessage); + } + const user = await get_user({ id: outcome.infoObject.user_id }); + const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.redirect(302, target); +}); + +// GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects. +router.get('/auth/oidc/callback/revalidate', async (req, res) => { + if ( subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('oidc-general') ) { + return res.status(429).send('Too many requests.'); + } + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('revalidate'); + const result = await processOIDCCallbackRequest_(req, callbackRedirectUri); + if ( result.error ) { + const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error]; + return res.status(status).send(message); + } + const { provider, userinfo, stateDecoded } = result; + if ( stateDecoded.flow !== 'revalidate' || stateDecoded.user_id == null ) { + return res.status(400).send('Invalid revalidate state.'); + } + const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( ! user ) { + return res.status(400).send('No account found.'); + } + if ( user.id !== stateDecoded.user_id ) { + return res.status(403).send('Wrong account. Sign in with the account linked to this session.'); + } + const token = jwt.sign({ user_id: user.id, purpose: 'revalidate' }, + config.jwt_secret, + { expiresIn: REVALIDATION_EXPIRY_SEC }); + res.cookie(REVALIDATION_COOKIE_NAME, token, { + sameSite: 'lax', + secure: true, + httpOnly: true, + maxAge: REVALIDATION_EXPIRY_SEC * 1000, + path: '/', + }); + const target = stateDecoded.redirect_uri || `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`; + return res.redirect(302, target); +}); + +// GET /auth/revalidate-done - landing page after OIDC revalidate; posts to opener and closes (for popup flow). +router.get('/auth/revalidate-done', (req, res) => { + if ( subdomain(req) !== '' ) { + return res.status(404).end(); + } + const origin = config.origin || ''; + res.set('Content-Type', 'text/html; charset=utf-8'); + res.send(`Re-validated

Re-validated. Closing…

`); +}); + +export default router; diff --git a/src/backend/src/routers/change_username.js b/src/backend/src/routers/change_username.js index e11f52779..5955d0950 100644 --- a/src/backend/src/routers/change_username.js +++ b/src/backend/src/routers/change_username.js @@ -78,9 +78,9 @@ module.exports = eggspress('/change_username', { mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)', sqlite: "`created_at` > datetime('now', '-1 month')", })}`, - [ req.user.id, 'change_username' ]); + [req.user.id, 'change_username']); - if ( rows[0].count >= 2 ) { + if ( rows[0].count >= (config.max_username_changes ?? 2) ) { throw APIError.create('too_many_username_changes'); } diff --git a/src/backend/src/routers/hosting/puter-site.js b/src/backend/src/routers/hosting/puter-site.js index 9319bd9ac..57ec2002c 100644 --- a/src/backend/src/routers/hosting/puter-site.js +++ b/src/backend/src/routers/hosting/puter-site.js @@ -642,7 +642,7 @@ class PuterSiteMiddleware extends AdvancedBase { res.redirect(`${config.origin}?${e.querystringize({ ...(req.query['puter.app_instance_id'] ? { - ['error_from_within_iframe']: true, + 'error_from_within_iframe': true, } : {}), })}`); } diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js index 1e9c4aa32..f78322c4b 100644 --- a/src/backend/src/routers/login.js +++ b/src/backend/src/routers/login.js @@ -26,21 +26,21 @@ const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middle const complete_ = async ({ req, res, user }) => { const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); + const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); + const gui_token = svc_auth.create_gui_token(user, session); - //set cookie - // res.cookie(config.cookie_name, token); - res.cookie(config.cookie_name, token, { + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, }); - // send response + // response body: GUI token only (client never gets session token) return res.send({ proceed: true, next_step: 'complete', - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js index 060101294..fb43f4ea3 100644 --- a/src/backend/src/routers/save_account.js +++ b/src/backend/src/routers/save_account.js @@ -207,9 +207,10 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { } } - // create token for login + // create token for login: session token for cookie, GUI token for client const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(req.user, { req }); + const { session, token: session_token } = await svc_auth.create_session_token(req.user, { req }); + const gui_token = svc_auth.create_gui_token(req.user, session); // user id // todo if pseudo user, assign directly no need to do another DB lookup @@ -219,8 +220,8 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { // todo send LINK-based verification email - //set cookie - res.cookie(config.cookie_name, token); + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) + res.cookie(config.cookie_name, session_token); { const svc_event = req.services.get('event'); @@ -229,7 +230,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => { // return results return res.send({ - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js index dcdab350e..dd282485a 100644 --- a/src/backend/src/routers/signup.js +++ b/src/backend/src/routers/signup.js @@ -420,11 +420,12 @@ module.exports = eggspress(['/signup'], { const [user] = await db.pread('SELECT * FROM `user` WHERE `id` = ? LIMIT 1', [user_id]); - // create token for login - const { token } = await svc_auth.create_session_token(user, { + // create token for login: session token for cookie, GUI token for client + const { session, token: session_token } = await svc_auth.create_session_token(user, { req, }); - // jwt.sign({uuid: user_uuid}, config.jwt_secret); + const gui_token = svc_auth.create_gui_token(user, session); + // jwt.sign({uuid: user_uuid}, config.jwt_secret); //------------------------------------------------------------- // email confirmation @@ -456,8 +457,8 @@ module.exports = eggspress(['/signup'], { const svc_user = Context.get('services').get('user'); await svc_user.generate_default_fsentries({ user }); - //set cookie - res.cookie(config.cookie_name, token, { + // HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie) + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, @@ -471,7 +472,7 @@ module.exports = eggspress(['/signup'], { // return results return res.send({ - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/signup_create_new_user.js b/src/backend/src/routers/signup_create_new_user.js new file mode 100644 index 000000000..902b82fbe --- /dev/null +++ b/src/backend/src/routers/signup_create_new_user.js @@ -0,0 +1,126 @@ +/* + * 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 . + */ +import config from '../config.js'; +import { DB_WRITE } from '../services/database/consts.js'; +import { generate_identifier } from '../util/identifier.js'; +import { v4 as uuidv4 } from 'uuid'; + +/** + * Create a new user for signup. Common behavior shared by POST /signup and OIDC signup. + * Form-signup path is still handled in signup.js; this handles OIDC and will support form signup after refactor. + * + * @param {object} services - Backend services (from req.services) + * @param {object} options - Creation options. For OIDC: { providerId, userinfo }. For form signup: TBD (to be refactored from signup.js). + * @returns {Promise} The created user, or null on failure (e.g. email already registered). + */ +async function signup_create_new_user (services, options) { + const { providerId, userinfo } = options; + if ( !providerId || !userinfo ) { + // Form signup: to be refactored from signup.js; not implemented here yet. + return null; + } + + const db = await services.get('database').get(DB_WRITE, 'auth'); + const svc_group = services.get('group'); + const svc_user = services.get('user'); + const svc_oidc = services.get('oidc'); + if ( ! svc_oidc ) return null; + + const claims = userinfo; + let username = (claims.name || claims.email || '').toString().trim(); + if ( username ) { + username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); + if ( username.length > 45 ) username = username.slice(0, 45); + } + if ( !username || !/^\w+$/.test(username) ) { + let candidate; + do { + candidate = generate_identifier(); + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) username = candidate; + } while ( !username ); + } else { + const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]); + if ( existing ) { + let suffix = 1; + while ( true ) { + const candidate = `${username}${suffix}`; + const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]); + if ( ! r ) { + username = candidate; break; + } + suffix++; + } + } + } + + const email = (claims.email || '').toString().trim() || null; + const clean_email = email ? email.toLowerCase().trim() : null; + if ( clean_email ) { + const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]); + if ( existingEmail ) { + return null; // email already registered; caller should return error + } + } + + const user_uuid = uuidv4(); + const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000)); + const email_confirm_token = uuidv4(); + + await db.write(`INSERT INTO user ( + username, email, clean_email, password, uuid, referrer, + email_confirm_code, email_confirm_token, free_storage, + referred_by, email_confirmed, requires_email_confirmation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + username, + email, + clean_email, + null, + user_uuid, + null, + email_confirm_code, + email_confirm_token, + config.storage_capacity, + null, + 1, + 0, + ]); + const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]); + const user_id = inserted.id; + + await svc_oidc.linkProviderToUser(user_id, providerId, claims.sub, null); + + await svc_group.add_users({ + uid: config.default_user_group, + users: [username], + }); + + const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]); + if ( user && user.metadata && typeof user.metadata === 'string' ) { + user.metadata = JSON.parse(user.metadata); + } else if ( user && !user.metadata ) { + user.metadata = {}; + } + await svc_user.generate_default_fsentries({ user }); + + return user; +} + +export default signup_create_new_user; diff --git a/src/backend/src/routers/user-protected/change-username.js b/src/backend/src/routers/user-protected/change-username.js new file mode 100644 index 000000000..03d06383a --- /dev/null +++ b/src/backend/src/routers/user-protected/change-username.js @@ -0,0 +1,75 @@ +/* + * 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 . + */ +const config = require('../../config'); +const APIError = require('../../api/APIError.js'); +const { DB_WRITE } = require('../../services/database/consts'); +const { username_exists, change_username } = require('../../helpers'); +const { Context } = require('../../util/context'); + +module.exports = { + route: '/change-username', + methods: ['POST'], + handler: async (req, res, _next) => { + const user = req.user; + const new_username = req.body.new_username; + + if ( ! new_username ) { + throw APIError.create('field_missing', null, { key: 'new_username' }); + } + if ( typeof new_username !== 'string' ) { + throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'a string' }); + } + if ( ! new_username.match(config.username_regex) ) { + throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'letters, numbers, underscore (_)' }); + } + if ( new_username.length > config.username_max_length ) { + throw APIError.create('field_too_long', null, { key: 'new_username', max_length: config.username_max_length }); + } + if ( await username_exists(new_username) ) { + throw APIError.create('username_already_in_use', null, { username: new_username }); + } + + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('change-username-start') ) { + return res.status(429).send('Too many requests.'); + } + + const db = Context.get('services').get('database').get(DB_WRITE, 'auth'); + const rows = await db.read('SELECT COUNT(*) AS `count` FROM `user_update_audit` ' + + `WHERE \`user_id\`=? AND \`reason\`=? AND ${ + db.case({ + mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)', + sqlite: "`created_at` > datetime('now', '-1 month')", + })}`, + [user.id, 'change_username']); + + if ( rows[0].count >= (config.max_username_changes ?? 2) ) { + throw APIError.create('too_many_username_changes'); + } + + await db.write('INSERT INTO `user_update_audit` ' + + '(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' + + 'VALUES (?, ?, ?, ?, ?)', + [user.id, user.id, user.username, new_username, 'change_username']); + + await change_username(user.id, new_username); + + res.json({}); + }, +}; diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts index 052b19895..f1f14d6d0 100644 --- a/src/backend/src/services/BaseService.d.ts +++ b/src/backend/src/services/BaseService.d.ts @@ -1,9 +1,15 @@ -import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService'; -import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService'; -import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs'; import { DDBClient } from '../clients/dynamodb/DDBClient'; import { DynamoKVStore } from '../clients/dynamodb/DynamoKVStore/DynamoKVStore'; +import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService'; +import { GroupService } from './auth/GroupService'; +import SignupService from './auth/SignupService'; +import { CleanEmailService } from './CleanEmailService'; +import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService'; +import { EventService } from './EventService'; +import { FeatureFlagService } from './FeatureFlagService'; +import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs'; import type { SUService } from './SUService'; +import { UserService } from './UserService'; export interface ServiceResources { services: { @@ -13,7 +19,13 @@ export interface ServiceResources { get (name: 'server-health'): ServerHealthService; get (name: 'su'): SUService; get (name: 'dynamo'): DDBClient; - get (name: string): any; + get (name: 'user'): UserService; + get (name: 'event'): EventService; + get (name: 'signup'): SignupService; + get (name: 'group'): GroupService; + get (name: 'feature-flag'): FeatureFlagService; + get (name: 'clean-email'): CleanEmailService; + get (name: string): unknown; }; config: Record & { services?: Record; server_id?: string }; name?: string; diff --git a/src/backend/src/services/BootScriptService.js b/src/backend/src/services/BootScriptService.js index 2a2172601..82fc2aca3 100644 --- a/src/backend/src/services/BootScriptService.js +++ b/src/backend/src/services/BootScriptService.js @@ -41,7 +41,7 @@ class BootScriptService extends BaseService { * @function * @returns {Promise} */ - async ['__on_boot.ready'] () { + async '__on_boot.ready' () { const args = Context.get('args'); if ( ! args['boot-script'] ) return; const script_name = args['boot-script']; @@ -66,7 +66,7 @@ class BootScriptService extends BaseService { async run_script (boot_json) { const scope = { runner: 'boot-script', - ['end-puter-process']: ({ args }) => { + 'end-puter-process': ({ args }) => { const svc_shutdown = this.services.get('shutdown'); svc_shutdown.shutdown(args[0]); }, diff --git a/src/backend/src/services/ChatAPIService.js b/src/backend/src/services/ChatAPIService.js index d4784a3b4..8e1079ead 100644 --- a/src/backend/src/services/ChatAPIService.js +++ b/src/backend/src/services/ChatAPIService.js @@ -40,7 +40,7 @@ class ChatAPIService extends BaseService { * @param {Express} options.app Express application instance to install routes on * @returns {Promise} */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { // Create a router for chat API endpoints const router = (() => { const require = this.require; diff --git a/src/backend/src/services/CommandService.js b/src/backend/src/services/CommandService.js index ba98342a3..ee7155809 100644 --- a/src/backend/src/services/CommandService.js +++ b/src/backend/src/services/CommandService.js @@ -99,7 +99,7 @@ class CommandService extends BaseService { })); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); const svc_command = this; const event = { diff --git a/src/backend/src/services/ContextInitService.js b/src/backend/src/services/ContextInitService.js index 253e3eacf..1f5b441c4 100644 --- a/src/backend/src/services/ContextInitService.js +++ b/src/backend/src/services/ContextInitService.js @@ -97,7 +97,7 @@ class ContextInitService extends BaseService { key, async_factory, }); } - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { this.mw.install(app); await this.services.emit('install.context-initializers'); } diff --git a/src/backend/src/services/EntityStoreService.js b/src/backend/src/services/EntityStoreService.js index d42b64abe..4d28a6898 100644 --- a/src/backend/src/services/EntityStoreService.js +++ b/src/backend/src/services/EntityStoreService.js @@ -61,7 +61,7 @@ class EntityStoreService extends BaseService { } static IMPLEMENTS = { - ['crud-q']: { + 'crud-q': { async create ({ object, options }) { if ( object.hasOwnProperty(this.om.primary_identifier) ) { throw APIError.create('field_not_allowed_for_create', null, { @@ -103,7 +103,7 @@ class EntityStoreService extends BaseService { es_params: options?.params ?? {}, }).arun(async () => { const entities = await this.select(options); - const promises = []; + const promises = []; for ( const entity of entities ) { promises.push(entity.get_client_safe()); } diff --git a/src/backend/src/services/EntriService.js b/src/backend/src/services/EntriService.js index c1bb08943..784157bdf 100644 --- a/src/backend/src/services/EntriService.js +++ b/src/backend/src/services/EntriService.js @@ -45,7 +45,7 @@ class EntriService extends BaseService { parseDomain = (await import('parse-domain')).parseDomain; } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { Endpoint({ route: '/entri/webhook', methods: ['POST', 'GET'], @@ -94,7 +94,7 @@ class EntriService extends BaseService { } static IMPLEMENTS = { - ['entri']: { + 'entri': { async getConfig ({ domain, userHostedSite }) { const es_subdomain = this.services.get('es:subdomain'); const svc_su = this.services.get('su'); @@ -231,7 +231,7 @@ class EntriService extends BaseService { }, }, }; - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/services/EventService.js b/src/backend/src/services/EventService.js index 3b2879608..e05fdb727 100644 --- a/src/backend/src/services/EventService.js +++ b/src/backend/src/services/EventService.js @@ -59,7 +59,7 @@ class EventService extends BaseService { this.global_listeners_ = []; } - async ['__on_boot.ready'] () { + async '__on_boot.ready' () { this.emit('ready', {}, {}); } diff --git a/src/backend/src/services/FeatureFlagService.js b/src/backend/src/services/FeatureFlagService.js index 6424cf5cb..03a90c4be 100644 --- a/src/backend/src/services/FeatureFlagService.js +++ b/src/backend/src/services/FeatureFlagService.js @@ -70,10 +70,12 @@ class FeatureFlagService extends BaseService { /** * checks is a feature flag is enabled for the current user - * @return {boolean} - true if the feature flag is enabled, false otherwise + * @return {boolean} true if the feature flag is enabled, false otherwise * - * Usage: - * check({ actor }, 'flag-name') + * @example with a specified actor + * check({ actor }, 'flag-name'); + * @example with actor in context + * check('flag-name'); */ async check (...a) { // allows binding call with multiple options objects; diff --git a/src/backend/src/services/FilesystemAPIService.js b/src/backend/src/services/FilesystemAPIService.js index 9658eebed..9f325945c 100644 --- a/src/backend/src/services/FilesystemAPIService.js +++ b/src/backend/src/services/FilesystemAPIService.js @@ -38,7 +38,7 @@ class FilesystemAPIService extends BaseService { * @function __on_install.routes * @returns {Promise} A promise that resolves when the routes are set up. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); // batch diff --git a/src/backend/src/services/HelloWorldService.js b/src/backend/src/services/HelloWorldService.js index c8eb6ebae..961bac461 100644 --- a/src/backend/src/services/HelloWorldService.js +++ b/src/backend/src/services/HelloWorldService.js @@ -28,7 +28,7 @@ const BaseService = require('./BaseService'); */ class HelloWorldService extends BaseService { static IMPLEMENTS = { - ['version']: { + 'version': { /** * Returns the current version of the service. * @@ -38,7 +38,7 @@ class HelloWorldService extends BaseService { return 'v1.0.0'; }, }, - ['hello-world']: { + 'hello-world': { /** * Greets the user with a customizable message. * diff --git a/src/backend/src/services/KernelInfoService.js b/src/backend/src/services/KernelInfoService.js index 71ed00dfb..08c2a7d87 100644 --- a/src/backend/src/services/KernelInfoService.js +++ b/src/backend/src/services/KernelInfoService.js @@ -46,7 +46,7 @@ class KernelInfoService extends BaseService { * @param {Express} param1.app Express application instance * @private */ - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const router = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/services/LocalDiskStorageService.js b/src/backend/src/services/LocalDiskStorageService.js index 1b7d9f954..f55fa6485 100644 --- a/src/backend/src/services/LocalDiskStorageService.js +++ b/src/backend/src/services/LocalDiskStorageService.js @@ -43,7 +43,7 @@ class LocalDiskStorageService extends BaseService { * * @returns {Promise} A promise that resolves when the context is initialized. */ - async ['__on_install.context-initializers'] () { + async '__on_install.context-initializers' () { const svc_contextInit = this.services.get('context-init'); const storage = new LocalDiskStorageStrategy({ services: this.services }); svc_contextInit.register_value('storage', storage); diff --git a/src/backend/src/services/MakeProdDebuggingLessAwfulService.js b/src/backend/src/services/MakeProdDebuggingLessAwfulService.js index b97b11448..13a35aa8e 100644 --- a/src/backend/src/services/MakeProdDebuggingLessAwfulService.js +++ b/src/backend/src/services/MakeProdDebuggingLessAwfulService.js @@ -110,7 +110,7 @@ class MakeProdDebuggingLessAwfulService extends BaseService { * @param {Express} options.app Express application instance * @returns {Promise} */ - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { // Add express middleware this.mw.install(app); } diff --git a/src/backend/src/services/NotificationService.js b/src/backend/src/services/NotificationService.js index 1afceacbc..e672e8c47 100644 --- a/src/backend/src/services/NotificationService.js +++ b/src/backend/src/services/NotificationService.js @@ -114,7 +114,7 @@ class NotificationService extends BaseService { this.notifs_pending_write = {}; } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const require = this.require; const express = require('express'); const router = express.Router(); diff --git a/src/backend/src/services/PermissionAPIService.js b/src/backend/src/services/PermissionAPIService.js index 1e68d7b01..94eb6d7f1 100644 --- a/src/backend/src/services/PermissionAPIService.js +++ b/src/backend/src/services/PermissionAPIService.js @@ -41,7 +41,7 @@ class PermissionAPIService extends BaseService { * @param {Express} options.app Express application instance to install routes on * @returns {Promise} */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { app.use(require('../routers/auth/get-user-app-token')); app.use(require('../routers/auth/grant-user-app')); app.use(require('../routers/auth/revoke-user-app')); diff --git a/src/backend/src/services/PuterAPIService.js b/src/backend/src/services/PuterAPIService.js index a2e0025ac..b98f0342e 100644 --- a/src/backend/src/services/PuterAPIService.js +++ b/src/backend/src/services/PuterAPIService.js @@ -38,7 +38,7 @@ class PuterAPIService extends BaseService { * This method registers various API endpoints with the web server. * It does not return a value as it configures the server directly. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const svc_web = this.services.get('web-server'); const { app } = svc_web; svc_web.allow_undefined_origin('/healthcheck'); @@ -70,6 +70,7 @@ class PuterAPIService extends BaseService { // app.use(require('../routers/get-launch-apps')) app.use(require('../routers/itemMetadata')); app.use(require('../routers/login')); + app.use(require('../routers/auth/oidc').default); app.use(require('../routers/logout')); app.use(require('../routers/open_item')); app.use(require('../routers/passwd')); diff --git a/src/backend/src/services/PuterHomepageService.js b/src/backend/src/services/PuterHomepageService.js index 23b7df571..4c61a32f6 100644 --- a/src/backend/src/services/PuterHomepageService.js +++ b/src/backend/src/services/PuterHomepageService.js @@ -59,7 +59,7 @@ export class PuterHomepageService extends BaseService { this.gui_params[key] = val; } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { Endpoint({ route: '/whoarewe', methods: ['GET'], @@ -327,8 +327,8 @@ export class PuterHomepageService extends BaseService { ${((!bundled && manifest?.css_paths) - ? manifest.css_paths.map(path => `\n`) - : []).join('') + ? manifest.css_paths.map(path => `\n`) + : []).join('') } diff --git a/src/backend/src/services/PuterVersionService.js b/src/backend/src/services/PuterVersionService.js index 506cb4c44..156f1d880 100644 --- a/src/backend/src/services/PuterVersionService.js +++ b/src/backend/src/services/PuterVersionService.js @@ -43,7 +43,7 @@ class PuterVersionService extends BaseService { * @async * @returns {Promise} Resolves when the routes are successfully registered. */ - async ['__on_install.routes'] () { + async '__on_install.routes' () { const { app } = this.services.get('web-server'); app.use(require('../routers/version')); } diff --git a/src/backend/src/services/RefreshAssociationsService.js b/src/backend/src/services/RefreshAssociationsService.js index 291a1f328..82c34c973 100644 --- a/src/backend/src/services/RefreshAssociationsService.js +++ b/src/backend/src/services/RefreshAssociationsService.js @@ -36,7 +36,7 @@ class RefreshAssociationsService extends BaseService { * @async * @returns {Promise} - A promise that resolves when the cache refresh process is complete. */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const { refresh_associations_cache } = require('../helpers'); /** diff --git a/src/backend/src/services/RegistryService.js b/src/backend/src/services/RegistryService.js index 6c8d5661a..20d5b90fb 100644 --- a/src/backend/src/services/RegistryService.js +++ b/src/backend/src/services/RegistryService.js @@ -101,7 +101,7 @@ class RegistryService extends BaseService { * * @private */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const services = this.services; await services.emit('registry.collections'); await services.emit('registry.entries'); diff --git a/src/backend/src/services/RequestMeasureService.js b/src/backend/src/services/RequestMeasureService.js index 5d3dfcdac..9d8c1eb02 100644 --- a/src/backend/src/services/RequestMeasureService.js +++ b/src/backend/src/services/RequestMeasureService.js @@ -1,7 +1,7 @@ const BaseService = require('./BaseService'); class RequestMeasureService extends BaseService { - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { const svc_event = this.services.get('event'); app.use(async (req, res, next) => { next(); diff --git a/src/backend/src/services/SNSService.js b/src/backend/src/services/SNSService.js index 1fd87b692..6e774f2aa 100644 --- a/src/backend/src/services/SNSService.js +++ b/src/backend/src/services/SNSService.js @@ -63,7 +63,7 @@ class SNSService extends BaseService { svc_web.allow_undefined_origin('/sns', '/sns/'); } - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { Endpoint({ route: '/sns', methods: ['POST'], diff --git a/src/backend/src/services/SUService.js b/src/backend/src/services/SUService.js index 3dcf09d18..fc6369f95 100644 --- a/src/backend/src/services/SUService.js +++ b/src/backend/src/services/SUService.js @@ -53,7 +53,7 @@ export class SUService extends BaseService { * @returns {Promise} A promise that resolves when both the * system user and actor have been set. */ - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const sys_user = await this.services.get('get-user').get_user({ username: 'system' }); this.sys_user_.resolve(sys_user); const sys_actor = new Actor({ diff --git a/src/backend/src/services/ServeGUIService.js b/src/backend/src/services/ServeGUIService.js index 4255d6d1a..9d5467a5b 100644 --- a/src/backend/src/services/ServeGUIService.js +++ b/src/backend/src/services/ServeGUIService.js @@ -35,7 +35,7 @@ class ServeGUIService extends BaseService { * @async * @returns {Promise} Resolves when routing is successfully set up. */ - async ['__on_install.routes-gui'] () { + async '__on_install.routes-gui' () { const { app } = this.services.get('web-server'); // is this a puter.site domain? diff --git a/src/backend/src/services/ShareService.js b/src/backend/src/services/ShareService.js index e6c6d9414..a01722a99 100644 --- a/src/backend/src/services/ShareService.js +++ b/src/backend/src/services/ShareService.js @@ -86,7 +86,7 @@ class ShareService extends BaseService { }); } - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { this.install_sharelink_endpoints({ app }); this.install_share_endpoint({ app }); } diff --git a/src/backend/src/services/UserService.js b/src/backend/src/services/UserService.js index c5ae78863..bfb15202b 100644 --- a/src/backend/src/services/UserService.js +++ b/src/backend/src/services/UserService.js @@ -22,6 +22,9 @@ const { invalidate_cached_user, invalidate_cached_user_by_id } = require('../hel const BaseService = require('./BaseService'); const { DB_WRITE } = require('./database/consts'); +/** + * Lorem ipsum dolor sit amet + */ class UserService extends BaseService { static MODULES = { uuidv4: require('uuid').v4, @@ -32,7 +35,7 @@ class UserService extends BaseService { this.dir_system = null; } - async ['__on_filesystem.ready'] () { + async '__on_filesystem.ready' () { const svc_fs = this.services.get('filesystem'); // Ensure system user has a home directory const dir_system = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(), @@ -54,7 +57,9 @@ class UserService extends BaseService { return this.dir_system; } - // used to be called: generate_system_fsentries + /** + * This used to be called `generate_system_fsentries` + */ async generate_default_fsentries ({ user }) { // Note: The comment below is outdated as we now do parallel writes for diff --git a/src/backend/src/services/WebDAV/WebDAVService.js b/src/backend/src/services/WebDAV/WebDAVService.js index 41a53100a..c081d7cdb 100644 --- a/src/backend/src/services/WebDAV/WebDAVService.js +++ b/src/backend/src/services/WebDAV/WebDAVService.js @@ -157,7 +157,7 @@ class WebDAVService extends BaseService { const base64Credentials = authHeader.split(' ')[1]; const credentials = Buffer.from(base64Credentials, 'base64').toString( 'ascii'); - let [ username, ...password ] = credentials.split(':'); + let [username, ...password] = credentials.split(':'); password = password.join(':'); // Call user's authentication function @@ -209,7 +209,7 @@ class WebDAVService extends BaseService { methodHandler(req, res, filePath, fileNode, headerLockToken); } - ['__on_install.routes'] ( _, { app } ) { + '__on_install.routes' ( _, { app } ) { COOKIE_NAME = this.global_config.cookie_name; const r_webdav = (() => { @@ -238,7 +238,7 @@ class WebDAVService extends BaseService { 'UNLOCK', 'OPTIONS', ], - mw: [ configurable_auth({ optional: true }) ], + mw: [configurable_auth({ optional: true })], /** * * @param {import("express").Request} req diff --git a/src/backend/src/services/WispService.js b/src/backend/src/services/WispService.js index 06bc53fa8..8939ef689 100644 --- a/src/backend/src/services/WispService.js +++ b/src/backend/src/services/WispService.js @@ -22,7 +22,7 @@ const { Endpoint } = require('../util/expressutil'); const BaseService = require('./BaseService'); class WispService extends BaseService { - ['__on_install.routes'] (_, { app }) { + '__on_install.routes' (_, { app }) { const r_wisp = (() => { const require = this.require; const express = require('express'); diff --git a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js index 03265e3ef..70384454c 100644 --- a/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/src/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -44,79 +44,87 @@ class EdgeRateLimitService extends BaseService { */ _construct () { this.scopes = { - ['login']: { + 'oidc-general': { + limit: 100, + window: 15 * MINUTE, + }, + 'login': { limit: 10, window: 15 * MINUTE, }, - ['signup']: { + 'signup': { limit: 10, window: 15 * MINUTE, }, - ['contact-us']: { + 'contact-us': { limit: 10, window: 15 * MINUTE, }, - ['share']: { + 'share': { limit: 30, window: 1 * MINUTE, }, - ['send-confirm-email']: { + 'send-confirm-email': { limit: 10, window: HOUR, }, - ['confirm-email']: { + 'confirm-email': { limit: 10, window: HOUR, }, - ['send-pass-recovery-email']: { + 'send-pass-recovery-email': { limit: 10, window: HOUR, }, - ['verify-pass-recovery-token']: { + 'verify-pass-recovery-token': { limit: 10, window: 15 * MINUTE, }, - ['set-pass-using-token']: { + 'set-pass-using-token': { limit: 10, window: HOUR, }, - ['save-account']: { + 'save-account': { limit: 10, window: HOUR, }, - ['change-email-start']: { + 'change-email-start': { limit: 10, window: HOUR, }, - ['change-email-confirm']: { + 'change-email-confirm': { limit: 10, window: HOUR, }, - ['passwd']: { + 'passwd': { limit: 10, window: HOUR, }, - ['/user-protected/change-password']: { + '/user-protected/change-password': { limit: 10, window: HOUR, }, - ['/user-protected/change-email']: { + '/user-protected/change-email': { limit: 10, window: HOUR, }, - ['/user-protected/disable-2fa']: { + '/user-protected/change-username': { limit: 10, window: HOUR, }, - ['login-otp']: { + '/user-protected/disable-2fa': { + limit: 10, + window: HOUR, + }, + 'login-otp': { limit: 15, window: 30 * MINUTE, }, - ['login-recovery']: { + 'login-recovery': { limit: 10, window: HOUR, }, - ['enable-2fa']: { + 'enable-2fa': { limit: 10, window: HOUR, }, diff --git a/src/backend/src/services/abuse-prevention/IdentificationService.js b/src/backend/src/services/abuse-prevention/IdentificationService.js index 50f5ab9a5..689feea8a 100644 --- a/src/backend/src/services/abuse-prevention/IdentificationService.js +++ b/src/backend/src/services/abuse-prevention/IdentificationService.js @@ -193,7 +193,7 @@ class IdentificationService extends BaseService { /** * We need to listen to this event to install a context-aware middleware */ - async ['__on_install.middlewares.context-aware'] (_, { app }) { + async '__on_install.middlewares.context-aware' (_, { app }) { this.mw.install(app); } } diff --git a/src/backend/src/services/ai/AIInterfaceService.js b/src/backend/src/services/ai/AIInterfaceService.js index 9205ad2b5..5e4b32820 100644 --- a/src/backend/src/services/ai/AIInterfaceService.js +++ b/src/backend/src/services/ai/AIInterfaceService.js @@ -32,7 +32,7 @@ class AIInterfaceService extends BaseService { * Extends the base service to provide AI-related interface management. * Handles registration of OCR, chat completion, image generation, and TTS interfaces. */ - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/services/ai/chat/AIChatService.ts b/src/backend/src/services/ai/chat/AIChatService.ts index f609c5420..b23044556 100644 --- a/src/backend/src/services/ai/chat/AIChatService.ts +++ b/src/backend/src/services/ai/chat/AIChatService.ts @@ -86,13 +86,13 @@ export class AIChatService extends BaseService { /** Driver interfaces */ static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface: string, method_name: string) { return iface === 'puter-chat-completion' && method_name === 'complete'; }, }, - ['puter-chat-completion']: { + 'puter-chat-completion': { async models () { return await (this as unknown as AIChatService).models(); diff --git a/src/backend/src/services/ai/ocr/AWSTextractService.js b/src/backend/src/services/ai/ocr/AWSTextractService.js index a876b7425..0e6e6ab1b 100644 --- a/src/backend/src/services/ai/ocr/AWSTextractService.js +++ b/src/backend/src/services/ai/ocr/AWSTextractService.js @@ -45,12 +45,12 @@ class AWSTextractService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-ocr' && method_name === 'recognize'; }, }, - ['puter-ocr']: { + 'puter-ocr': { /** * Performs OCR recognition on a document using AWS Textract * @param {Object} params - Recognition parameters diff --git a/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js b/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js index 78e2752fb..9756a5db1 100644 --- a/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js +++ b/src/backend/src/services/ai/sts/ElevenLabsVoiceChangerService.js @@ -46,12 +46,12 @@ class ElevenLabsVoiceChangerService extends BaseService { }; static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-speech2speech' && method_name === 'convert'; }, }, - ['puter-speech2speech']: { + 'puter-speech2speech': { async convert (params) { return this.convert(params); }, diff --git a/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js b/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js index 5aa8418e5..6154ef462 100644 --- a/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js +++ b/src/backend/src/services/ai/stt/OpenAISpeechToTextService.js @@ -99,13 +99,13 @@ class OpenAISpeechToTextService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-speech2txt' && (method_name === 'transcribe' || method_name === 'translate'); }, }, - ['puter-speech2txt']: { + 'puter-speech2txt': { async list_models () { return this.listModels(); }, diff --git a/src/backend/src/services/ai/tts/AWSPollyService.js b/src/backend/src/services/ai/tts/AWSPollyService.js index 65821e9a2..0ab2925cf 100644 --- a/src/backend/src/services/ai/tts/AWSPollyService.js +++ b/src/backend/src/services/ai/tts/AWSPollyService.js @@ -61,12 +61,12 @@ class AWSPollyService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { /** * Implements the driver interface methods for text-to-speech functionality * Contains methods for listing available voices and synthesizing speech diff --git a/src/backend/src/services/ai/tts/ElevenLabsTTSService.js b/src/backend/src/services/ai/tts/ElevenLabsTTSService.js index 0144d8aaa..c4e4c860c 100644 --- a/src/backend/src/services/ai/tts/ElevenLabsTTSService.js +++ b/src/backend/src/services/ai/tts/ElevenLabsTTSService.js @@ -47,12 +47,12 @@ class ElevenLabsTTSService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { async list_voices () { return this.listVoices(); }, diff --git a/src/backend/src/services/ai/tts/OpenAITTSService.js b/src/backend/src/services/ai/tts/OpenAITTSService.js index d93592d3a..fc94b2c78 100644 --- a/src/backend/src/services/ai/tts/OpenAITTSService.js +++ b/src/backend/src/services/ai/tts/OpenAITTSService.js @@ -106,12 +106,12 @@ class OpenAITTSService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-tts' && method_name === 'synthesize'; }, }, - ['puter-tts']: { + 'puter-tts': { async list_voices ({ provider } = {}) { if ( provider && provider !== 'openai' ) { return []; diff --git a/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js b/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js index 7ba4b80e7..7318ab5c7 100644 --- a/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js +++ b/src/backend/src/services/ai/video/OpenAIVideoGenerationService/OpenAIVideoGenerationService.js @@ -82,13 +82,13 @@ class OpenAIVideoGenerationService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-video-generation' && method_name === 'generate'; }, }, - ['puter-video-generation']: { + 'puter-video-generation': { async generate (params) { return await this.generateVideo(params); }, diff --git a/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js b/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js index 87b8a38d9..a31b7ec56 100644 --- a/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js +++ b/src/backend/src/services/ai/video/TogetherVideoGenerationService/TogetherVideoGenerationService.js @@ -53,13 +53,13 @@ class TogetherVideoGenerationService extends BaseService { } static IMPLEMENTS = { - ['driver-capabilities']: { + 'driver-capabilities': { supports_test_mode (iface, method_name) { return iface === 'puter-video-generation' && method_name === 'generate'; }, }, - ['puter-video-generation']: { + 'puter-video-generation': { async generate (params) { return await this.generateVideo(params); }, diff --git a/src/backend/src/services/auth/ACLService.js b/src/backend/src/services/auth/ACLService.js index 3199cbbb4..583c3b932 100644 --- a/src/backend/src/services/auth/ACLService.js +++ b/src/backend/src/services/auth/ACLService.js @@ -101,7 +101,7 @@ class ACLService extends BaseService { * @returns {Promise} True if actor has permission, false otherwise * @private */ - async ['__on_install.routes'] (_, { app }) { + async '__on_install.routes' (_, { app }) { /** * Handles route installation for ACL service endpoints. * Sets up routes for user-to-user permission management including: diff --git a/src/backend/src/services/auth/Actor.d.ts b/src/backend/src/services/auth/Actor.d.ts index 55f6790e9..5b9b3640a 100644 --- a/src/backend/src/services/auth/Actor.d.ts +++ b/src/backend/src/services/auth/Actor.d.ts @@ -12,8 +12,10 @@ export class SystemActorType { } export class UserActorType { - constructor (params: { user: IUser }); + constructor (params: { user: IUser; session?: { uuid: string }; hasHttpOnlyCookie?: boolean }); user: IUser; + /** When true, this actor can access user-protected HTTP endpoints (e.g. change password). GUI tokens set this false. */ + hasHttpOnlyCookie: boolean; get uid (): string; get_related_type (type_class: unknown): UserActorType; } diff --git a/src/backend/src/services/auth/Actor.js b/src/backend/src/services/auth/Actor.js index e8d624594..6afde7077 100644 --- a/src/backend/src/services/auth/Actor.js +++ b/src/backend/src/services/auth/Actor.js @@ -16,12 +16,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { AdvancedBase } from '../../../../putility/index.js'; -import { Context } from '../../util/context.js'; -import { get_user, get_app } from '../../helpers.js'; -import * as config from '../../config.js'; -import { v5 as uuidv5 } from 'uuid'; import crypto from 'crypto'; +import { v5 as uuidv5 } from 'uuid'; +import { AdvancedBase } from '../../../../putility/index.js'; +import * as config from '../../config.js'; +import { get_app, get_user } from '../../helpers.js'; +import { Context } from '../../util/context.js'; // TODO: add these to configuration; production deployments should change these! const PRIVATE_UID_NAMESPACE = config.private_uid_namespace @@ -222,6 +222,13 @@ export class Actor extends AdvancedBase { * user actors and define how they relate to other types of actors within the system. */ export class UserActorType extends ActorType { + constructor (o) { + super(o); + if ( this.hasHttpOnlyCookie === undefined ) { + this.hasHttpOnlyCookie = false; + } + } + /** * Gets the unique identifier for the user actor. * diff --git a/src/backend/src/services/auth/AntiCSRFService.js b/src/backend/src/services/auth/AntiCSRFService.js index 3343e70c7..e8b0fdbe7 100644 --- a/src/backend/src/services/auth/AntiCSRFService.js +++ b/src/backend/src/services/auth/AntiCSRFService.js @@ -43,7 +43,7 @@ class AntiCSRFService extends BaseService { * * @returns {void} */ - ['__on_install.routes'] () { + '__on_install.routes' () { const { app } = this.services.get('web-server'); app.use(eggspress('/get-anticsrf-token', { diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index bfc5c8d2d..901cac2b9 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -107,6 +107,32 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session: session.uuid, + hasHttpOnlyCookie: true, + }); + + return new Actor({ + user_uid: decoded.user_uid, + type: actor_type, + }); + } + + if ( decoded.type === 'gui' ) { + const session = await this.get_session_(decoded.uuid); + + if ( ! session ) { + throw APIError.create('token_auth_failed'); + } + + const user = await get_user({ uuid: decoded.user_uid }); + + if ( ! user ) { + throw APIError.create('user_not_found'); + } + + const actor_type = new UserActorType({ + user, + session: session.uuid, + hasHttpOnlyCookie: false, }); return new Actor({ @@ -310,6 +336,44 @@ class AuthService extends BaseService { return { session, token }; } + /** + * Creates a GUI token bound to the same session as the given session object. + * GUI tokens create a UserActorType with hasHttpOnlyCookie false, so they cannot + * access user-protected HTTP endpoints (e.g. change password). The GUI receives + * only this token, not the full session token. + * + * @param {*} user - User object (must have .uuid). + * @param {{ uuid: string }} session - Session object (must have .uuid). + * @returns {string} JWT GUI token. + */ + create_gui_token (user, session) { + return this.modules.jwt.sign({ + type: 'gui', + version: '0.0.0', + uuid: session.uuid, + user_uid: user.uuid, + }, this.global_config.jwt_secret); + } + + /** + * Creates a session token (hasHttpOnlyCookie) for an existing session. + * Used when the client authenticated with a GUI token (e.g. QR login via + * ?auth_token=) so we can set the HTTP-only cookie and allow user-protected + * endpoints (change password, email, username, etc.) to work. + * + * @param {*} user - User object (must have .uuid). + * @param {string} session_uuid - Existing session UUID. + * @returns {string} JWT session token. + */ + create_session_token_for_session (user, session_uuid) { + return this.modules.jwt.sign({ + type: 'session', + version: '0.0.0', + uuid: session_uuid, + user_uid: user.uuid, + }, this.global_config.jwt_secret); + } + /** * This method checks if the provided session token is valid and returns the associated user and token. * If the token is not a valid session token or it does not exist in the database, it returns an empty object. @@ -323,7 +387,7 @@ class AuthService extends BaseService { console.log('\x1B[36;1mDECODED SESSION', decoded); - if ( decoded.type && decoded.type !== 'session' ) { + if ( decoded.type && decoded.type !== 'session' && decoded.type !== 'gui' ) { return {}; } @@ -343,19 +407,24 @@ class AuthService extends BaseService { return {}; } - // Return the session - return { user, token: cur_token }; + // Return GUI token to client (if they sent session token, exchange for GUI token) + const gui_token = decoded.type === 'gui' + ? cur_token + : this.create_gui_token(user, session); + return { user, token: gui_token }; } this.log.info('UPGRADING SESSION'); // Upgrade legacy token // TODO: phase this out - const { session, token } = await this.create_session_token(user, meta); + const { session, token: session_token } = await this.create_session_token(user, meta); + const gui_token = this.create_gui_token(user, session); const actor_type = new UserActorType({ user, session, + hasHttpOnlyCookie: true, }); const actor = new Actor({ @@ -363,7 +432,8 @@ class AuthService extends BaseService { type: actor_type, }); - return { actor, user, token }; + // token = GUI token for client (response body); session_token = for HTTP-only cookie + return { actor, user, token: gui_token, session_token }; } /** @@ -375,7 +445,7 @@ class AuthService extends BaseService { async remove_session_by_token (token) { const decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); - if ( decoded.type !== 'session' ) { + if ( decoded.type !== 'session' && decoded.type !== 'gui' ) { return; } @@ -469,12 +539,12 @@ class AuthService extends BaseService { } else { token_uid = tokenOrUuid; } - /* eslint-disable */ + await this.db.write( 'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?', [token_uid], ); - /* eslint-enable */ + const svc_permission = this.services.get('permission'); svc_permission.invalidate_permission_scan_cache_for_access_token(token_uid); } @@ -610,6 +680,69 @@ class AuthService extends BaseService { return null; } } + + /** + * Registers GET /get-gui-token. Must be called from the GUI origin (no api. subdomain) + * so the HTTP-only session cookie is sent. Returns the GUI token for use in Authorization headers. + */ + '__on_install.routes' () { + const { app } = this.services.get('web-server'); + const config = require('../../config'); + const { subdomain } = require('../../helpers'); + const configurable_auth = require('../../middleware/configurable_auth'); + const { Endpoint } = require('../../util/expressutil'); + const svc_auth = this; + + Endpoint({ + route: '/get-gui-token', + methods: ['GET'], + mw: [configurable_auth()], + handler: async (req, res) => { + if ( ! req.user ) { + return res.status(401).json({}); + } + + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + return res.status(403).json({}); + } + if ( ! actor.type.session ) { + return res.status(400).json({ error: 'No session bound to this actor' }); + } + + const gui_token = svc_auth.create_gui_token(actor.type.user, { uuid: actor.type.session }); + return res.json({ token: gui_token }); + }, + }).attach(app); + + // Sync HTTP-only session cookie to the user implied by the request's auth token. + // Used when switching users in the UI: client sends Authorization with the new user's + // GUI token; we set the session cookie so cookie-based (e.g. user-protected) requests match. + Endpoint({ + route: '/session/sync-cookie', + methods: ['GET'], + mw: [configurable_auth()], + handler: async (req, res) => { + if ( ! req.user ) { + return res.status(401).end(); + } + const actor = Context.get('actor'); + if ( !(actor.type instanceof UserActorType) || !actor.type.session ) { + return res.status(400).end(); + } + const session_token = svc_auth.create_session_token_for_session( + actor.type.user, + actor.type.session, + ); + res.cookie(config.cookie_name, session_token, { + sameSite: 'none', + secure: true, + httpOnly: true, + }); + return res.status(204).end(); + }, + }).attach(app); + } } module.exports = { diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js new file mode 100644 index 000000000..c903deba0 --- /dev/null +++ b/src/backend/src/services/auth/OIDCService.js @@ -0,0 +1,253 @@ +/* + * 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'; +import jwt from 'jsonwebtoken'; +import { username_exists } from '../../helpers.js'; +import { generate_identifier } from '../../util/identifier.js'; +import { OutcomeObject } from '../../util/outcomeutil.js'; +import BaseService from '../BaseService.js'; +import { DB_WRITE } from '../database/consts.js'; +import { CreatedUserOutcome } from './SignupService.js'; + +const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration'; +const GOOGLE_SCOPES = 'openid email profile'; +const STATE_EXPIRY_SEC = 600; // 10 minutes + +const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate']; + +async function generate_random_username () { + let username; + do { + username = generate_identifier(); + } while ( await username_exists(username) ); + return username; +} + +/** + * OIDC/OAuth2 service for sign-in with Google (and extensible to other providers). + * Uses config.oidc.providers only; no environment variables. + */ +export class OIDCService extends BaseService { + #googleDiscovery; + + async _init () { + this.db = await this.services.get('database').get(DB_WRITE, 'auth'); + this.providers = this.config.providers ?? {}; + this.#googleDiscovery = null; + } + + /** + * Get provider config from config.oidc.providers. For Google, resolve endpoints from discovery. + * @param {string} providerId - e.g. 'google' + * @returns {Promise} Config with client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, scopes + */ + async getProviderConfig (providerId) { + const providers = this.providers; + const raw = providers[providerId]; + if ( !raw || typeof raw !== 'object' || !raw.client_id || !raw.client_secret ) { + return null; + } + if ( providerId === 'google' ) { + const discovery = await this.#getGoogleDiscovery(); + if ( ! discovery ) return null; + return { + client_id: raw.client_id, + client_secret: raw.client_secret, + authorization_endpoint: discovery.authorization_endpoint, + token_endpoint: discovery.token_endpoint, + userinfo_endpoint: discovery.userinfo_endpoint, + scopes: raw.scopes ?? GOOGLE_SCOPES, + }; + } + if ( raw.authorization_endpoint && raw.token_endpoint && raw.userinfo_endpoint ) { + return { + ...raw, + scopes: raw.scopes ?? 'openid email profile', + }; + } + return null; + } + + async #getGoogleDiscovery () { + if ( this.#googleDiscovery ) return this.#googleDiscovery; + try { + const res = await fetch(GOOGLE_DISCOVERY_URL); + if ( ! res.ok ) return null; + this.#googleDiscovery = await res.json(); + return this.#googleDiscovery; + } catch ( e ) { + this.log?.warn?.('OIDC: Google discovery fetch failed', e); + return null; + } + } + + /** + * Return the OAuth callback URL for a given flow. Structure: /auth/oidc/callback/ + * @param {string} flow - e.g. 'login' or 'signup' + * @returns {string|null} Full callback URL, or null if flow is invalid + */ + getCallbackUrlForFlow (flow) { + if ( !flow || !VALID_OIDC_FLOWS.includes(flow) ) return null; + const base = this.global_config.origin || ''; + const callback_url = `${base.replace(/\/$/, '')}/auth/oidc/callback/${flow}`; + this.log.noticeme('CALLBACK URL???', { callback_url }); + return callback_url; + } + + /** + * Build authorization URL for the provider. Callback URL is /auth/oidc/callback/ when flow is provided. + */ + async getAuthorizationUrl (providerId, state, flow) { + const config = await this.getProviderConfig(providerId); + if ( ! config ) return null; + const base = this.getCallbackUrlForFlow(flow) ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + const params = new URLSearchParams({ + client_id: config.client_id, + redirect_uri: base, + response_type: 'code', + scope: config.scopes, + state, + }); + return `${config.authorization_endpoint}?${params.toString()}`; + } + + /** + * Sign state payload for CSRF protection (short-lived JWT). + */ + signState (payload) { + return jwt.sign(payload, + this.global_config.jwt_secret, + { expiresIn: STATE_EXPIRY_SEC }); + } + + verifyState (token) { + try { + return jwt.verify(token, this.global_config.jwt_secret); + } catch ( e ) { + return null; + } + } + + /** + * Exchange authorization code for tokens. redirectUri must match the URL used in getAuthorizationUrl (e.g. /auth/oidc/callback/:flow). + */ + async exchangeCodeForTokens (providerId, code, redirectUri) { + const config = await this.getProviderConfig(providerId); + if ( ! config ) return null; + const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: base, + client_id: config.client_id, + client_secret: config.client_secret, + }); + const res = await fetch(config.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + if ( ! res.ok ) { + const text = await res.text(); + this.log?.warn?.('OIDC token exchange failed', { status: res.status, body: text }); + return null; + } + return await res.json(); + } + + /** + * Get userinfo from provider (e.g. Google userinfo endpoint). + */ + async getUserInfo (providerId, accessToken) { + const config = await this.getProviderConfig(providerId); + if ( !config || !config.userinfo_endpoint ) return null; + const res = await fetch(config.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if ( ! res.ok ) return null; + return await res.json(); + } + + /** + * Find Puter user by provider and IdP subject. Returns user object or null. + */ + async findUserByProviderSub (providerId, providerSub) { + const rows = await this.db.pread('SELECT user_id FROM user_oidc_providers WHERE provider = ? AND provider_sub = ? LIMIT 1', + [providerId, providerSub]); + if ( !rows || rows.length === 0 ) return null; + const svc_get_user = this.services.get('get-user'); + return await svc_get_user.get_user({ id: rows[0].user_id, cached: false }); + } + + /** + * Link an existing Puter user to an OIDC provider identity. + */ + async linkProviderToUser (userId, providerId, providerSub, refreshToken = null) { + try { + await this.db.write('INSERT INTO user_oidc_providers (user_id, provider, provider_sub, refresh_token) VALUES (?, ?, ?, ?)', + [userId, providerId, providerSub, refreshToken]); + } catch ( e ) { + if ( e.message?.includes('UNIQUE') || e.code === 'SQLITE_CONSTRAINT' ) { + // already linked + return; + } + throw e; + } + } + + /** + * Create a new Puter user from OIDC claims and link the provider. Delegates to signup_create_new_user. + */ + async createUserFromOIDC (providerId, claims) { + if ( claims.email_verified === false ) { + // This should never happen; Google always sends verified emails. + const outcome = new OutcomeObject(new CreatedUserOutcome()); + return outcome.fail( + 'Provider did not verify this email address.', + 'oidc.email_not_verified', + ); + } + const svc_signup = this.services.get('signup'); + const outcome = await svc_signup.create_new_user({ + username: await generate_random_username(), + email: claims?.email ?? null, + password: null, + oidc_only: true, + assume_email_ownership: true, + }); + const { user_id } = outcome.infoObject; + if ( outcome.succeeded ) { + await this.linkProviderToUser(user_id, providerId, claims.sub, null); + } + return outcome; + } + + /** + * List provider ids that have valid config (for frontend to show "Sign in with Google" etc.). + */ + async getEnabledProviderIds () { + const providers = this.providers ?? {}; + const ids = []; + for ( const id of Object.keys(providers) ) { + const cfg = await this.getProviderConfig(id); + if ( cfg ) ids.push(id); + } + return ids; + } +} diff --git a/src/backend/src/services/auth/OTPService.js b/src/backend/src/services/auth/OTPService.js index aa7eec9f8..0a167fbf4 100644 --- a/src/backend/src/services/auth/OTPService.js +++ b/src/backend/src/services/auth/OTPService.js @@ -27,7 +27,7 @@ class OTPService extends BaseService { static MODULES = { otpauth: require('otpauth'), crypto: require('crypto'), - ['hi-base32']: require('hi-base32'), + 'hi-base32': require('hi-base32'), }; create_secret (label) { diff --git a/src/backend/src/services/auth/PermissionService.js b/src/backend/src/services/auth/PermissionService.js index c80be97c6..b0372fc5d 100644 --- a/src/backend/src/services/auth/PermissionService.js +++ b/src/backend/src/services/auth/PermissionService.js @@ -74,7 +74,7 @@ class PermissionService extends BaseService { this.dbAvgTimes = { count: 0, avg: 0, max: 0 }; } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_event = this.services.get('event'); // Event to allow extensions to add permissions { diff --git a/src/backend/src/services/auth/PreAuthService.js b/src/backend/src/services/auth/PreAuthService.js index 4b1aa2787..c7c1c9e18 100644 --- a/src/backend/src/services/auth/PreAuthService.js +++ b/src/backend/src/services/auth/PreAuthService.js @@ -2,7 +2,7 @@ const configurable_auth = require('../../middleware/configurable_auth'); const BaseService = require('../BaseService'); class PreAuthService extends BaseService { - async ['__on_install.middlewares.early'] (_, { app }) { + async '__on_install.middlewares.early' (_, { app }) { app.use(configurable_auth({ optional: true })); } } diff --git a/src/backend/src/services/auth/SignupService.js b/src/backend/src/services/auth/SignupService.js new file mode 100644 index 000000000..224a15068 --- /dev/null +++ b/src/backend/src/services/auth/SignupService.js @@ -0,0 +1,276 @@ +//@ts-check +import bcrypt from 'bcrypt'; +import { v4 as uuidv4 } from 'uuid'; +import { generate_random_username, send_email_verification_code, send_email_verification_token, username_exists } from '../../helpers.js'; +import { OutcomeObject } from '../../util/outcomeutil.js'; +import { validate_nonEmpty_string } from '../../util/validutil.js'; +import BaseService from '../BaseService.js'; +import { DB_WRITE } from '../database/consts.js'; + +export class CreatedUserOutcome { + /** + * @type {number|null} + */ + user_id = null; +} + +export class SignupService extends BaseService { + /** + * Creates a new user. + * @async + * @param {object} params - The parameters for creating a new user. + * @param {object} [params.req] - The request object (if applicable). + * @param {boolean} [params.temporary] - Whether the user is a temporary user. + * @param {boolean} [params.oidc_only] - Whether the user created with OIDC + * @param {boolean} [params.send_confirmation_code] - Whether to send a confirmation code instead of a token by email + * @param {boolean} [params.assume_email_ownership] - If true, set email_confirmed=1 without sending verification (e.g. OIDC provider already verified). + * @param {string|null} params.username - The username of the user. + * @param {string|null} params.email - The email of the user. + * @param {string|null} params.password - The password of the user. + * @returns {Promise>} The outcome of the user creation. + */ + async create_new_user ({ + req, + temporary = false, + oidc_only = false, + send_confirmation_code = false, + assume_email_ownership = false, + username = null, + email = null, + password = null, + }) { + const outcome = new OutcomeObject(new CreatedUserOutcome()); + + let raw_email = email; + + if ( ! username ) { + throw new TypeError('username is a required parameter of create_new_user'); + } + if ( !temporary && !validate_nonEmpty_string(email) ) { + throw new TypeError('email is a required parameter of create_new_user'); + } + + // Temp users get default values; they cannot have emails or passwords + if ( temporary ) { + username = username ?? await generate_random_username(); + email = email ?? `${username}@nonexis.com`; + password = 'login-is-not-enabled'; // arbitrary, but accurate + } + + // Some installations of Puter are configured to disable + // signup or temporary users. In these cases, we will specify + // a failure message and abort creating a user. + { + const svc_featureFlag = this.services.get('feature-flag'); + const is_temp_users_disabled = + await svc_featureFlag.check('temp-users-disabled'); + const is_user_signup_disabled = + await svc_featureFlag.check('user-signup-disabled'); + + if ( is_user_signup_disabled && is_temp_users_disabled ) { + return outcome.fail( + 'User signup and Temporary users are disabled.', + 'signup.signup_and_temp_users_disabled', + ); + } + + if ( temporary && is_temp_users_disabled ) { + return outcome.fail( + 'Temporary users are disabled.', + 'signup.temp_users_disabled', + ); + } + + if ( !temporary && is_user_signup_disabled ) { + return outcome.fail( + 'User signup is disabled.', + 'signup.user_signup_disabled', + ); + } + } + + // Emit the `puter.signup` event + // NOTICE: conditional early return + { + const svc_event = this.services.get('event'); + const event = { allow: true, outcome }; + + if ( req ) { + event.ip = req.headers?.['x-forwarded-for'] || + req.connection?.remoteAddress; + event.user_agent = req.headers?.['user-agent']; + event.body = req.body; + } + + await svc_event.emit('puter.signup', event); + + if ( ! event.allow ) { + outcome.log('disallowed by a puter.signup listener'); + return outcome; + } + } + + if ( await username_exists(username) ) { + return outcome.fail( + 'Username already exists', + 'username_already_exists', + ); + } + + // These checks are required for non-temporary users + if ( ! temporary ) { + const db = this.services.get('database').get(DB_WRITE, 'create-user:not-temp-checks'); + const svc_cleanEmail = this.services.get('clean-email'); + raw_email = email; + + if ( ! email ) { + return outcome.fail( + 'An email address is required', + 'email_required', + ); + } + + email = svc_cleanEmail.clean(email); + if ( ! await svc_cleanEmail.validate(email) ) { + return outcome.fail( + 'This email does not seem to be valid', + 'email_invalid', + ); + } + + let rows2 = await db.read(`SELECT EXISTS( + SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL + ) AS email_exists`, [raw_email, email]); + if ( rows2[0].email_exists ) + { + return outcome.fail( + 'Email is already verified for another account', + 'email_already_exists', + ); + } + } + + // TODO: this is where referral goes. We might drop + // referral, so I'm leaving it out here for now. + + const user_uuid = uuidv4(); + const email_confirm_token = uuidv4(); + // TODO: `Math.random()` is not crypto-secure + const email_confirm_code = `${Math.floor(100000 + Math.random() * 900000)}`; + + const audit_metadata = {}; + if ( req ) { + audit_metadata.ip = req.connection.remoteAddress; + audit_metadata.ip_fwd = req.headers['x-forwarded-for']; + audit_metadata.user_agent = req.headers['user-agent']; + audit_metadata.origin = req.headers['origin']; + audit_metadata.server = this.global_config.server_id; + } + + { + const db = this.services.get('database').get(DB_WRITE, 'create-user:main-insert'); + + const insert_res = await db.write(`INSERT INTO user + ( + username, email, clean_email, password, uuid, referrer, + email_confirm_code, email_confirm_token, email_confirmed, free_storage, + referred_by, audit_metadata, signup_ip, signup_ip_forwarded, + signup_user_agent, signup_origin, signup_server + ) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + // username + username, + // email + temporary ? null : raw_email, + // normalized email + temporary ? null : email, + // password + (temporary || oidc_only) ? null : await bcrypt.hash(password, 8), + // uuid + user_uuid, + // referrer + req?.body?.referrer ?? null, + // email_confirm_code + email_confirm_code, + // email_confirm_token + email_confirm_token, + // email_confirmed (1 when assume_email_ownership, else 0) + assume_email_ownership ? 1 : 0, + // free_storage + this.global_config.storage_capacity, + // referred_by + // TODO: we might remove referalls so I'mm leaving out + // the value for the `referred_by` field for now + null, + // audit_metadata + JSON.stringify(audit_metadata), + // signup_ip + req?.connection?.remoteAddress ?? null, + // signup_ip_fwd + req?.headers?.['x-forwarded-for'] ?? null, + // signup_user_agent + req?.headers?.['user-agent'] ?? null, + // signup_origin + req?.headers?.['origin'] ?? null, + // signup_server + this.global_config.server_id ?? null, + ]); + + // record activity (asynchronously) + db.write( + 'UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1', + [insert_res.insertId], + ); + + // TODO: it would be VERY NICE if this was a calculated + // group membership instead of something we store in the DB + const svc_group = this.services.get('group'); + await svc_group.add_users({ + uid: temporary + ? this.global_config.default_temp_group + : this.global_config.default_user_group, + users: [username], + }); + + const user_id = insert_res.insertId; + outcome.infoObject.user_id = user_id; + + const [user] = await db.pread( + 'SELECT * FROM `user` WHERE `id` = ? LIMIT 1', + [user_id]); + + // TODO(???): should user login happen here or by caller? + { + // const { token } = await svc_auth.create_session_token(user, { + // req, + // }); + } + + if ( ! assume_email_ownership ) { + if ( send_confirmation_code ) { + send_email_verification_code(email_confirm_code, email); + } else { + send_email_verification_token(email_confirm_token, email, user_uuid); + } + } + + // TODO: This is where sending the referral code would + // usually happen but we might remove referral so I'm + // leaving it out for now. + const svc_user = this.services.get('user'); + await svc_user.generate_default_fsentries({ user }); + + // NOTE: `res.cookie` happens here in @signup.js but this + // should be handled by the caller over here. + + { + const svc_event = this.services.get('event'); + svc_event.emit('user.save_account', { user }); + } + + return outcome.success(); + } + } +} diff --git a/src/backend/src/services/database/BaseDatabaseAccessService.js b/src/backend/src/services/database/BaseDatabaseAccessService.js index 3d427aa37..4e46eec94 100644 --- a/src/backend/src/services/database/BaseDatabaseAccessService.js +++ b/src/backend/src/services/database/BaseDatabaseAccessService.js @@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService { * * @returns {BaseDatabaseAccessService} The current instance of the service. */ - get () { + get (_accessLevel, _scope) { return this; } diff --git a/src/backend/src/services/database/SqliteDatabaseAccessService.js b/src/backend/src/services/database/SqliteDatabaseAccessService.js index 7fb9ff636..7f10d7278 100644 --- a/src/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/src/backend/src/services/database/SqliteDatabaseAccessService.js @@ -170,6 +170,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { [40, [ '0044_dev-center-godmode.sql', ]], + [41, [ + '0045_user_oidc_providers.sql', + ]], ]; // Database upgrade logic diff --git a/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql b/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql new file mode 100644 index 000000000..b8cc6d8a6 --- /dev/null +++ b/src/backend/src/services/database/sqlite_setup/0045_user_oidc_providers.sql @@ -0,0 +1,16 @@ +-- OIDC/OAuth2: link user accounts to identity providers (e.g. Google) +-- Used for "Sign in with Google" login and signup + +CREATE TABLE `user_oidc_providers` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `user_id` INTEGER NOT NULL, + `provider` VARCHAR(64) NOT NULL, + `provider_sub` VARCHAR(255) NOT NULL, + `refresh_token` TEXT DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(`provider`, `provider_sub`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +); + +CREATE INDEX `idx_user_oidc_providers_provider_sub` ON `user_oidc_providers` (`provider`, `provider_sub`); +CREATE INDEX `idx_user_oidc_providers_user_id` ON `user_oidc_providers` (`user_id`); diff --git a/src/backend/src/services/drivers/DriverService.js b/src/backend/src/services/drivers/DriverService.js index 1f9bfe5f1..a39c0b821 100644 --- a/src/backend/src/services/drivers/DriverService.js +++ b/src/backend/src/services/drivers/DriverService.js @@ -130,7 +130,7 @@ class DriverService extends BaseService { }); } - async ['__on_boot.consolidation'] () { + async '__on_boot.consolidation' () { const svc_registry = this.services.get('registry'); const svc_event = this.services.get('event'); @@ -159,7 +159,7 @@ class DriverService extends BaseService { * This method is responsible for registering collections in the service registry. * It registers 'interfaces', 'drivers', and 'types' collections. */ - async ['__on_registry.collections'] () { + async '__on_registry.collections' () { const svc_registry = this.services.get('registry'); svc_registry.register_collection('interfaces'); svc_registry.register_collection('drivers'); @@ -170,7 +170,7 @@ class DriverService extends BaseService { * It registers 'interfaces', 'drivers', and 'types' collections. * It also populates the 'interfaces' collection with default interfaces and registers the collections with the driver service registry. */ - async ['__on_registry.entries'] () { + async '__on_registry.entries' () { const services = this.services; const svc_registry = services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -193,7 +193,7 @@ class DriverService extends BaseService { // This allows DriverService to be a driver called "driver". // The driver drivers allows checking metered usage for drivers, // and in the future may provide other driver-related functions. - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); @@ -283,13 +283,13 @@ class DriverService extends BaseService { // parameter. To support outdated clients we use this hard-coded // table to map interfaces to default drivers. const iface_to_driver = { - ['puter-ocr']: 'aws-textract', - ['puter-tts']: 'aws-polly', - ['puter-speech2speech']: 'elevenlabs-voice-changer', - ['puter-speech2txt']: 'openai-speech2txt', - ['puter-chat-completion']: 'openai-completion', - ['puter-image-generation']: 'openai-image-generation', - ['puter-video-generation']: 'openai-video-generation', + 'puter-ocr': 'aws-textract', + 'puter-tts': 'aws-polly', + 'puter-speech2speech': 'elevenlabs-voice-changer', + 'puter-speech2txt': 'openai-speech2txt', + 'puter-chat-completion': 'openai-completion', + 'puter-image-generation': 'openai-image-generation', + 'puter-video-generation': 'openai-video-generation', 'puter-apps': 'es:app', 'puter-subdomains': 'es:subdomain', 'puter-notifications': 'es:notification', diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 245fbe24a..1288eb004 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -24,6 +24,10 @@ const { UserActorType } = require('../auth/Actor'); const { Endpoint } = require('../../util/expressutil'); const APIError = require('../../api/APIError.js'); const configurable_auth = require('../../middleware/configurable_auth.js'); +const config = require('../../config'); +const jwt = require('jsonwebtoken'); + +const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; /** * @class UserProtectedEndpointsService @@ -41,6 +45,15 @@ class UserProtectedEndpointsService extends BaseService { express: require('express'), }; + async #revalidateUrlFields (req, user) { + const origin = (config.origin || '').replace(/\/$/, ''); + const svc_oidc = req.services.get('oidc'); + const providers = await svc_oidc.getEnabledProviderIds(); + const provider = providers && providers[0]; + if ( ! provider ) return {}; + return { revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${user.id}` }; + } + /** * Sets up and configures routes for user-protected endpoints. * This method initializes an Express router, applies middleware for authentication, @@ -50,7 +63,7 @@ class UserProtectedEndpointsService extends BaseService { * @instance * @method __on_install.routes */ - ['__on_install.routes'] () { + '__on_install.routes' () { const router = (() => { const require = this.require; const express = require('express'); @@ -74,7 +87,7 @@ class UserProtectedEndpointsService extends BaseService { // Require authenticated session router.use(configurable_auth({ no_options_auth: true })); - // Only allow user sessions, not API tokens for apps + // Only allow user sessions with HTTP powers (session token), not GUI tokens or API tokens router.use((req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); @@ -82,6 +95,9 @@ class UserProtectedEndpointsService extends BaseService { if ( ! (actor.type instanceof UserActorType) ) { return APIError.create('user_tokens_only').write(res); } + if ( ! actor.type.hasHttpOnlyCookie ) { + return APIError.create('session_required').write(res); + } next(); }); @@ -97,46 +113,60 @@ class UserProtectedEndpointsService extends BaseService { router.use(async (req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); - if ( req.user.password === null ) { + if ( req.user.password === null && req.user.email === null ) { return APIError.create('temporary_account').write(res); } next(); }); /** - * Middleware to validate the provided password against the stored user password. - * - * This method ensures that the user has entered their current password correctly before - * allowing changes to critical account settings. It uses bcrypt for password comparison. - * - * @param {Object} req - Express request object, containing user and password in body. - * @param {Object} res - Express response object for sending back the response. - * @param {Function} next - Callback to pass control to the next middleware or route handler. + * Middleware to validate identity: either password (bcrypt) or a valid OIDC revalidation cookie. + * OIDC-only accounts (user.password === null) must use revalidation; password accounts may use either. */ router.use(async (req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); - if ( ! req.body.password ) { - return (APIError.create('password_required')).write(res); - } - - const bcrypt = (() => { - const require = this.require; - return require('bcrypt'); - })(); - const user = await get_user({ id: req.user.id, force: true }); - const isMatch = await bcrypt.compare(req.body.password, user.password); - if ( ! isMatch ) { - return APIError.create('password_mismatch').write(res); + const revalidationCookie = req.cookies && req.cookies[REVALIDATION_COOKIE_NAME]; + + if ( req.body.password ) { + if ( user.password === null ) { + return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res); + } + const bcrypt = (() => { + const require = this.require; + return require('bcrypt'); + })(); + const isMatch = await bcrypt.compare(req.body.password, user.password); + if ( ! isMatch ) { + return APIError.create('password_mismatch').write(res); + } + return next(); } - next(); + + if ( revalidationCookie ) { + try { + const payload = jwt.verify(revalidationCookie, config.jwt_secret); + if ( payload.purpose === 'revalidate' && payload.user_id === req.user.id ) { + return next(); + } + } catch ( e ) { + // invalid or expired + } + } + + if ( user.password === null ) { + return (APIError.create('oidc_revalidation_required', null, await this.#revalidateUrlFields(req, user))).write(res); + } + return (APIError.create('password_required')).write(res); }); Endpoint(require('../../routers/user-protected/change-password.js')).attach(router); Endpoint(require('../../routers/user-protected/change-email.js')).attach(router); + Endpoint(require('../../routers/user-protected/change-username.js')).attach(router); + Endpoint(require('../../routers/user-protected/disable-2fa.js')).attach(router); } } diff --git a/src/backend/src/services/worker/WorkerService.js b/src/backend/src/services/worker/WorkerService.js index c68d28d80..abbb2b6ec 100644 --- a/src/backend/src/services/worker/WorkerService.js +++ b/src/backend/src/services/worker/WorkerService.js @@ -183,7 +183,7 @@ class WorkerService extends BaseService { }); } static IMPLEMENTS = { - ['workers']: { + 'workers': { /** * * @param {{filePath: string, workerName: string, authorization: string}} param0 @@ -333,7 +333,7 @@ class WorkerService extends BaseService { }, }, }; - async ['__on_driver.register.interfaces'] () { + async '__on_driver.register.interfaces' () { const svc_registry = this.services.get('registry'); const col_interfaces = svc_registry.get('interfaces'); diff --git a/src/backend/src/util/.gitignore b/src/backend/src/util/.gitignore new file mode 100644 index 000000000..7493f97ed --- /dev/null +++ b/src/backend/src/util/.gitignore @@ -0,0 +1 @@ +!outcomeutil.js diff --git a/src/backend/src/util/outcomeutil.ts b/src/backend/src/util/outcomeutil.ts new file mode 100644 index 000000000..654f151a2 --- /dev/null +++ b/src/backend/src/util/outcomeutil.ts @@ -0,0 +1,81 @@ +/** + * Represents the outcome of a task that might fail or succeed. + */ +export class OutcomeObject { + /** + * If the task was not successful, this will be the message a user + * sees. + */ + userMessage = null; + + /** + * If the task was not successful, this will be the i18n key for + * the message a user sees. + */ + userMessageKey = null; + + /** + * If the task was not successful, this will be values used for + * a message template that is identified using `userMessageKey`. + */ + userMessageFields = {}; + + /** + * If the task being performed failed + */ + failed = false; + + messages: Record[] = []; + fields = {}; + + /** + * Whether the task being performed has ended, + * either successfully or unsuccessfully. + */ + ended = false; + + infoObject: T; + + constructor (infoObject: T) { + this.failed = true; + this.userMessageFields = {}; + this.infoObject = infoObject; + } + log (text, fields) { + this.messages.push({ text, fields }); + } + + get succeeded () { + return this.ended && !this.failed; + } + + /** + * Records a failure message. + * Returns the outcome object for chaining with a return statement. + * + * @example + * return outcome.fail( + * 'User already exists', + * 'signup.user_already_exists', + * { username: 'john_doe' } + * ); + * + * @param {*} message - message the user sees without i18n + * @param {*} i18nKey - i18n key for the message + * @param {*} fields - fields for i18n-key-identified template + */ + fail (message, i18nKey, fields = {}) { + this.userMessage = message; + this.userMessageKey = i18nKey; + this.userMessageFields = fields; + this.ended = true; + this.failed = true; + return this; + } + + success () { + this.ended = true; + this.failed = false; + return this; + } +} diff --git a/src/backend/src/util/validutil.js b/src/backend/src/util/validutil.js index f992a953b..e90bc7779 100644 --- a/src/backend/src/util/validutil.js +++ b/src/backend/src/util/validutil.js @@ -1,4 +1,4 @@ -const APIError = require("../api/APIError"); +const APIError = require('../api/APIError'); /* * Copyright (C) 2024-present Puter Technologies Inc. @@ -31,7 +31,7 @@ const valid_file_size = v => { const validate_fields = (fields, values) => { // First, check for missing fields (undefined) - const missing_fields = Object.keys(fields).filter(field => ! fields[field].optional && values[field] === undefined); + const missing_fields = Object.keys(fields).filter(field => !fields[field].optional && values[field] === undefined); if ( missing_fields.length > 0 ) { throw APIError.create('fields_missing', null, { keys: missing_fields }); } @@ -54,9 +54,20 @@ const validate_fields = (fields, values) => { })), }); } -} +}; + +const validate_nonEmpty_string = value => { + if ( typeof value !== 'string' ) { + return false; + } + if ( value.length === 0 ) { + return false; + } + return true; +}; module.exports = { valid_file_size, + validate_nonEmpty_string, validate_fields, }; diff --git a/src/backend/tools/test.mjs b/src/backend/tools/test.mjs index bf8d1b482..4ab017945 100644 --- a/src/backend/tools/test.mjs +++ b/src/backend/tools/test.mjs @@ -107,7 +107,7 @@ export class TestKernel extends AdvancedBase { const root_context = Context.create({ services, useapi: this.useapi, - ['runtime-modules']: this.runtimeModuleRegistry, + 'runtime-modules': this.runtimeModuleRegistry, args: {}, }, 'app'); this.root_context = root_context; @@ -135,7 +135,7 @@ export class TestKernel extends AdvancedBase { const mod_context = this._create_mod_context(mod_install_root_context, { name: module.constructor.name, - ['module']: module, + 'module': module, external: false, }); await this.root_context.arun(async () => { diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index fbefd99ae..45244370e 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -17,8 +17,7 @@ * along with this program. If not, see . */ -import TeePromise from '../../util/TeePromise.js'; -import UIComponentWindow from '../UIComponentWindow.js'; +import UIWindowDisable2FA from '../Settings/UIWindowDisable2FA.js'; import UIWindow2FASetup from '../UIWindow2FASetup.js'; import UIWindowChangePassword from '../UIWindowChangePassword.js'; import UIWindowManageSessions from '../UIWindowManageSessions.js'; @@ -148,93 +147,25 @@ const TabSecurity = { }); $el_window.find('.dashboard-section-security .disable-2fa').on('click', async function (e) { - let win; - const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = $win.find('.password-entry').val(); - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - headers: { - Authorization: `Bearer ${puter.authToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { - } - message = message || i18n('error_unknown_cause'); - $win.find('.password-entry').addClass('error'); - $win.find('.error-message').text(message).show(); - return; - } - password_confirm_promise.resolve(true); - $(win).close(); - }; - - let h = ''; - h += '
'; - h += '
'; - h += `

${i18n('disable_2fa_confirm')}

`; - h += `

${i18n('disable_2fa_instructions')}

`; - h += '
'; - h += '
'; - h += ''; - h += ''; - h += '
'; - h += '
'; - h += ``; - h += ``; - h += '
'; - h += '
'; - - win = await UIComponentWindow({ - html: h, - width: 500, - backdrop: true, - is_resizable: false, - body_css: { - width: 'initial', - 'background-color': 'var(--dashboard-input-background)', - 'backdrop-filter': 'blur(3px)', - padding: '20px', + const { promise } = await UIWindowDisable2FA({ + window_options: { + parent_uuid: $el_window.attr('data-element_uuid'), + backdrop: true, + close_on_backdrop_click: true, + parent_center: true, + stay_on_top: true, + has_head: false, }, }); + const tfa_was_disabled = await promise; - // Set up event listeners - const $win = $(win); - const $password_entry = $win.find('.password-entry'); - - $password_entry.on('keypress', (e) => { - if ( e.which === 13 ) { // Enter key - try_password(); - } - }); - - $win.find('.confirm-disable-2fa').on('click', () => { - try_password(); - }); - - $win.find('.cancel-disable-2fa').on('click', () => { - password_confirm_promise.resolve(false); - $win.close(); - }); - - $password_entry.focus(); - - const ok = await password_confirm_promise; - if ( ! ok ) return; - - $el_window.find('.dashboard-section-security .enable-2fa').show(); - $el_window.find('.dashboard-section-security .disable-2fa').hide(); - $el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled')); - $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').removeClass('dashboard-settings-card-success'); - $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').addClass('dashboard-settings-card-warning'); + if ( tfa_was_disabled ) { + $el_window.find('.dashboard-section-security .enable-2fa').show(); + $el_window.find('.dashboard-section-security .disable-2fa').hide(); + $el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled')); + $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').removeClass('dashboard-settings-card-success'); + $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa').addClass('dashboard-settings-card-warning'); + } }); }, }; diff --git a/src/gui/src/UI/Settings/UITabSecurity.js b/src/gui/src/UI/Settings/UITabSecurity.js index 1f961e1e4..342a3e960 100644 --- a/src/gui/src/UI/Settings/UITabSecurity.js +++ b/src/gui/src/UI/Settings/UITabSecurity.js @@ -16,9 +16,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import TeePromise from '../../util/TeePromise.js'; -import UIComponentWindow from '../UIComponentWindow.js'; import UIWindow2FASetup from '../UIWindow2FASetup.js'; +import UIWindowDisable2FA from './UIWindowDisable2FA.js'; export default { id: 'security', @@ -82,89 +81,16 @@ export default { }); $el_window.find('.disable-2fa').on('click', async function (e) { - let win, password_entry; - const password_confirm_promise = new TeePromise(); - const try_password = async () => { - const value = password_entry.get('value'); - const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, { - method: 'POST', - headers: { - Authorization: `Bearer ${puter.authToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - password: value, - }), - }); - if ( resp.status !== 200 ) { - /* eslint no-empty: ["error", { "allowEmptyCatch": true }] */ - let message; try { - message = (await resp.json()).message; - } catch (e) { - } - message = message || i18n('error_unknown_cause'); - password_entry.set('error', message); - return; - } - password_confirm_promise.resolve(true); - $(win).close(); - }; + const { promise } = await UIWindowDisable2FA(); + const tfa_was_disabled = await promise; - let h = ''; - h += '
'; - h += '
'; - h += `

${i18n('disable_2fa_confirm')}

`; - h += `

${i18n('disable_2fa_instructions')}

`; - h += '
'; - h += '
'; - h += ''; - h += ``; - h += ``; - h += '
'; - h += '
'; - - win = await UIComponentWindow({ - html: h, - width: 500, - backdrop: true, - is_resizable: false, - body_css: { - width: 'initial', - 'background-color': 'rgb(245 247 249)', - 'backdrop-filter': 'blur(3px)', - padding: '20px', - }, - }); - - // Set up event listeners - const $win = $(win); - const $password_entry = $win.find('.password-entry'); - - $password_entry.on('keypress', (e) => { - if ( e.which === 13 ) { // Enter key - try_password(); - } - }); - - $win.find('.confirm-disable-2fa').on('click', () => { - try_password(); - }); - - $win.find('.cancel-disable-2fa').on('click', () => { - password_confirm_promise.resolve(false); - $win.close(); - }); - - $password_entry.focus(); - - const ok = await password_confirm_promise; - if ( ! ok ) return; - - $el_window.find('.enable-2fa').show(); - $el_window.find('.disable-2fa').hide(); - $el_window.find('.user-otp-state').text(i18n('two_factor_disabled')); - $el_window.find('.settings-card-security').removeClass('settings-card-success'); - $el_window.find('.settings-card-security').addClass('settings-card-warning'); + if ( tfa_was_disabled ) { + $el_window.find('.enable-2fa').show(); + $el_window.find('.disable-2fa').hide(); + $el_window.find('.user-otp-state').text(i18n('two_factor_disabled')); + $el_window.find('.settings-card-security').removeClass('settings-card-success'); + $el_window.find('.settings-card-security').addClass('settings-card-warning'); + } }); }, }; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 0fd394b2d..f4835e807 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -17,6 +17,7 @@ * along with this program. If not, see . */ +import { openRevalidatePopup } from '../../util/openid.js'; import Placeholder from '../../util/Placeholder.js'; import PasswordEntry from '../Components/PasswordEntry.js'; import UIWindow from '../UIWindow.js'; @@ -41,11 +42,18 @@ async function UIWindowChangeEmail (options) { h += ``; h += ``; h += ''; - // password confirmation - h += '
'; + // password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += `${place_password_entry.html}`; h += '
'; + h += ''; + h += ''; + h += '
'; // Change Email h += ``; @@ -74,6 +82,18 @@ async function UIWindowChangeEmail (options) { show_in_taskbar: false, onAppend: function (this_window) { $(this_window).find('.new-email').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-email-auth-row'); + if ( oidc_only ) { + authRow.find('.change-email-password-wrap').hide(); + const oidcWrap = authRow.find('.change-email-oidc-wrap').show(); + oidcWrap.find('.change-email-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); + } else { + authRow.find('.change-email-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -87,12 +107,34 @@ async function UIWindowChangeEmail (options) { password_entry.attach(place_password_entry); - $(el_window).find('.change-email-btn').on('click', function (e) { - // hide previous error/success msg - $(el_window).find('.form-success-msg, .form-success-msg').hide(); + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-email`; + let revalidated = false; + + const hint = $(el_window).find('.change-email-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-email-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-email-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + }; + + $(el_window).find('.change-email-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_email = $(el_window).find('.new-email').val(); - const password = $(el_window).find('.password').val(); + const password = password_entry.get('value'); + const oidc_only = !!(window.user && window.user.oidc_only); if ( ! new_email ) { $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); @@ -100,46 +142,64 @@ async function UIWindowChangeEmail (options) { return; } - $(el_window).find('.form-error-msg').hide(); + if ( oidc_only && !revalidated && !password ) { + await myOpenRevalidatePopup(); - // disable button + const res = await doSubmit({ new_email }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } + $(el_window).find('.form-error-msg').hide(); $(el_window).find('.change-email-btn').addClass('disabled'); - // disable input $(el_window).find('.new-email').attr('disabled', true); - $.ajax({ - url: `${window.api_origin }/user-protected/change-email`, - type: 'POST', - async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - contentType: 'application/json', - data: JSON.stringify({ - new_email: new_email, - password: password_entry.get('value'), - }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - // update email - window.user.email = new_email; - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseJSON?.message)); - $(el_window).find('.form-error-msg').fadeIn(); - // enable button - $(el_window).find('.change-email-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-email').attr('disabled', false); - }, - }); + let res = await doSubmit({ new_email, password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit({ new_email }); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || 'Request failed'); }); + + function doSubmit ({ new_email, password }) { + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + new_email, + password: password !== undefined && password !== '' ? password : undefined, + }), + }); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } + + function onSuccess () { + const new_email = $(el_window).find('.new-email').val(); + $(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + window.user.email = new_email; + $(el_window).find('.change-email-btn').removeClass('disabled'); + $(el_window).find('.new-email').attr('disabled', false); + } } export default UIWindowChangeEmail; \ No newline at end of file diff --git a/src/gui/src/UI/Settings/UIWindowDisable2FA.js b/src/gui/src/UI/Settings/UIWindowDisable2FA.js new file mode 100644 index 000000000..57b52fae2 --- /dev/null +++ b/src/gui/src/UI/Settings/UIWindowDisable2FA.js @@ -0,0 +1,207 @@ +/** + * Copyright (C) 2026-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 . + */ + +import { openRevalidatePopup } from '../../util/openid.js'; +import Placeholder from '../../util/Placeholder.js'; +import TeePromise from '../../util/TeePromise.js'; +import PasswordEntry from '../Components/PasswordEntry.js'; +import UIWindow from '../UIWindow.js'; + +async function UIWindowDisable2FA (options) { + options = options ?? {}; + + const promise = new TeePromise(); + let disabled_successfully = false; + + const password_entry = new PasswordEntry({}); + const place_password_entry = Placeholder(); + + const internal_id = window.uuidv4(); + let h = ''; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += `

${i18n('disable_2fa_instructions')}

`; + h += '
'; + h += '
'; + h += '
'; + h += ``; + h += `${place_password_entry.html}`; + h += '
'; + h += ''; + h += ''; + h += '
'; + h += ``; + h += '
'; + + const el_window = await UIWindow({ + title: i18n('disable_2fa'), + app: 'disable-2fa', + single_instance: true, + icon: null, + uid: null, + is_dir: false, + body_content: h, + has_head: true, + selectable_body: false, + draggable_body: false, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + width: 350, + height: 'auto', + dominant: true, + show_in_taskbar: false, + on_before_exit: async () => { + if ( ! disabled_successfully ) { + promise.resolve(false); + } + return true; + }, + onAppend: function (this_window) { + $(this_window).find('.disable-2fa-password-wrap input').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.disable-2fa-auth-row'); + if ( oidc_only ) { + authRow.find('.disable-2fa-password-wrap').hide(); + const oidcWrap = authRow.find('.disable-2fa-oidc-wrap').show(); + oidcWrap.find('.disable-2fa-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); + } else { + authRow.find('.disable-2fa-oidc-wrap').hide(); + } + }, + window_class: 'window-publishWebsite', + body_css: { + width: 'initial', + height: '100%', + 'background-color': 'rgb(245 247 249)', + 'backdrop-filter': 'blur(3px)', + }, + ...options.window_options, + }); + + password_entry.attach(place_password_entry); + + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/disable-2fa`; + let revalidated = false; + + const hint = $(el_window).find('.disable-2fa-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.disable-2fa-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.disable-2fa-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + }; + + $(el_window).find('.disable-2fa-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); + + const password = password_entry.get('value'); + const oidc_only = !!(window.user && window.user.oidc_only); + + if ( !oidc_only && !password ) { + $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + + if ( oidc_only && !revalidated && !password ) { + await myOpenRevalidatePopup(); + + const res = await doSubmit({ password: undefined }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } + $(el_window).find('.form-error-msg').hide(); + $(el_window).find('.disable-2fa-btn').addClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', true); + + let res = await doSubmit({ password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit({ password: undefined }); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || 'Request failed'); + }); + + function doSubmit ({ password }) { + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + password: password !== undefined && password !== '' ? password : undefined, + }), + }); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.disable-2fa-btn').removeClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', false); + } + + function onSuccess () { + disabled_successfully = true; + $(el_window).find('.form-success-msg').html(i18n('two_factor_disabled')); + $(el_window).find('.form-success-msg').fadeIn(); + if ( window.user ) window.user.otp = false; + $(el_window).find('.disable-2fa-btn').removeClass('disabled'); + $(el_window).find('.disable-2fa-password-wrap input').attr('disabled', false); + promise.resolve(true); + $(el_window).close(); + } + + return { promise }; +} + +export default UIWindowDisable2FA; diff --git a/src/gui/src/UI/UIComponentWindow.js b/src/gui/src/UI/UIComponentWindow.js index a10baf909..3edffd6e6 100644 --- a/src/gui/src/UI/UIComponentWindow.js +++ b/src/gui/src/UI/UIComponentWindow.js @@ -18,17 +18,20 @@ */ import UIWindow from './UIWindow.js'; import Placeholder from '../util/Placeholder.js'; +import JustHTML from './Components/JustHTML.js'; /** * @typedef {Object} UIComponentWindowOptions - * @property {Component} A component to render in the window + * @property {Component} [component] A component to render in the window + * @property {string} [html] HTML string to render in the window (uses JustHTML component) */ /** - * Render a UIWindow that contains an instance of Component + * Render a UIWindow that contains an instance of Component or HTML string * @param {UIComponentWindowOptions} options */ export default async function UIComponentWindow (options) { + const component = options.component ?? new JustHTML({ html: options.html ?? '' }); const placeholder = Placeholder(); const win = await UIWindow({ @@ -37,8 +40,8 @@ export default async function UIComponentWindow (options) { body_content: placeholder.html, }); - options.component.attach(placeholder); - options.component.focus(); + component.attach(placeholder); + component.focus(); return win; } diff --git a/src/gui/src/UI/UIWindowChangePassword.js b/src/gui/src/UI/UIWindowChangePassword.js index ae0722d78..d7da2fd1d 100644 --- a/src/gui/src/UI/UIWindowChangePassword.js +++ b/src/gui/src/UI/UIWindowChangePassword.js @@ -17,8 +17,9 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; import check_password_strength from '../helpers/check_password_strength.js'; +import { openRevalidatePopup } from '../util/openid.js'; +import UIWindow from './UIWindow.js'; async function UIWindowChangePassword (options) { options = options ?? {}; @@ -30,11 +31,17 @@ async function UIWindowChangePassword (options) { h += '
'; // success msg h += '
'; - // current password - h += '
'; + // current password / OIDC revalidate + h += '
'; + h += '
'; h += ``; h += ``; h += '
'; + h += ''; + h += '
'; // new password h += '
'; h += ``; @@ -45,6 +52,7 @@ async function UIWindowChangePassword (options) { h += ``; h += ``; h += '
'; + h += ''; // Change Password h += ``; @@ -72,7 +80,19 @@ async function UIWindowChangePassword (options) { dominant: true, show_in_taskbar: false, onAppend: function (this_window) { - $(this_window).find('.current-password').get(0).focus({ preventScroll: true }); + $(this_window).find('.current-password').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-password-auth-row'); + if ( oidc_only ) { + authRow.find('.change-password-current-wrap').hide(); + const oidcWrap = authRow.find('.change-password-oidc-wrap').show(); + oidcWrap.find('.change-password-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); + } else { + authRow.find('.change-password-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -84,27 +104,52 @@ async function UIWindowChangePassword (options) { ...options.window_options, }); - $(el_window).find('.change-password-btn').on('click', function (e) { + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-password`; + let revalidated = false; + + const hint = $(el_window).find('.change-password-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-password-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-password-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + }; + + $(el_window).find('.change-password-btn').on('click', async function (e) { const current_password = $(el_window).find('.current-password').val(); const new_password = $(el_window).find('.new-password').val(); const confirm_new_password = $(el_window).find('.confirm-new-password').val(); + const oidc_only = !!(window.user && window.user.oidc_only); - // hide success message - $(el_window).find('.form-success-msg').hide(); + $(el_window).find('.form-success-msg, .form-error-msg').hide(); - // check if all fields are filled - if ( !current_password || !new_password || !confirm_new_password ) { + if ( !new_password || !confirm_new_password ) { $(el_window).find('.form-error-msg').html('All fields are required.'); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check if new password and confirm new password are the same - else if ( new_password !== confirm_new_password ) { + // For password users, current password is required; for OIDC, we need revalidated or will open popup + if ( !oidc_only && !current_password ) { + $(el_window).find('.form-error-msg').html('All fields are required.'); + $(el_window).find('.form-error-msg').fadeIn(); + return; + } + if ( new_password !== confirm_new_password ) { $(el_window).find('.form-error-msg').html(i18n('passwords_do_not_match')); $(el_window).find('.form-error-msg').fadeIn(); return; } - // check password strength const pass_strength = check_password_strength(new_password); if ( ! pass_strength.overallPass ) { $(el_window).find('.form-error-msg').html(i18n('password_strength_error')); @@ -112,31 +157,63 @@ async function UIWindowChangePassword (options) { return; } - $(el_window).find('.form-error-msg').hide(); + if ( oidc_only && !revalidated && !current_password ) { + await myOpenRevalidatePopup(); - $.ajax({ - url: `${window.api_origin }/user-protected/change-password`, - type: 'POST', - async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - contentType: 'application/json', - data: JSON.stringify({ + const res = await doSubmit({ new_password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } + + $(el_window).find('.form-error-msg').hide(); + $(el_window).find('.change-password-btn').addClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', true); + + let res = await doSubmit({ current_password, new_password }); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || res.statusText || 'Request failed'); + }); + + function doSubmit ({ new_password, current_password }) { + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: current_password, new_pass: new_password, }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('password_changed')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseText)); - $(el_window).find('.form-error-msg').fadeIn(); - }, }); - }); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } + + function onSuccess () { + $(el_window).find('.form-success-msg').html(i18n('password_changed')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + $(el_window).find('.change-password-btn').removeClass('disabled'); + $(el_window).find('.current-password, .new-password, .confirm-new-password').attr('disabled', false); + } } export default UIWindowChangePassword; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowChangeUsername.js b/src/gui/src/UI/UIWindowChangeUsername.js index d826cf1ef..c94f10f46 100644 --- a/src/gui/src/UI/UIWindowChangeUsername.js +++ b/src/gui/src/UI/UIWindowChangeUsername.js @@ -17,8 +17,9 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; import update_username_in_gui from '../helpers/update_username_in_gui.js'; +import { openRevalidatePopup } from '../util/openid.js'; +import UIWindow from './UIWindow.js'; async function UIWindowChangeUsername (options) { options = options ?? {}; @@ -26,17 +27,23 @@ async function UIWindowChangeUsername (options) { const internal_id = window.uuidv4(); let h = ''; h += '
'; - // error msg h += '
'; - // success msg h += '
'; - // new username h += '
'; h += ``; h += ``; h += '
'; - - // Change Username + h += '
'; + h += ``; + h += '
'; + h += ``; + h += '
'; + h += ''; + h += ''; + h += '
'; h += ``; h += '
'; @@ -63,6 +70,18 @@ async function UIWindowChangeUsername (options) { show_in_taskbar: false, onAppend: function (this_window) { $(this_window).find('.new-username').get(0)?.focus({ preventScroll: true }); + const oidc_only = !!(window.user && window.user.oidc_only); + const authRow = $(this_window).find('.change-username-auth-row'); + if ( oidc_only ) { + authRow.find('.change-username-password-wrap').hide(); + const oidcWrap = authRow.find('.change-username-oidc-wrap').show(); + oidcWrap.find('.change-username-oidc-flow-notice').text( + i18n('revalidate_flow_notice') || + 'You will be asked to sign in with your linked account when you continue.', + ); + } else { + authRow.find('.change-username-oidc-wrap').hide(); + } }, window_class: 'window-publishWebsite', body_css: { @@ -74,59 +93,102 @@ async function UIWindowChangeUsername (options) { ...options.window_options, }); - $(el_window).find('.change-username-btn').on('click', function (e) { - // hide previous error/success msg - $(el_window).find('.form-success-msg, .form-success-msg').hide(); + const origin = window.gui_origin || window.api_origin || ''; + const apiUrl = `${origin}/user-protected/change-username`; + let revalidated = false; + const hint = $(el_window).find('.change-username-oidc-hint'); + const REVALIDATE_POPUP_TEXT = i18n('revalidate_sign_in_popup') || 'Sign in with your linked account in the popup.'; + + const myOpenRevalidatePopup = async (revalidateUrl) => { + revalidateUrl = revalidateUrl || (window.user && window.user.oidc_revalidate_url); + $(el_window).find('.change-username-btn').addClass('disabled'); + hint.text(REVALIDATE_POPUP_TEXT).show(); + try { + await openRevalidatePopup(revalidateUrl); + } catch (e) { + onError(e.message || 'Authentication failed'); + return; + } finally { + hint.hide(); + } + $(el_window).find('.change-username-revalidated-msg').text(i18n('revalidated') || 'Re-validated.').show(); + }; + + $(el_window).find('.change-username-btn').on('click', async function (e) { + $(el_window).find('.form-success-msg, .form-error-msg').hide(); const new_username = $(el_window).find('.new-username').val(); + const password = $(el_window).find('.change-username-password').val(); + const oidc_only = !!(window.user && window.user.oidc_only); if ( ! new_username ) { $(el_window).find('.form-error-msg').html(i18n('all_fields_required')); $(el_window).find('.form-error-msg').fadeIn(); return; } + if ( oidc_only && !revalidated && !password ) { + $(el_window).find('.change-username-btn').addClass('disabled'); + await myOpenRevalidatePopup(); + const res = await doSubmit(); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + if ( res.ok ) onSuccess(); + else onError(data.message || 'Request failed'); + return; + } $(el_window).find('.form-error-msg').hide(); - - // disable button $(el_window).find('.change-username-btn').addClass('disabled'); - // disable input - $(el_window).find('.new-username').attr('disabled', true); + $(el_window).find('.new-username, .change-username-password').attr('disabled', true); - $.ajax({ - url: `${window.api_origin }/change_username`, - type: 'POST', - async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - contentType: 'application/json', - data: JSON.stringify({ - new_username: new_username, - }), - success: function (data) { - $(el_window).find('.form-success-msg').html(i18n('username_changed')); - $(el_window).find('.form-success-msg').fadeIn(); - $(el_window).find('input').val(''); - // update auth data - update_username_in_gui(new_username); - // update username - window.user.username = new_username; - // enable button - $(el_window).find('.change-username-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-username').attr('disabled', false); - }, - error: function (err) { - $(el_window).find('.form-error-msg').html(html_encode(err.responseJSON?.message)); - $(el_window).find('.form-error-msg').fadeIn(); - // enable button - $(el_window).find('.change-username-btn').removeClass('disabled'); - // enable input - $(el_window).find('.new-username').attr('disabled', false); - }, - }); + let res = await doSubmit(password); + const data = res.ok ? await res.json().catch(() => ({})) : await res.json().catch(() => ({})); + + if ( res.ok ) { + onSuccess(); + return; + } + if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) { + await myOpenRevalidatePopup(data.revalidate_url); + const r = await doSubmit(); + if ( r.ok ) onSuccess(); + else r.json().then((d) => onError(d.message || 'Request failed')).catch(() => onError('Request failed')); + return; + } + onError(data.message || 'Request failed'); }); + + function doSubmit (password) { + const new_username = $(el_window).find('.new-username').val(); + const body = { new_username }; + if ( password !== undefined && password !== '' ) body.password = password; + // Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie) + return fetch(apiUrl, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } + + function onSuccess () { + const new_username = $(el_window).find('.new-username').val(); + $(el_window).find('.form-success-msg').html(i18n('username_changed')); + $(el_window).find('.form-success-msg').fadeIn(); + $(el_window).find('input').val(''); + update_username_in_gui(new_username); + window.user.username = new_username; + $(el_window).find('.change-username-btn').removeClass('disabled'); + $(el_window).find('.new-username, .change-username-password').attr('disabled', false); + } + + function onError (message) { + $(el_window).find('.form-error-msg').html(html_encode(message)); + $(el_window).find('.form-error-msg').fadeIn(); + $(el_window).find('.change-username-btn').removeClass('disabled'); + $(el_window).find('.new-username, .change-username-password').attr('disabled', false); + } } export default UIWindowChangeUsername; \ No newline at end of file diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index ff62d7d87..c944c4b8c 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -17,17 +17,17 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; -import UIWindowSignup from './UIWindowSignup.js'; -import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'; import TeePromise from '../util/TeePromise.js'; -import UIComponentWindow from './UIComponentWindow.js'; -import Flexer from './Components/Flexer.js'; -import CodeEntryView from './Components/CodeEntryView.js'; -import JustHTML from './Components/JustHTML.js'; -import StepView from './Components/StepView.js'; import Button from './Components/Button.js'; +import CodeEntryView from './Components/CodeEntryView.js'; +import Flexer from './Components/Flexer.js'; +import JustHTML from './Components/JustHTML.js'; import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js'; +import StepView from './Components/StepView.js'; +import UIComponentWindow from './UIComponentWindow.js'; +import UIWindow from './UIWindow.js'; +import UIWindowRecoverPassword from './UIWindowRecoverPassword.js'; +import UIWindowSignup from './UIWindowSignup.js'; async function UIWindowLogin (options) { options = options ?? {}; @@ -83,6 +83,10 @@ async function UIWindowLogin (options) { // password recovery h += `

${i18n('forgot_pass_c2a')}

`; h += ''; + h += ''; h += '
'; // create account link @@ -148,6 +152,21 @@ async function UIWindowLogin (options) { }); }); + (async () => { + try { + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); + if ( ! res.ok ) return; + const data = await res.json(); + if ( data.providers && data.providers.includes('google') ) { + $(el_window).find('.oidc-providers-wrapper').show(); + $(el_window).find('.oidc-google-btn').on('click', function () { + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=login`; + }); + } + } catch (_) { + } + })(); + $(el_window).find('.login-btn').on('click', function (e) { // Prevent default button behavior (important for async requests) e.preventDefault(); diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 8b8d6beb3..61be05e5e 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -17,10 +17,10 @@ * along with this program. If not, see . */ -import UIWindow from './UIWindow.js'; -import UIWindowLogin from './UIWindowLogin.js'; -import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'; import check_password_strength from '../helpers/check_password_strength.js'; +import UIWindow from './UIWindow.js'; +import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js'; +import UIWindowLogin from './UIWindowLogin.js'; function UIWindowSignup (options) { options = options ?? {}; @@ -96,6 +96,10 @@ function UIWindowSignup (options) { // Create Account h += ``; h += ''; + h += ''; h += '
'; // login link // create account link @@ -155,6 +159,21 @@ function UIWindowSignup (options) { }; initTurnstile(); + + (async () => { + try { + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); + if ( ! res.ok ) return; + const data = await res.json(); + if ( data.providers && data.providers.includes('google') ) { + $(el_window).find('.oidc-providers-wrapper').show(); + $(el_window).find('.oidc-google-btn').on('click', function () { + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=signup`; + }); + } + } catch (_) { + } + })(); }, window_class: 'window-signup', window_css: { diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 62fcdeac6..539582e4e 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -461,6 +461,27 @@ window.update_auth_data = async (auth_token, user, api_origin) => { window.auth_token = auth_token; localStorage.setItem('auth_token', auth_token); + // Set http-only session cookie when user is changing. + // This ensures user-protected endpoints, which only refer to the http-only cookie, + // act on the intended user. + // Only the server can set this cookie, so we call the `/session/sync-cookie` endpoint. + const userChanging = !window.user || window.user.uuid !== user.uuid; + if ( userChanging && auth_token && (window.gui_origin || window.location?.origin) ) { + try { + const origin = window.gui_origin || window.location.origin; + await fetch(`${origin}/session/sync-cookie`, { + method: 'GET', + credentials: 'include', + headers: { Authorization: `Bearer ${auth_token}` }, + }); + } catch (e) { + console.error('Failed to sync session cookie:', e); + await UIAlert({ + message: `Failed to sync session cookie: ${ e.message}`, + }); + } + } + if ( api_origin ) { window.api_origin = api_origin; localStorage.setItem('api_origin', api_origin); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index ba9752e0c..6e2c1b8d9 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -49,6 +49,10 @@ const en = { change_password: 'Change Password', change_ui_colors: 'Change UI Colors', change_username: 'Change Username', + revalidate_with_google: 'Re-validate with Google', + revalidated: 'Re-validated.', + revalidate_sign_in_popup: 'Sign in with your linked account in the popup.', + revalidate_flow_notice: 'You will be asked to sign in with your linked account when you continue.', color_depth: 'Color Depth', clock_visibility: 'Clock Visibility', close: 'Close', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index aba48a669..b634e37a4 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -17,11 +17,14 @@ * along with this program. If not, see . */ +import UIDashboard from './UI/Dashboard/UIDashboard.js'; import UIAlert from './UI/UIAlert.js'; import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; import UIWindow from './UI/UIWindow.js'; +import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; +import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js'; @@ -30,8 +33,6 @@ import UIWindowRequestPermission from './UI/UIWindowRequestPermission.js'; import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js'; import UIWindowSessionList from './UI/UIWindowSessionList.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; -import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; -import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import { PROCESS_RUNNING } from './definitions.js'; import item_icon from './helpers/item_icon.js'; import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js'; @@ -49,7 +50,6 @@ import { ProcessService } from './services/ProcessService.js'; import { SettingsService } from './services/SettingsService.js'; import { ThemeService } from './services/ThemeService.js'; import { privacy_aware_path } from './util/desktop.js'; -import UIDashboard from './UI/Dashboard/UIDashboard.js'; const launch_services = async function (options) { // === Services Data Structures === @@ -393,6 +393,21 @@ window.initgui = async function (options) { // Launch services before any UI is rendered await launch_services(options); + // If no token in storage but we have a session cookie (e.g. after OIDC redirect), fetch GUI token + if ( !localStorage.getItem('auth_token') && window.auth_token == null ) { + try { + const r = await fetch(`${window.gui_origin}/get-gui-token`, { credentials: 'include' }); + if ( r.ok ) { + const { token } = await r.json(); + window.auth_token = token; + localStorage.setItem('auth_token', token); + if ( typeof puter !== 'undefined' ) puter.setAuthToken(token, window.api_origin); + } + } catch (e) { + // ignore + } + } + //-------------------------------------------------------------------------------------- // Is attempt_temp_user_creation? // i.e. https://puter.com/?attempt_temp_user_creation=true diff --git a/src/gui/src/util/openid.js b/src/gui/src/util/openid.js new file mode 100644 index 000000000..ab73ef6ec --- /dev/null +++ b/src/gui/src/util/openid.js @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026-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 . + */ + +import TeePromise from './TeePromise.js'; + +/** + * This file contains common functions that are used to re-authenticate an + * OIDC-authenticated user when performing actions on protected endpoints. + * + * No design patterns, no abstractions; only simple functions. + * (this is not merely a description; it is a guideline for future changes) + */ + +const POPUP_FEATURES = 'width=500,height=600'; + +export const openRevalidatePopup = async (revalidateUrl) => { + const donePromise = new TeePromise(); + + const url = revalidateUrl; + if ( ! url ) { + throw new Error('No revalidate URL'); + } + let doneCalled = false; + const popup = window.open(url, 'puter-revalidate', POPUP_FEATURES); + const onMessage = ev => { + if ( (ev.origin !== window.gui_origin) && (ev.origin !== window.location.origin) ) return; + if ( !ev.data || ev.data.type !== 'puter-revalidate-done' ) return; + if ( doneCalled ) return; + doneCalled = true; + window.removeEventListener('message', onMessage); + donePromise.resolve(); + }; + window.addEventListener('message', onMessage); + const checkClosed = setInterval(() => { + if ( popup && popup.closed ) { + clearInterval(checkClosed); + window.removeEventListener('message', onMessage); + if ( ! doneCalled ) { + doneCalled = true; + donePromise.reject(new Error('Popup closed')); + } + } + }, 300); + await donePromise; +};