From 7c8f0d5572eb02035cd0e18dd16bd4f8f2e75396 Mon Sep 17 00:00:00 2001
From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com>
Date: Tue, 10 Feb 2026 13:12:01 -0500
Subject: [PATCH] dev(backend): OIDC continued [1]
This commit is rather monolithic. An attempt to split it up into smaller
changes proved too difficult (as well as frustrating) and I realized it
would absolutely increase the chance of having a broken commit (making
bisects more difficult) unless a lot of testing effort between commits
was performed, which would have very little benefit.
The changes in this commit include:
- Outcome utility used by SignupService for error handling
- SignupService, whichs implements re-usable create_user function
- Signup method in OIDCService
- flow-specific callbacks in OIDC (separates login from signup)
- **SEPARATE SESSION COOKIE AND GUI COOKIE**
- this change "rocks the boat" the most and has the highest likelihood
of causing problems
---
src/backend/src/CoreModule.js | 3 +
src/backend/src/api/APIError.js | 4 +
src/backend/src/helpers.js | 10 +
.../src/middleware/configurable_auth.js | 3 +-
src/backend/src/routers/auth/oidc.js | 160 ++++++-----
src/backend/src/routers/login.js | 12 +-
src/backend/src/routers/logout.js | 2 +
src/backend/src/routers/save_account.js | 11 +-
src/backend/src/routers/signup.js | 13 +-
.../src/routers/signup_create_new_user.js | 127 ++++++++
src/backend/src/services/BaseService.d.ts | 20 +-
.../src/services/FeatureFlagService.js | 8 +-
src/backend/src/services/UserService.js | 7 +-
src/backend/src/services/auth/Actor.d.ts | 4 +-
src/backend/src/services/auth/Actor.js | 17 +-
src/backend/src/services/auth/AuthService.js | 102 ++++++-
src/backend/src/services/auth/OIDCService.js | 139 +++------
.../src/services/auth/SignupService.js | 270 ++++++++++++++++++
.../database/BaseDatabaseAccessService.js | 2 +-
.../web/UserProtectedEndpointsService.js | 7 +-
src/backend/src/util/outcomeutil.js | 32 +++
src/backend/src/util/outcomeutil.ts | 77 +++++
src/backend/src/util/validutil.js | 17 +-
.../src/UI/Settings/UIWindowChangeEmail.js | 8 +-
src/gui/src/UI/UIWindowLogin.js | 6 +-
src/gui/src/UI/UIWindowSignup.js | 6 +-
src/gui/src/initgui.js | 21 +-
27 files changed, 868 insertions(+), 220 deletions(-)
create mode 100644 src/backend/src/routers/signup_create_new_user.js
create mode 100644 src/backend/src/services/auth/SignupService.js
create mode 100644 src/backend/src/util/outcomeutil.js
create mode 100644 src/backend/src/util/outcomeutil.ts
diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js
index 36ebe6434..cea0be5df 100644
--- a/src/backend/src/CoreModule.js
+++ b/src/backend/src/CoreModule.js
@@ -272,6 +272,9 @@ const install = async ({ context, services, app, useapi, modapi }) => {
const { OIDCService } = require('./services/auth/OIDCService');
services.registerService('oidc', OIDCService);
+ const { SignupService } = require('./services/auth/SignupService');
+ services.registerService('signup', SignupService);
+
const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService');
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js
index 9b0879bdb..496348f64 100644
--- a/src/backend/src/api/APIError.js
+++ b/src/backend/src/api/APIError.js
@@ -453,6 +453,10 @@ class APIError {
status: 403,
message: 'This endpoint must be requested with a user session',
},
+ 'session_required': {
+ status: 403,
+ message: 'This endpoint requires a full session (e.g. change password cannot be done with a GUI token).',
+ },
'temporary_accounts_not_allowed': {
status: 403,
message: 'Temporary accounts cannot perform this action',
diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js
index da2862220..b14f4ad55 100644
--- a/src/backend/src/helpers.js
+++ b/src/backend/src/helpers.js
@@ -30,6 +30,7 @@ const { NodeUIDSelector } = require('./filesystem/node/selectors');
const { redisClient } = require('./clients/redis/redisSingleton');
const { kv } = require('./util/kvSingleton');
const { APP_ICONS_SUBDOMAIN } = require('./consts/app-icons.js');
+const { generate_identifier } = require('./util/identifier');
const identifying_uuid = require('uuid').v4();
@@ -1479,6 +1480,14 @@ async function username_exists (username) {
}
}
+async function generate_random_username () {
+ let username;
+ do {
+ username = generate_identifier();
+ } while ( await username_exists(username) );
+ return username;
+}
+
async function app_name_exists (name) {
/** @type BaseDatabaseAccessService */
const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem');
@@ -2070,6 +2079,7 @@ module.exports = {
suggestedAppForFsEntry,
df,
username_exists,
+ generate_random_username,
uuid2fsentry,
validate_fsentry_name,
validate_signature_auth,
diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js
index e65fdb279..364acaf41 100644
--- a/src/backend/src/middleware/configurable_auth.js
+++ b/src/backend/src/middleware/configurable_auth.js
@@ -134,7 +134,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 hasHttpPowers; client gets GUI token in response
+ res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, {
sameSite: 'none',
secure: true,
httpOnly: true,
diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js
index ac54c2e90..52e5bee30 100644
--- a/src/backend/src/routers/auth/oidc.js
+++ b/src/backend/src/routers/auth/oidc.js
@@ -20,32 +20,61 @@
const express = require('express');
const router = new express.Router();
const config = require('../../config');
+const { get_user } = require('../../helpers');
-const complete_ = async ({ req, res, user }) => {
+/** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */
+const finishOidcSuccess_ = async (req, res, user, stateDecoded) => {
+ console.log('okay finishOidSuccess_ is happening');
const svc_auth = req.services.get('auth');
- const { token } = await svc_auth.create_session_token(user, { req });
- res.cookie(config.cookie_name, token, {
+ const { session, token: session_token } = await svc_auth.create_session_token(user, { req });
+ res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
- return res.send({
- proceed: true,
- next_step: 'complete',
- token,
- user: {
- username: user.username,
- uuid: user.uuid,
- email: user.email,
- email_confirmed: user.email_confirmed,
- is_temp: (user.password === null && user.email === null),
- },
+ console.log('what are these values?', {
+ stateDecoded,
});
+ let target = stateDecoded.redirect_uri || config.origin || '/';
+ const origin = config.origin || '';
+ console.log('okay what\'s target though?', { target, origin });
+ if ( target && origin && !target.startsWith(origin) ) {
+ target = origin;
+ }
+ return res.redirect(302, target);
+};
+
+/** Exchange code for tokens, get userinfo; returns { provider, userinfo, stateDecoded } or sends error and returns null. */
+const oidcCallbackPreamble_ = async (req, res, callbackRedirectUri) => {
+ const svc_oidc = req.services.get('oidc');
+ const code = req.query.code;
+ const state = req.query.state;
+ if ( !code || !state ) {
+ res.status(400).send('Missing code or state.');
+ return null;
+ }
+ const stateDecoded = svc_oidc.verifyState(state);
+ if ( !stateDecoded || !stateDecoded.provider ) {
+ res.status(400).send('Invalid or expired state.');
+ return null;
+ }
+ const provider = stateDecoded.provider;
+ const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri);
+ if ( !tokens || !tokens.access_token ) {
+ res.status(401).send('Token exchange failed.');
+ return null;
+ }
+ const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token);
+ if ( !userinfo || !userinfo.sub ) {
+ res.status(401).send('Could not get user info.');
+ return null;
+ }
+ return { provider, userinfo, stateDecoded };
};
// GET /auth/oidc/providers - list enabled provider ids for frontend
router.get('/auth/oidc/providers', async (req, res) => {
- if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) {
+ if ( require('../../helpers').subdomain(req) !== 'api' ) {
return res.status(404).end();
}
const svc_oidc = req.services.get('oidc');
@@ -55,7 +84,7 @@ router.get('/auth/oidc/providers', async (req, res) => {
// GET /auth/oidc/:provider/start - redirect to IdP authorization
router.get('/auth/oidc/:provider/start', async (req, res) => {
- if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) {
+ if ( require('../../helpers').subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
@@ -68,74 +97,71 @@ router.get('/auth/oidc/:provider/start', async (req, res) => {
if ( ! cfg ) {
return res.status(404).send('Provider not configured.');
}
- const redirectUri = req.query.redirect_uri ? String(req.query.redirect_uri) : undefined;
- const statePayload = { provider, redirect_uri: redirectUri };
+ const flow = req.query.flow ? String(req.query.flow) : undefined;
+ const flowRedirects = {
+ login: config.origin || '/',
+ signup: config.origin || '/',
+ };
+ const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/');
+ const statePayload = { provider, redirect_uri: appRedirectUri };
const state = svc_oidc.signState(statePayload);
- const url = await svc_oidc.getAuthorizationUrl(provider, state, redirectUri ? undefined : undefined);
+ 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 - handle IdP redirect (code + state)
-router.get('/auth/oidc/callback', async (req, res) => {
- if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) {
+// 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 ( require('../../helpers').subdomain(req) !== '' ) {
return res.status(404).end();
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('login') ) {
return res.status(429).send('Too many requests.');
}
- const code = req.query.code;
- const state = req.query.state;
- if ( !code || !state ) {
- return res.status(400).send('Missing code or state.');
+ const svc_oidc = req.services.get('oidc');
+ const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login');
+ const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri);
+ if ( ! preamble ) return;
+ const { provider, userinfo, stateDecoded } = preamble;
+ 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.');
+ }
+ return await finishOidcSuccess_(req, res, user, stateDecoded);
+});
+
+// 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 ( require('../../helpers').subdomain(req) !== '' ) {
+ return res.status(404).end();
+ }
+ const svc_edgeRateLimit = req.services.get('edge-rate-limit');
+ if ( ! svc_edgeRateLimit.check('login') ) {
+ return res.status(429).send('Too many requests.');
}
const svc_oidc = req.services.get('oidc');
- const stateDecoded = svc_oidc.verifyState(state);
- if ( !stateDecoded || !stateDecoded.provider ) {
- return res.status(400).send('Invalid or expired state.');
+ const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup');
+ const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri);
+ if ( ! preamble ) return;
+ const { provider, userinfo, stateDecoded } = preamble;
+ const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
+ if ( existingUser ) {
+ return res.status(400).send('Account already exists. Log in instead.');
}
- const provider = stateDecoded.provider;
- const redirectUri = `${config.api_base_url}/auth/oidc/callback`;
- const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, redirectUri);
- if ( !tokens || !tokens.access_token ) {
- return res.status(401).send('Token exchange failed.');
+ const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo);
+ if ( outcome.failed ) {
+ console.log('it looks like the outcome failed...');
+ return res.status(400).send(outcome.userMessage);
}
- const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token);
- if ( !userinfo || !userinfo.sub ) {
- return res.status(401).send('Could not get user info.');
- }
- let user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
- if ( user ) {
- if ( user.suspended ) {
- return res.status(401).send('This account is suspended.');
- }
- return await complete_({ req, res, user });
- }
- user = await svc_oidc.createUserFromOIDC(provider, userinfo);
- if ( ! user ) {
- return res.status(400).send('Email already registered. Please log in with your password and link your Google account, or use a different email.');
- }
- const accept = req.headers.accept || '';
- const wantsRedirect = accept.includes('text/html');
- if ( wantsRedirect ) {
- const svc_auth = req.services.get('auth');
- const { token } = await svc_auth.create_session_token(user, { req });
- res.cookie(config.cookie_name, token, {
- sameSite: 'none',
- secure: true,
- httpOnly: true,
- });
- let target = stateDecoded.redirect_uri || config.origin || '/';
- const origin = config.origin || '';
- if ( target && origin && !target.startsWith(origin) ) {
- target = origin;
- }
- return res.redirect(302, target);
- }
- return await complete_({ req, res, user });
+ const user = await get_user({ id: outcome.infoObject.user_id });
+ console.log('got user????', user);
+ return await finishOidcSuccess_(req, res, user, stateDecoded);
});
module.exports = router;
diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js
index 6aac43f51..21948e518 100644
--- a/src/backend/src/routers/login.js
+++ b/src/backend/src/routers/login.js
@@ -26,21 +26,21 @@ const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middle
const complete_ = async ({ req, res, user }) => {
const svc_auth = req.services.get('auth');
- const { token } = await svc_auth.create_session_token(user, { req });
+ const { session, token: session_token } = await svc_auth.create_session_token(user, { req });
+ const gui_token = svc_auth.create_gui_token(user, session);
- //set cookie
- // res.cookie(config.cookie_name, token);
- res.cookie(config.cookie_name, token, {
+ // HTTP-only cookie gets session token (cookie-based requests have hasHttpPowers)
+ res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
});
- // send response
+ // response body: GUI token only (client never gets session token)
return res.send({
proceed: true,
next_step: 'complete',
- token: token,
+ token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
diff --git a/src/backend/src/routers/logout.js b/src/backend/src/routers/logout.js
index 84cbe275b..da3ca586a 100644
--- a/src/backend/src/routers/logout.js
+++ b/src/backend/src/routers/logout.js
@@ -51,7 +51,9 @@ router.post('/logout', auth, express.json(), async (req, res, next) => {
//---------------------------------------------------------
// DANGER ZONE: delete temp user and all its data
//---------------------------------------------------------
+ console.log('wait... what are these?', req.user.password, req.user.email);
if ( req.user.password === null && req.user.email === null ) {
+ console.log('ACTUALLY DELETING A USER');
const { deleteUser } = require('../helpers');
deleteUser(req.user.id);
}
diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js
index 5d4cb342c..32471dc55 100644
--- a/src/backend/src/routers/save_account.js
+++ b/src/backend/src/routers/save_account.js
@@ -208,9 +208,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
@@ -220,8 +221,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 hasHttpPowers)
+ res.cookie(config.cookie_name, session_token);
{
const svc_event = req.services.get('event');
@@ -230,7 +231,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
// return results
return res.send({
- token: token,
+ token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js
index 0e96ee4e2..1bf9e4499 100644
--- a/src/backend/src/routers/signup.js
+++ b/src/backend/src/routers/signup.js
@@ -421,11 +421,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
@@ -457,8 +458,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 hasHttpPowers)
+ res.cookie(config.cookie_name, session_token, {
sameSite: 'none',
secure: true,
httpOnly: true,
@@ -472,7 +473,7 @@ module.exports = eggspress(['/signup'], {
// return results
return res.send({
- token: token,
+ token: gui_token,
user: {
username: user.username,
uuid: user.uuid,
diff --git a/src/backend/src/routers/signup_create_new_user.js b/src/backend/src/routers/signup_create_new_user.js
new file mode 100644
index 000000000..fa577953a
--- /dev/null
+++ b/src/backend/src/routers/signup_create_new_user.js
@@ -0,0 +1,127 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+'use strict';
+const config = require('../config');
+const { DB_WRITE } = require('../services/database/consts');
+const { generate_identifier } = require('../util/identifier');
+const { v4: uuidv4 } = require('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