Merge pull request #2460 from HeyPuter/eric/262A0_PUT-453

Login/Signup with OIDC

Wellp... this is not a rebase, this is merge. My hands were tied this time.
This commit is contained in:
Eric Dubé
2026-02-19 22:39:44 -05:00
committed by GitHub
119 changed files with 2315 additions and 525 deletions
+1
View File
@@ -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,
+20
View File
@@ -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,
+6
View File
@@ -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);
+4 -4
View File
@@ -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' ) {
+3 -3
View File
@@ -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,
});
+8
View File
@@ -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': {
+6
View File
@@ -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,
@@ -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 () {
+9
View File
@@ -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');
@@ -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,
@@ -17,13 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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');
@@ -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 });
@@ -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 }) => {
@@ -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');
@@ -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 }));
}
+1 -1
View File
@@ -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'));
}
@@ -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', [
{
+1 -1
View File
@@ -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', [
{
+1 -1
View File
@@ -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'));
}
@@ -97,7 +97,7 @@ class ParameterService extends BaseService {
* for parameter management.
* @private
*/
['__on_boot.consolidation'] () {
'__on_boot.consolidation' () {
this._registerCommands(this.services.get('commands'));
}
@@ -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 });
},
@@ -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');
@@ -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();
@@ -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');
@@ -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');
@@ -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 = {};
@@ -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', [
{
@@ -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 ) {
@@ -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;
@@ -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) => {
@@ -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 ) {
@@ -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.");
}
}
@@ -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');
@@ -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,
}) {
@@ -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}
*/
@@ -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<void>} 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=<GUI 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
+3 -3
View File
@@ -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;
+254
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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(`<!DOCTYPE html><html><head><title>Re-validated</title></head><body><script>
(function(){
var origin = ${JSON.stringify(origin)};
if (window.opener) {
try { window.opener.postMessage({ type: 'puter-revalidate-done' }, origin); } catch (e) {}
window.close();
} else {
document.body.innerHTML = '<p>Re-validated. You can close this tab.</p>';
}
})();
</script><p>Re-validated. Closing&hellip;</p></body></html>`);
});
export default router;
+2 -2
View File
@@ -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');
}
@@ -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,
} : {}),
})}`);
}
+6 -6
View File
@@ -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,
+6 -5
View File
@@ -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,
+7 -6
View File
@@ -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,
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<object|null>} 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;
@@ -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 <https://www.gnu.org/licenses/>.
*/
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({});
},
};
+16 -4
View File
@@ -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<string, any> & { services?: Record<string, any>; server_id?: string };
name?: string;
@@ -41,7 +41,7 @@ class BootScriptService extends BaseService {
* @function
* @returns {Promise<void>}
*/
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]);
},
+1 -1
View File
@@ -40,7 +40,7 @@ class ChatAPIService extends BaseService {
* @param {Express} options.app Express application instance to install routes on
* @returns {Promise<void>}
*/
async ['__on_install.routes'] (_, { app }) {
async '__on_install.routes' (_, { app }) {
// Create a router for chat API endpoints
const router = (() => {
const require = this.require;
+1 -1
View File
@@ -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 = {
@@ -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');
}
@@ -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());
}
+3 -3
View File
@@ -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');
+1 -1
View File
@@ -59,7 +59,7 @@ class EventService extends BaseService {
this.global_listeners_ = [];
}
async ['__on_boot.ready'] () {
async '__on_boot.ready' () {
this.emit('ready', {}, {});
}
@@ -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 <caption>with a specified actor</caption>
* check({ actor }, 'flag-name');
* @example <caption>with actor in context</caption>
* check('flag-name');
*/
async check (...a) {
// allows binding call with multiple options objects;
@@ -38,7 +38,7 @@ class FilesystemAPIService extends BaseService {
* @function __on_install.routes
* @returns {Promise<void>} 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
@@ -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.
*
@@ -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');
@@ -43,7 +43,7 @@ class LocalDiskStorageService extends BaseService {
*
* @returns {Promise<void>} 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);
@@ -110,7 +110,7 @@ class MakeProdDebuggingLessAwfulService extends BaseService {
* @param {Express} options.app Express application instance
* @returns {Promise<void>}
*/
async ['__on_install.middlewares.context-aware'] (_, { app }) {
async '__on_install.middlewares.context-aware' (_, { app }) {
// Add express middleware
this.mw.install(app);
}
@@ -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();
@@ -41,7 +41,7 @@ class PermissionAPIService extends BaseService {
* @param {Express} options.app Express application instance to install routes on
* @returns {Promise<void>}
*/
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'));
+2 -1
View File
@@ -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'));
@@ -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 {
<!-- Files from JSON (may be empty) -->
${((!bundled && manifest?.css_paths)
? manifest.css_paths.map(path => `<link rel="stylesheet" href="${path}">\n`)
: []).join('')
? manifest.css_paths.map(path => `<link rel="stylesheet" href="${path}">\n`)
: []).join('')
}
<!-- END Files from JSON -->
@@ -43,7 +43,7 @@ class PuterVersionService extends BaseService {
* @async
* @returns {Promise<void>} 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'));
}
@@ -36,7 +36,7 @@ class RefreshAssociationsService extends BaseService {
* @async
* @returns {Promise<void>} - 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');
/**
+1 -1
View File
@@ -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');
@@ -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();
+1 -1
View File
@@ -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'],
+1 -1
View File
@@ -53,7 +53,7 @@ export class SUService extends BaseService {
* @returns {Promise<void>} 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({
+1 -1
View File
@@ -35,7 +35,7 @@ class ServeGUIService extends BaseService {
* @async
* @returns {Promise<void>} 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?
+1 -1
View File
@@ -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 });
}
+7 -2
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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');
@@ -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,
},
@@ -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);
}
}
@@ -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');
@@ -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();
@@ -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
@@ -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);
},
@@ -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();
},
@@ -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
@@ -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();
},
@@ -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 [];
@@ -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);
},
@@ -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);
},
+1 -1
View File
@@ -101,7 +101,7 @@ class ACLService extends BaseService {
* @returns {Promise<boolean>} 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:
+3 -1
View File
@@ -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;
}
+12 -5
View File
@@ -16,12 +16,12 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.
*
@@ -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', {
+141 -8
View File
@@ -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 = {
@@ -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 <https://www.gnu.org/licenses/>.
*/
'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<object|null>} 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/<flow>
* @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/<flow> 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;
}
}
+1 -1
View File
@@ -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) {
@@ -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
{
@@ -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 }));
}
}
@@ -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<OutcomeObject<CreatedUserOutcome>>} 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();
}
}
}
@@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService {
*
* @returns {BaseDatabaseAccessService} The current instance of the service.
*/
get () {
get (_accessLevel, _scope) {
return this;
}
@@ -170,6 +170,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
[40, [
'0044_dev-center-godmode.sql',
]],
[41, [
'0045_user_oidc_providers.sql',
]],
];
// Database upgrade logic
@@ -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`);
@@ -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',

Some files were not shown because too many files have changed in this diff Show More