From f6408db74e00c0efc2917bf79b3a8d7d5562be08 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Mon, 8 Jun 2026 18:47:07 -0700 Subject: [PATCH] fix: access tokens with full accesses allowed through with new middleware gate (#3234) --- extensions/installedApps.ts | 2 +- src/backend/controllers/apps/AppController.js | 3 + .../controllers/auth/AuthController.test.ts | 73 +++++++++++ .../controllers/auth/AuthController.ts | 19 ++- .../controllers/desktop/DesktopController.js | 4 + .../controllers/fs/LegacyFSController.ts | 5 + .../controllers/hosting/HostingController.js | 1 + .../notification/NotificationController.ts | 12 +- .../puterai/PuterAIController.test.ts | 10 +- .../controllers/puterai/PuterAIController.ts | 6 + .../controllers/system/SystemController.js | 1 + src/backend/core/actor.ts | 9 ++ src/backend/core/http/middleware/antiCsrf.js | 13 ++ .../core/http/middleware/antiCsrf.test.js | 45 +++++++ .../core/http/middleware/gates.test.ts | 105 ++++++++++++++++ src/backend/core/http/middleware/gates.ts | 29 ++++- src/backend/core/http/types.ts | 11 ++ src/backend/server.ts | 6 +- .../services/appIcon/AppIconService.test.ts | 92 ++++++++++++++ .../services/appIcon/AppIconService.ts | 15 ++- src/backend/services/auth/AuthService.test.ts | 114 ++++++++++++++++++ src/backend/services/auth/AuthService.ts | 54 ++++++++- src/backend/services/auth/types.ts | 6 + .../services/permission/PermissionService.ts | 23 ++++ src/backend/services/permission/consts.ts | 16 +++ src/backend/util/dbError.test.ts | 53 ++++++++ src/backend/util/dbError.ts | 44 +++++++ src/gui/src/UI/Dashboard/TabAccount.js | 8 +- src/gui/src/UI/UIWindowAuthMe.js | 3 +- src/gui/src/UI/UIWindowCopyToken.js | 109 ++++++++++++++--- src/gui/src/helpers/create_access_token.js | 70 +++++++++++ src/gui/src/i18n/translations/en.js | 20 +++ src/gui/src/initgui.js | 37 +++++- 33 files changed, 977 insertions(+), 41 deletions(-) create mode 100644 src/backend/services/appIcon/AppIconService.test.ts create mode 100644 src/backend/util/dbError.test.ts create mode 100644 src/backend/util/dbError.ts create mode 100644 src/gui/src/helpers/create_access_token.js diff --git a/extensions/installedApps.ts b/extensions/installedApps.ts index 9e74ce130..9b3014897 100644 --- a/extensions/installedApps.ts +++ b/extensions/installedApps.ts @@ -71,6 +71,6 @@ export const handleInstalledApps = async ( extension.get( '/installedApps', - { subdomain: 'api', requireUserActor: true }, + { subdomain: 'api', requireUserActor: true, allowFullAccessToken: true }, handleInstalledApps, ); diff --git a/src/backend/controllers/apps/AppController.js b/src/backend/controllers/apps/AppController.js index d2ae385b7..71b563c36 100644 --- a/src/backend/controllers/apps/AppController.js +++ b/src/backend/controllers/apps/AppController.js @@ -56,6 +56,7 @@ export class AppController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const apps = await this.appDriver.select({ @@ -71,6 +72,7 @@ export class AppController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const name = req.query?.name; @@ -185,6 +187,7 @@ export class AppController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const raw = req.params.name; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index a73fa9c30..419640b7e 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -39,6 +39,7 @@ import { runWithContext } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; import { requireUserActorGate } from '../../core/http/middleware/gates.js'; import { PuterServer } from '../../server.js'; +import { FULL_API_ACCESS } from '../../services/permission/consts.js'; import { setupTestServer } from '../../testUtil.js'; // ── Test harness ──────────────────────────────────────────────────── @@ -1287,6 +1288,78 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', () expect(decoded.user_uid).toBe(actor.user.uuid); }); + // -- Full-API-access + labels -- + + it('mints a full-access token and stores a trimmed label on the session row', async () => { + const res = makeRes(); + await controller.handleCreateAccessToken( + makeReq( + { permissions: [FULL_API_ACCESS], label: ' My CLI ' }, + { actor }, + ), + res, + ); + const decoded = server.services.token.verify( + 'auth', + (res.body as { token: string }).token, + ) as { + type: string; + token_uid: string; + session_uid: string; + full_access?: boolean; + }; + expect(decoded.type).toBe('access-token'); + + // Full access is a signed claim, not a stored permission row. + expect(decoded.full_access).toBe(true); + const permRows = (await server.clients.db.read( + 'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?', + [decoded.token_uid], + )) as Array<{ permission: string }>; + expect(permRows).toHaveLength(0); + + const sessRows = (await server.clients.db.read( + 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', + [decoded.session_uid], + )) as Array<{ label: string }>; + expect(sessRows[0]?.label).toBe('My CLI'); + }); + + it('caps an over-long label at 64 characters', async () => { + const res = makeRes(); + await controller.handleCreateAccessToken( + makeReq( + { permissions: [FULL_API_ACCESS], label: 'x'.repeat(120) }, + { actor }, + ), + res, + ); + const decoded = server.services.token.verify( + 'auth', + (res.body as { token: string }).token, + ) as { session_uid: string }; + const sessRows = (await server.clients.db.read( + 'SELECT `label` FROM `sessions` WHERE `uuid` = ?', + [decoded.session_uid], + )) as Array<{ label: string }>; + expect(sessRows[0]?.label.length).toBe(64); + }); + + it('rejects a non-string label with 400', async () => { + await expect( + controller.handleCreateAccessToken( + makeReq( + { + permissions: [FULL_API_ACCESS], + label: 123 as unknown as string, + }, + { actor }, + ), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + it('revoke-access-token: requires tokenOrUuid and returns ok:true on success', async () => { // Mint, then revoke. const created = makeRes(); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 26d59c9fe..7ec17c8f7 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -2212,13 +2212,25 @@ export class AuthController extends PuterController { requireAuth: true, }) async handleCreateAccessToken(req: Request, res: Response): Promise { - const { permissions, expiresIn } = req.body; + const { permissions, expiresIn, label } = req.body; if (!Array.isArray(permissions) || permissions.length === 0) { throw new HttpError(400, 'Missing or empty `permissions` array', { legacyCode: 'bad_request', }); } + // Optional user-facing name for the manage-sessions UI. Trim and clamp + // to the same 64-char limit the rename endpoint enforces. + let normalizedLabel: string | null = null; + if (label !== undefined && label !== null) { + if (typeof label !== 'string') { + throw new HttpError(400, '`label` must be a string', { + legacyCode: 'bad_request', + }); + } + normalizedLabel = label.trim().slice(0, 64) || null; + } + // Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is const normalized = permissions.map((spec) => { if (typeof spec === 'string') return [spec]; @@ -2233,7 +2245,10 @@ export class AuthController extends PuterController { const token = await this.services.auth.createAccessToken( req.actor!, normalized as never, - expiresIn ? { expiresIn } : {}, + { + ...(expiresIn ? { expiresIn } : {}), + ...(normalizedLabel ? { label: normalizedLabel } : {}), + }, ); res.json({ token }); } diff --git a/src/backend/controllers/desktop/DesktopController.js b/src/backend/controllers/desktop/DesktopController.js index add9ad55b..47c695661 100644 --- a/src/backend/controllers/desktop/DesktopController.js +++ b/src/backend/controllers/desktop/DesktopController.js @@ -51,6 +51,7 @@ export class DesktopController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const { url, color, fit } = req.body ?? {}; @@ -105,6 +106,7 @@ export class DesktopController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const { items } = req.body ?? {}; @@ -130,6 +132,7 @@ export class DesktopController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const { item_uid, item_path, layout } = req.body ?? {}; @@ -156,6 +159,7 @@ export class DesktopController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }, async (req, res) => { const { item_uid, item_path, sort_by, sort_order } = diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index fd96014dd..5e4b50091 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -146,6 +146,11 @@ export class LegacyFSController extends PuterController { { subdomain: ['api', ''], requireUserActor: true, + // The user's own credential may download their files; apps and + // scoped tokens stay blocked. antiCsrf still protects the + // cookie-authed GUI path — a bearer-token PAT is exempt below + // (CSRF can't forge a header-credentialed request). + allowFullAccessToken: true, requireVerified: true, antiCsrf: true, }, diff --git a/src/backend/controllers/hosting/HostingController.js b/src/backend/controllers/hosting/HostingController.js index f9d66698b..75004508c 100644 --- a/src/backend/controllers/hosting/HostingController.js +++ b/src/backend/controllers/hosting/HostingController.js @@ -44,6 +44,7 @@ export class HostingController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, requireVerified: true, }, async (req, res) => { diff --git a/src/backend/controllers/notification/NotificationController.ts b/src/backend/controllers/notification/NotificationController.ts index 040d12303..e7e42c2ee 100644 --- a/src/backend/controllers/notification/NotificationController.ts +++ b/src/backend/controllers/notification/NotificationController.ts @@ -38,7 +38,11 @@ export class NotificationController extends PuterController { * POST /notif/mark-ack — user dismissed a notification. * Sets `acknowledged` timestamp; pushes ack event to sockets. */ - @Post('/mark-ack', { subdomain: 'api', requireUserActor: true }) + @Post('/mark-ack', { + subdomain: 'api', + requireUserActor: true, + allowFullAccessToken: true, + }) async markAck(req: Request, res: Response): Promise { const uid = req.body?.uid; if (typeof uid !== 'string' || uid.length === 0) { @@ -77,7 +81,11 @@ export class NotificationController extends PuterController { * POST /notif/mark-read — user saw a notification. * Sets `shown` timestamp; pushes ack event to sockets. */ - @Post('/mark-read', { subdomain: 'api', requireUserActor: true }) + @Post('/mark-read', { + subdomain: 'api', + requireUserActor: true, + allowFullAccessToken: true, + }) async markRead(req: Request, res: Response): Promise { const uid = req.body?.uid; if (typeof uid !== 'string' || uid.length === 0) { diff --git a/src/backend/controllers/puterai/PuterAIController.test.ts b/src/backend/controllers/puterai/PuterAIController.test.ts index a37d0be06..fbabdd028 100644 --- a/src/backend/controllers/puterai/PuterAIController.test.ts +++ b/src/backend/controllers/puterai/PuterAIController.test.ts @@ -188,10 +188,11 @@ describe('PuterAIController.registerRoutes', () => { ]), ); - // The four upstream-proxy routes are user-only: app actors must - // call puter-chat-completion directly. The `requireUserActor` - // route option wires up the gate that enforces this, so each - // proxy route gets the assertion (not just chat/completions). + // The four upstream-proxy routes reject third-party apps (they must + // call puter-chat-completion directly), but admit the user's own + // full-access personal access token: `requireUserActor` keeps apps out + // and `allowFullAccessToken` opens the gate to a full-access PAT. Each + // proxy route carries both flags. const userOnlyPaths = [ '/puterai/openai/v1/chat/completions', '/puterai/openai/v1/completions', @@ -203,6 +204,7 @@ describe('PuterAIController.registerRoutes', () => { expect(route?.opts).toEqual({ subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, }); } const modelsRoute = calls.find( diff --git a/src/backend/controllers/puterai/PuterAIController.ts b/src/backend/controllers/puterai/PuterAIController.ts index 2619cba5d..b33adf4b5 100644 --- a/src/backend/controllers/puterai/PuterAIController.ts +++ b/src/backend/controllers/puterai/PuterAIController.ts @@ -52,6 +52,12 @@ export class PuterAIController extends PuterController { const apiAuthOpts = { subdomain: 'api', requireUserActor: true, + // These are the user's own programmatic AI surface (OpenAI/Anthropic + // wire). `requireUserActor` here exists to keep third-party apps + // out, not to block the user's own credential — so admit full-access + // personal access tokens (CLI/MCP/scripts). Apps and scoped tokens + // stay blocked. + allowFullAccessToken: true, } as RouteOptions; const publicOpts = { subdomain: 'api', requireAuth: false } as const; diff --git a/src/backend/controllers/system/SystemController.js b/src/backend/controllers/system/SystemController.js index d086c12ef..f56723d59 100644 --- a/src/backend/controllers/system/SystemController.js +++ b/src/backend/controllers/system/SystemController.js @@ -77,6 +77,7 @@ export class SystemController extends PuterController { { subdomain: 'api', requireUserActor: true, + allowFullAccessToken: true, rateLimit: { scope: 'contact-us', limit: 10, diff --git a/src/backend/core/actor.ts b/src/backend/core/actor.ts index 751a0e1e7..d37c38d65 100644 --- a/src/backend/core/actor.ts +++ b/src/backend/core/actor.ts @@ -33,6 +33,15 @@ export interface ActorAccessToken { uid: string; issuer: Actor; authorized?: Actor | null; + /** + * Full-API-access ("personal access token") flag, set from the signed + * `full_access` JWT claim. Such a token may exercise everything its issuing + * user can do via the API and is admitted past `requireNonAccessTokenGate` + * — but is still rejected by `requireUserActor` / web-session gates, so it + * can never manage the account. Normal (scoped) access tokens leave this + * false and remain blocked from non-`allowAccessToken` routes. + */ + fullAccess?: boolean; } export interface Actor { diff --git a/src/backend/core/http/middleware/antiCsrf.js b/src/backend/core/http/middleware/antiCsrf.js index 9d91f8ccc..3b2926eca 100644 --- a/src/backend/core/http/middleware/antiCsrf.js +++ b/src/backend/core/http/middleware/antiCsrf.js @@ -73,6 +73,19 @@ export const antiCsrf = { export function requireAntiCsrf() { return async (req, _res, next) => { try { + // CSRF only applies to ambient credentials the browser attaches + // automatically (the session cookie). A full-access personal access + // token travels in the Authorization header — it can't be forged + // cross-origin — so it needs no anti-CSRF token, which is what makes + // routes like `/fs/down` reachable by a PAT. We deliberately scope + // this to full-access tokens (the only access tokens routed onto + // antiCsrf-protected endpoints): anything else — cookie-authed user + // actors, app actors, or a scoped token that somehow reaches here — + // still goes through the check below and fails closed. + if (req.actor?.accessToken?.fullAccess) { + return next(); + } + const sessionId = req.actor?.user?.uuid; if (!sessionId) { return next( diff --git a/src/backend/core/http/middleware/antiCsrf.test.js b/src/backend/core/http/middleware/antiCsrf.test.js index 65161f4ec..f8fc5254f 100644 --- a/src/backend/core/http/middleware/antiCsrf.test.js +++ b/src/backend/core/http/middleware/antiCsrf.test.js @@ -118,6 +118,51 @@ describe('requireAntiCsrf middleware', () => { expect(arg).toBeUndefined(); }); + it('exempts a FULL-ACCESS access token (no anti_csrf token needed)', async () => { + // A full-access PAT is header-credentialed (bearer), so it can't be + // CSRF-forged — the middleware lets it through without a token. This is + // what makes antiCsrf routes like /fs/down reachable by a PAT. + const arg = await runMiddleware({ + actor: { + user: { uuid: 'user-uuid-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'user-uuid-1' } }, + fullAccess: true, + }, + }, + body: {}, + }); + expect(arg).toBeUndefined(); + }); + + it('does NOT exempt a scoped (non-full-access) access token — fails closed', async () => { + // Scoped tokens are never deliberately routed onto antiCsrf endpoints; + // if one reaches here it must still present a token (which it can't get) + // rather than getting a free pass. + const arg = await runMiddleware({ + actor: { + user: { uuid: 'user-uuid-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'user-uuid-1' } }, + }, + }, + body: {}, + }); + expect(isHttpError(arg)).toBe(true); + expect(arg.statusCode).toBe(400); + }); + + it('still enforces the token for cookie-authed user actors (no accessToken)', async () => { + const arg = await runMiddleware({ + actor: { user: { uuid: 'user-uuid-1' } }, + body: {}, + }); + expect(isHttpError(arg)).toBe(true); + expect(arg.statusCode).toBe(400); + }); + it('returns 401 unauthorized when no actor is attached', async () => { const arg = await runMiddleware({ body: { anti_csrf: 'whatever' } }); expect(isHttpError(arg)).toBe(true); diff --git a/src/backend/core/http/middleware/gates.test.ts b/src/backend/core/http/middleware/gates.test.ts index 90eb0501a..a32839c37 100644 --- a/src/backend/core/http/middleware/gates.test.ts +++ b/src/backend/core/http/middleware/gates.test.ts @@ -26,6 +26,7 @@ import { allowedAppIdsGate, requireAuthGate, requireEmailConfirmedGate, + requireNonAccessTokenGate, requireUserActorGate, requireVerifiedGate, subdomainGate, @@ -224,6 +225,110 @@ describe('requireUserActorGate', () => { const got = runGate(requireUserActorGate(), {}); expectHttpError(got, 401, 'token_missing'); }); + + it('rejects a FULL-ACCESS access token too — account routes stay closed', () => { + // The account wall is actor-type based: even a full-access PAT (which + // the resource wall lets through) is rejected here, so it can never + // reach change-password/email/2FA/token-minting/etc. + const got = runGate(requireUserActorGate(), { + actor: { + user: { uuid: 'u-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'u-1' } }, + fullAccess: true, + }, + }, + }); + expectHttpError(got, 403, 'forbidden'); + }); + + // allowFullAccess opt-in: relaxes ONLY the access-token half, for + // user-resource/inference routes (e.g. the AI proxy). + it('admits a full-access PAT when allowFullAccess is set', () => { + const got = runGate(requireUserActorGate({ allowFullAccess: true }), { + actor: { + user: { uuid: 'u-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'u-1' } }, + fullAccess: true, + }, + }, + }); + expect(got).toBeUndefined(); + }); + + it('still rejects a SCOPED access token even when allowFullAccess is set', () => { + const got = runGate(requireUserActorGate({ allowFullAccess: true }), { + actor: { + user: { uuid: 'u-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'u-1' } }, + // no fullAccess + }, + }, + }); + expectHttpError(got, 403, 'forbidden'); + }); + + it('still rejects a third-party app even when allowFullAccess is set', () => { + const got = runGate(requireUserActorGate({ allowFullAccess: true }), { + actor: { user: { uuid: 'u-1' }, app: { uid: 'app-1' } }, + }); + expectHttpError(got, 403, 'forbidden'); + }); +}); + +// -- requireNonAccessTokenGate -- + +describe('requireNonAccessTokenGate', () => { + it('passes through for plain user actors', () => { + const got = runGate(requireNonAccessTokenGate(), { + actor: { user: { uuid: 'u-1' } }, + }); + expect(got).toBeUndefined(); + }); + + it('passes through for app-under-user actors (only access tokens are gated)', () => { + const got = runGate(requireNonAccessTokenGate(), { + actor: { user: { uuid: 'u-1' }, app: { uid: 'app-1' } }, + }); + expect(got).toBeUndefined(); + }); + + it('rejects a normal (scoped) access token with 403', () => { + const got = runGate(requireNonAccessTokenGate(), { + actor: { + user: { uuid: 'u-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'u-1' } }, + }, + }, + }); + expectHttpError(got, 403, 'forbidden'); + }); + + it('admits a FULL-ACCESS access token (the resource-wall carve-out)', () => { + const got = runGate(requireNonAccessTokenGate(), { + actor: { + user: { uuid: 'u-1' }, + accessToken: { + uid: 'tok-1', + issuer: { user: { uuid: 'u-1' } }, + fullAccess: true, + }, + }, + }); + expect(got).toBeUndefined(); + }); + + it("falls back to 401 if there's no actor at all (defensive)", () => { + const got = runGate(requireNonAccessTokenGate(), {}); + expectHttpError(got, 401, 'token_missing'); + }); }); // ── adminOnlyGate ─────────────────────────────────────────────────── diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index f188266dc..adb1312d2 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -99,8 +99,18 @@ export const requireAuthGate = (): RequestHandler => { * Reject app-under-user and access-token actors with 403. Use on endpoints * that should only be exercised by a human session — settings changes, * admin-style actions on the user's own account. + * + * `allowFullAccess` (set per-route via the `allowFullAccessToken` route option) + * relaxes ONLY the access-token half: a full-access ("personal access token") + * actor is admitted, because it represents the user's own full API reach. + * Third-party apps are ALWAYS rejected, and scoped access tokens are always + * rejected. This opt-in is for user-resource / inference endpoints (AI proxy, + * etc.) that use this gate purely to keep apps out — NEVER for account or + * security management, which must stay closed to every access token. */ -export const requireUserActorGate = (): RequestHandler => { +export const requireUserActorGate = ( + opts: { allowFullAccess?: boolean } = {}, +): RequestHandler => { return (req, _res, next) => { const actor = req.actor; // requireAuth runs first; this gate just narrows the actor type. @@ -108,7 +118,14 @@ export const requireUserActorGate = (): RequestHandler => { next(rejectAuth(req)); return; } - if (actor.app || actor.accessToken) { + // Third-party apps are never allowed through this gate. + const appBlocked = !!actor.app; + // Access tokens are blocked unless the route opted in AND this is a + // full-access PAT (the user's own credential). Scoped tokens: blocked. + const tokenBlocked = + !!actor.accessToken && + !(opts.allowFullAccess && actor.accessToken.fullAccess); + if (appBlocked || tokenBlocked) { next( new HttpError( 403, @@ -129,7 +146,13 @@ export const requireNonAccessTokenGate = (): RequestHandler => { next(rejectAuth(req)); return; } - if (actor.accessToken) { + // Full-access ("personal access token") access tokens are admitted here: + // they carry the user's full API reach by design. They remain blocked + // from account management because those routes also use + // `requireUserActorGate`, which rejects ALL access tokens. Normal + // (scoped) access tokens stay blocked from non-`allowAccessToken` + // routes. + if (actor.accessToken && !actor.accessToken.fullAccess) { next( new HttpError( 403, diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 5248252ce..8c4287a9b 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -86,6 +86,17 @@ export interface RouteOptions { /** Reject app/access-token actors. Implies `requireAuth`. */ requireUserActor?: boolean; + /** + * Only meaningful alongside `requireUserActor`. Relaxes the access-token + * half of that gate so a FULL-ACCESS personal access token (the user's own + * credential) is admitted — third-party apps and scoped tokens stay + * blocked. Use ONLY on user-resource / inference endpoints that gate with + * `requireUserActor` purely to keep apps out (e.g. the AI proxy). NEVER set + * on account/security/credential routes — those must stay closed to every + * access token. Default-deny: omitting this keeps PATs blocked. + */ + allowFullAccessToken?: boolean; + /** Allows access-tokens */ allowAccessToken?: boolean; diff --git a/src/backend/server.ts b/src/backend/server.ts index b963999b3..814cca107 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -847,7 +847,11 @@ export class PuterServer { // `actor.user.username`, which is populated for access-token and // app-under-user actors alike. if (opts.requireUserActor) { - mwChain.push(requireUserActorGate()); + mwChain.push( + requireUserActorGate({ + allowFullAccess: opts.allowFullAccessToken, + }), + ); } if (opts.adminOnly) { diff --git a/src/backend/services/appIcon/AppIconService.test.ts b/src/backend/services/appIcon/AppIconService.test.ts new file mode 100644 index 000000000..a25e72bc8 --- /dev/null +++ b/src/backend/services/appIcon/AppIconService.test.ts @@ -0,0 +1,92 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { PuterServer } from '../../server'; +import type { IConfig } from '../../types'; +import { + POSTGRES_TEST_MIGRATIONS_PATH, + setupTestServer, +} from '../../testUtil.js'; + +const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; + +describe('AppIconService.ensureIconsDirectory', () => { + let server: PuterServer; + + beforeAll(async () => { + // Boot on (pgmock) Postgres specifically: the `subdomains.subdomain` + // UNIQUE constraint this fix relies on exists on Postgres but not on + // the sqlite test schema, so only Postgres faithfully reproduces the + // duplicate-insert the race triggers. + // + // `no_default_user: false` provisions the admin user and the app-icons + // subdomain at boot — the realistic setup for the first-boot race. + server = await setupTestServer({ + no_default_user: false, + database: { + engine: 'postgres', + inMemory: true, + migrationPaths: [POSTGRES_TEST_MIGRATIONS_PATH], + }, + } as unknown as IConfig); + }, 180_000); // pgmock boot + migrations is slow + + afterAll(async () => { + await server?.shutdown(); + }, 60_000); + + // Regression: `ensureIconsDirectory` runs twice on first boot (once + // un-awaited from its own onServerStart, then from DefaultUserService), + // and the existence check reads a cache that can hold a stale negative + // entry. The losing create then violated the unique constraint and — on + // the un-awaited path — surfaced as an unhandled rejection. The "ensure" + // must be idempotent even when the guard wrongly reports "absent". + it('is idempotent when the existence cache reports the subdomain absent but the row exists', async () => { + const subdomains = server.stores.subdomain; + expect(await subdomains.existsBySubdomain(APP_ICONS_SUBDOMAIN)).toBe( + true, + ); + + // Reproduce the stale-negative-cache: write the negative marker for a + // subdomain that genuinely exists, so the guard in + // `ensureIconsDirectory` passes and it attempts a duplicate insert. + await server.clients.redis.set( + `subdomains:name:${APP_ICONS_SUBDOMAIN}`, + '__none__', + ); + // Self-check: confirm the poison took effect (guards against the + // cache key/marker drifting out from under this test). + expect(await subdomains.existsBySubdomain(APP_ICONS_SUBDOMAIN)).toBe( + false, + ); + + // Before the fix this rejected with a unique-constraint violation. + await expect( + server.services.appIcon.ensureIconsDirectory(), + ).resolves.toBeUndefined(); + + // And no duplicate row was created. + const rows = await server.clients.db.read( + 'SELECT COUNT(*) AS n FROM `subdomains` WHERE `subdomain` = ?', + [APP_ICONS_SUBDOMAIN], + ); + expect(Number(rows[0]?.n)).toBe(1); + }, 60_000); +}); diff --git a/src/backend/services/appIcon/AppIconService.ts b/src/backend/services/appIcon/AppIconService.ts index 526ad0a0d..6b289d412 100644 --- a/src/backend/services/appIcon/AppIconService.ts +++ b/src/backend/services/appIcon/AppIconService.ts @@ -19,6 +19,7 @@ import { Readable } from 'node:stream'; import type { LayerInstances } from '../../types'; +import { isUniqueViolation } from '../../util/dbError.js'; import type { puterServices } from '../index'; import { PuterService } from '../types.js'; @@ -198,15 +199,25 @@ export class AppIconService extends PuterService { } // Register the `puter-app-icons` subdomain pointing at that dir. - // Idempotent: skip if it already exists. + // Idempotent, and must stay so under a concurrent boot: this method + // runs twice on first boot — once un-awaited from our own + // `onServerStart`, then again (awaited) from `DefaultUserService` + // right after it creates the admin — and `existsBySubdomain` reads a + // cache that can still hold a stale negative entry. So the existence + // check can pass in both calls; let the unique constraint be the real + // arbiter and swallow the loser's duplicate. The end state (subdomain + // exists) is identical either way. const already = await this.stores.subdomain.existsBySubdomain(APP_ICONS_SUBDOMAIN); - if (!already) { + if (already) return; + try { await this.stores.subdomain.create({ userId: adminUser.id, subdomain: APP_ICONS_SUBDOMAIN, rootDirId: dirEntry.id ?? null, }); + } catch (e) { + if (!isUniqueViolation(e)) throw e; } } diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 4e100a27a..323fc3b79 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -24,6 +24,7 @@ import type { Actor } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; +import { FULL_API_ACCESS } from '../permission/consts.js'; import { AuthService } from './AuthService.js'; function createAuthService(): AuthService { @@ -76,6 +77,20 @@ describe('AuthService.createAccessToken', () => { ), ).rejects.toMatchObject({ statusCode: 403 }); }); + + it('rejects a full-access mint by an app-under-user actor', async () => { + // Apps may hold scoped grants but must not be able to escalate to a + // blanket account-wide token. This throws on actor shape, before any + // DB / permission interaction, so the mock service is sufficient. + const authService = createAuthService(); + const appActor = { + user: { uuid: 'user-issuer', id: 1, username: 'issuer' }, + app: { id: 0, uid: 'app-x' }, + } as Actor; + await expect( + authService.createAccessToken(appActor, [[FULL_API_ACCESS]]), + ).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' }); + }); }); // ── Real-server integration tests ─────────────────────────────────── @@ -1651,6 +1666,105 @@ describe('AuthService (integration)', () => { legacyCode: 'forbidden', }); }); + + // -- Full-API-access tokens -- + + it('mints a full-access token for a user actor and stores the label', async () => { + const user = await makeUser(); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const jwt = await authService.createAccessToken( + actor, + [[FULL_API_ACCESS]], + { label: 'My CLI' }, + ); + const decoded = server.services.token.verify('auth', jwt) as { + type: string; + token_uid: string; + session_uid: string; + full_access?: boolean; + }; + expect(decoded.type).toBe('access-token'); + + // Full access is carried as a signed claim — NOT a stored grant. + expect(decoded.full_access).toBe(true); + const permRows = (await server.clients.db.read( + 'SELECT `permission` FROM `access_token_permissions` WHERE `token_uid` = ?', + [decoded.token_uid], + )) as Array<{ permission: string }>; + expect(permRows).toHaveLength(0); + + // The label lands on the access-token session row so it shows + // (and is revocable) in the manage-sessions UI. + const sessRows = (await server.clients.db.read( + 'SELECT `label`, `kind` FROM `sessions` WHERE `uuid` = ?', + [decoded.session_uid], + )) as Array<{ label: string; kind: string }>; + expect(sessRows[0]?.kind).toBe('access_token'); + expect(sessRows[0]?.label).toBe('My CLI'); + }); + + it('full-access token resolves any permission the issuing user holds, but a scoped token does not', async () => { + const user = await makeUser(); + await generateDefaultFsentries( + server.clients.db, + server.stores.user, + user, + ); + const body = Buffer.from('secret'); + await server.services.fs.write(user.id, { + fileMetadata: { + path: `/${user.username}/Documents/secret.txt`, + size: body.byteLength, + contentType: 'text/plain', + }, + fileContent: body, + } as never); + const fileEntry = await server.stores.fsEntry.getEntryByPath( + `/${user.username}/Documents/secret.txt`, + ); + expect(fileEntry).not.toBeNull(); + + const userActor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + + // Full-access token: the owner fs:read resolves *through the + // issuer*, even though the token holds no fs grant of its own. + const fullJwt = await authService.createAccessToken(userActor, [ + [FULL_API_ACCESS], + ]); + const fullActor = + await authService.authenticateFromToken(fullJwt); + expect(fullActor).toBeTruthy(); + // The signed claim is surfaced on the actor — this flag is what the + // resource gate and the permission scan both key off. + expect(fullActor!.accessToken?.fullAccess).toBe(true); + expect( + await server.services.permission.check( + fullActor!, + `fs:${fileEntry!.uuid}:read`, + ), + ).toBe(true); + + // A scoped token (granted an unrelated permission) gets NO owner + // fs access — access-token actors are excluded from the owner + // implicator, so this stays the pre-existing behaviour. + const scopedJwt = await authService.createAccessToken(userActor, [ + [`user:${user.uuid}:email:read`], + ]); + const scopedActor = + await authService.authenticateFromToken(scopedJwt); + expect(scopedActor).toBeTruthy(); + expect(scopedActor!.accessToken?.fullAccess).toBeFalsy(); + expect( + await server.services.permission.check( + scopedActor!, + `fs:${fileEntry!.uuid}:read`, + ), + ).toBe(false); + }); }); describe('private-asset / public hosted-actor tokens', () => { diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 6e17d895b..07c99f09b 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -30,6 +30,7 @@ import type { LayerInstances } from '../../types'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; import type { puterServices } from '../index'; import { PuterService } from '../types'; +import { FULL_API_ACCESS } from '../permission/consts'; import { V1TokensDisabledError } from './TokenService'; import type { AccessTokenPayload, @@ -1441,7 +1442,7 @@ export class AuthService extends PuterService { // '30d'). `#hardExpiryFromExpiresIn` supports both, and existing // callers / tests pass the string form, so narrowing to `number` // here would force unsafe casts at every call site. - options: { expiresIn?: string | number } = {}, + options: { expiresIn?: string | number; label?: string | null } = {}, ): Promise { if (!actor.user) throw new HttpError(403, 'Actor must be a user', { @@ -1457,6 +1458,20 @@ export class AuthService extends PuterService { ); } + // Full-API-access sentinel: a token that may do anything its issuing + // user can do via the API (resolved against the issuer at check time — + // see PermissionService.#scanAccessToken). Only a plain user actor may + // mint one; an app-under-user actor must not be able to escalate the + // scoped access it was granted into blanket account-wide access. + const wantsFullAccess = permissions.some( + ([p]) => p === FULL_API_ACCESS, + ); + if (wantsFullAccess && actor.app) { + throw new HttpError(403, 'Apps may not mint full-access tokens', { + legacyCode: 'forbidden', + }); + } + // Permission-subset enforcement: an access token can only carry // permissions the issuer itself holds. Without this, an // app-under-user actor (third-party app authorized by the user) @@ -1465,12 +1480,19 @@ export class AuthService extends PuterService { // returned verbatim at check-time, with no re-validation against // the authorizer. `checkMany` is one pipelined MGET against the // per-actor permission cache so the cost is small even for - // many-permission mints. + // many-permission mints. The full-access sentinel is excluded — it + // isn't a real permission the issuer "holds"; its gate is the + // user-actor check above. const requestedPerms = [ ...new Set( permissions .map(([p]) => p) - .filter((p): p is string => typeof p === 'string' && !!p), + .filter( + (p): p is string => + typeof p === 'string' && + !!p && + p !== FULL_API_ACCESS, + ), ), ]; if (requestedPerms.length > 0) { @@ -1509,6 +1531,9 @@ export class AuthService extends PuterService { actor.user.id as number, { kind: 'access_token', + // User-facing name shown (and editable) in the manage-sessions + // UI. Trimmed/clamped by the caller; null when unnamed. + label: options.label ?? null, parent_session_id, expires_at: expiresAt, auth_id, @@ -1530,6 +1555,14 @@ export class AuthService extends PuterService { if (actor.app) { jwtPayload.app_uid = actor.app.uid; } + // Full-access is carried as a signed claim (not a stored permission + // row): it's the single source of truth read at auth time into + // `actor.accessToken.fullAccess`, which both `requireNonAccessTokenGate` + // and the permission scan consult. The `actor.app` block above already + // rejected app-issued full-access mints. + if (wantsFullAccess) { + jwtPayload.full_access = true; + } // jsonwebtoken's SignOptions.expiresIn is typed as `number | // ${number}${unit}` (template literal), so a plain string can't @@ -1539,7 +1572,11 @@ export class AuthService extends PuterService { const jwt = this.services.token.sign( 'auth', jwtPayload, - options as { expiresIn?: number }, + // Only `expiresIn` is a valid jsonwebtoken sign option; `label` is + // ours (stored on the session row above), so don't forward it. + options.expiresIn !== undefined + ? { expiresIn: options.expiresIn as number } + : {}, ); // Store each permission grant @@ -1550,6 +1587,9 @@ export class AuthService extends PuterService { }; for (const spec of permissions) { const [permission, extra] = spec; + // The full-access sentinel is not a real grant — it lives in the + // signed `full_access` claim, not `access_token_permissions`. + if (permission === FULL_API_ACCESS) continue; await (db.clients?.db ?? this.clients.db).write( 'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `authorizer_app_id`, `permission`, `extra`) VALUES (?, ?, ?, ?, ?)', [ @@ -1836,6 +1876,12 @@ export class AuthService extends PuterService { uid: decoded.token_uid, issuer: authorizer, authorized: null, + // Honor the signed full-access claim only for user-issued + // tokens. App-issued tokens (`app_uid` present) can never be + // full-access — mirrors the mint-time block — so even a + // claim on one is ignored here. + fullAccess: + !decoded.app_uid && decoded.full_access === true, }, }, }; diff --git a/src/backend/services/auth/types.ts b/src/backend/services/auth/types.ts index e0afed6b6..b5bf2a955 100644 --- a/src/backend/services/auth/types.ts +++ b/src/backend/services/auth/types.ts @@ -87,6 +87,12 @@ export interface AccessTokenPayload extends TokenPayloadBase { token_uid: string; user_uid: string; app_uid?: string; + /** + * Full-API-access ("personal access token") marker. Only ever set on + * user-issued tokens (never app-issued). Drives `actor.accessToken + * .fullAccess` — see ActorAccessToken in core/actor.ts. + */ + full_access?: boolean; } export type AnyTokenPayload = diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index b69dbba84..b5e016788 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -431,6 +431,29 @@ export class PermissionService extends PuterService { ): Promise { if (!actor.accessToken) return; const issuerActor = actor.accessToken.issuer; + + // Full-API-access token: it may do anything its issuing user can do. + // Resolve every requested option directly against the issuer (the + // owner FS implicator, group/service grants, and user-to-user grants + // all apply to the user actor), with no per-permission grant row + // required. The marker is the signed `full_access` claim surfaced on + // the actor (single source of truth, set only for user-issued tokens). + // Account-management endpoints stay closed because they gate on actor + // type (requireUserActor / session cookie), not permissions. + if (actor.accessToken.fullAccess) { + for (const permission of options) { + const issuerReading = await this.scan(issuerActor, permission); + reading.push({ + $: 'path', + via: 'access-token', + has_terminal: readingHasTerminal(issuerReading), + permission, + reading: issuerReading, + }); + } + return; + } + for (const permission of options) { const hasTokenPerm = await this.stores.permission.hasAccessTokenPerm( diff --git a/src/backend/services/permission/consts.ts b/src/backend/services/permission/consts.ts index 76e7263ef..8b945d5dc 100644 --- a/src/backend/services/permission/consts.ts +++ b/src/backend/services/permission/consts.ts @@ -29,3 +29,19 @@ export const PERMISSION_FOR_NOTHING_IN_PARTICULAR = /** TTL (seconds) for redis-cached permission scan readings. */ export const PERMISSION_SCAN_CACHE_TTL_SECONDS = 20; + +/** + * Sentinel grant for a "full API access" access token: the token may do + * anything its issuing user can do via the API (filesystem, drivers, KV, AI, + * apps, workers — resolved against the issuer at check time in + * `PermissionService.#scanAccessToken`). It does NOT unlock account + * management: access-token actors are still rejected by the + * `requireUserActor` / session-cookie gates that protect change-password, + * change-email, change-username, 2FA, sync-cookie, token minting, etc. + * + * Only a plain user actor may mint a token carrying this grant — never an + * app-under-user actor (which must not be able to escalate to full access). + * + * Chosen over `'*'`, which already appears as an audit-log "revoke all" label. + */ +export const FULL_API_ACCESS = 'full-api-access'; diff --git a/src/backend/util/dbError.test.ts b/src/backend/util/dbError.test.ts new file mode 100644 index 000000000..061600bc3 --- /dev/null +++ b/src/backend/util/dbError.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it } from 'vitest'; +import { isUniqueViolation } from './dbError.js'; + +describe('isUniqueViolation', () => { + it('matches UNIQUE / PRIMARY KEY violations across the supported drivers', () => { + expect(isUniqueViolation({ code: '23505' })).toBe(true); // postgres + expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT_UNIQUE' })).toBe( + true, + ); + expect( + isUniqueViolation({ code: 'SQLITE_CONSTRAINT_PRIMARYKEY' }), + ).toBe(true); + expect(isUniqueViolation({ code: 'ER_DUP_ENTRY' })).toBe(true); // mysql + expect(isUniqueViolation({ errno: 1062 })).toBe(true); // mysql errno + }); + + it('does NOT match other constraint / error kinds, which must bubble up', () => { + // The bare parent code can be a CHECK / NOT NULL / FK violation. + expect(isUniqueViolation({ code: 'SQLITE_CONSTRAINT' })).toBe(false); + expect( + isUniqueViolation({ code: 'SQLITE_CONSTRAINT_FOREIGNKEY' }), + ).toBe(false); + expect(isUniqueViolation({ code: '23502' })).toBe(false); // pg NOT NULL + expect(isUniqueViolation({ code: '23503' })).toBe(false); // pg FK + expect(isUniqueViolation({ errno: 1452 })).toBe(false); // mysql FK + }); + + it('is safe on non-error inputs', () => { + expect(isUniqueViolation(null)).toBe(false); + expect(isUniqueViolation(undefined)).toBe(false); + expect(isUniqueViolation('boom')).toBe(false); + expect(isUniqueViolation({})).toBe(false); + }); +}); diff --git a/src/backend/util/dbError.ts b/src/backend/util/dbError.ts new file mode 100644 index 000000000..7b974fd15 --- /dev/null +++ b/src/backend/util/dbError.ts @@ -0,0 +1,44 @@ +/** + * 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 . + */ + +/** + * True when `err` is a UNIQUE / PRIMARY KEY constraint violation, across the + * three database drivers Puter runs on. Use to make an "insert if absent" + * idempotent in the face of a lost check-then-insert race — only a duplicate + * is swallowed; CHECK / NOT NULL / FK / type violations still bubble up. + * + * better-sqlite3 surfaces `SqliteError.code`; mysql2 surfaces `.code` and + * `.errno` (1062 = ER_DUP_ENTRY); pg surfaces SQLSTATE `23505`. + */ +export function isUniqueViolation(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const { code, errno } = err as { code?: string; errno?: number }; + // Match only UNIQUE / PRIMARY KEY violations. The bare `SQLITE_CONSTRAINT` + // parent code is intentionally excluded so CHECK / NOT NULL / FK / type + // violations still bubble up rather than being silently swallowed. + if ( + code === 'SQLITE_CONSTRAINT_UNIQUE' || + code === 'SQLITE_CONSTRAINT_PRIMARYKEY' || + code === 'ER_DUP_ENTRY' || + code === '23505' + ) { + return true; + } + return errno === 1062; +} diff --git a/src/gui/src/UI/Dashboard/TabAccount.js b/src/gui/src/UI/Dashboard/TabAccount.js index 9cd37b5cd..dc3b3cfba 100644 --- a/src/gui/src/UI/Dashboard/TabAccount.js +++ b/src/gui/src/UI/Dashboard/TabAccount.js @@ -101,18 +101,18 @@ const TabAccount = { h += ''; } - // Auth token card + // API token card h += '
'; h += '
'; h += '
'; h += ''; h += '
'; h += '
'; - h += `${i18n('auth_token')}`; - h += `${i18n('copy_token_description')}`; + h += `${i18n('api_token')}`; + h += `${i18n('api_token_description')}`; h += '
'; h += '
'; - h += ``; + h += ``; h += '
'; // Danger zone diff --git a/src/gui/src/UI/UIWindowAuthMe.js b/src/gui/src/UI/UIWindowAuthMe.js index 6cf5c2d42..8c35fe1be 100644 --- a/src/gui/src/UI/UIWindowAuthMe.js +++ b/src/gui/src/UI/UIWindowAuthMe.js @@ -116,8 +116,9 @@ async function UIWindowAuthMe (options = {}) { h += ` `; - h += `${i18n('your_auth_token')}`; + h += `${i18n('shared_api_token')}`; h += ''; + h += `

${i18n('shared_api_token_note')}

`; h += ''; // Buttons diff --git a/src/gui/src/UI/UIWindowCopyToken.js b/src/gui/src/UI/UIWindowCopyToken.js index 0be4a377c..f0e80770e 100644 --- a/src/gui/src/UI/UIWindowCopyToken.js +++ b/src/gui/src/UI/UIWindowCopyToken.js @@ -18,7 +18,14 @@ */ import UIWindow from './UIWindow.js'; +import UIWindowManageSessions from './UIWindowManageSessions.js'; +import create_access_token from '../helpers/create_access_token.js'; +// Creates a named, revocable full-API-access token and shows it once. +// Replaces the old "copy your raw GUI/session token" behaviour: the copied +// token used to be a session-equivalent credential that could escalate to +// full account control; the minted access token can use the whole API but is +// locked out of account management (see create_access_token.js). function UIWindowCopyToken (options = {}) { return new Promise(async (resolve) => { let h = ''; @@ -46,29 +53,49 @@ function UIWindowCopyToken (options = {}) { `; h += ''; - h += `

${i18n('auth_token')}

`; - h += `

${i18n('copy_token_message')}

`; + h += `

${i18n('create_api_token')}

`; + h += `

${i18n('create_token_message')}

`; h += ''; } h += '
'; - if ( ! options.show_header ) { - h += `
${i18n('copy_token_message')}
`; - } if ( options.show_close_button ) { h += '
×
'; } - h += `
`; - h += ``; + + // -- Phase A: create form -- + h += '
'; + if ( ! options.show_header ) { + h += `
${i18n('create_token_message')}
`; + } + h += ''; + h += ``; + h += ``; + h += ``; + h += `'; + h += ``; + h += '
'; + + // -- Phase B: result (shown once, after mint) -- + h += ''; const el_window = await UIWindow({ - title: i18n('auth_token'), + title: i18n('create_api_token'), app: 'copy-token', single_instance: true, icon: null, @@ -99,19 +126,71 @@ function UIWindowCopyToken (options = {}) { ...options.window_options, }); - $(el_window).find('.copy-token-btn').on('click', function () { + const $win = $(el_window); + + const showError = (msg) => { + $win.find('.form-error-msg').text(msg).show(); + }; + + const doCreate = async (label) => { + const $btn = $win.find('.create-token-btn'); + const expiresIn = $win.find('.token-expiry-select').val() || null; + $win.find('.form-error-msg').hide(); + $btn.addClass('disabled').prop('disabled', true); + try { + const token = await create_access_token({ label, expiresIn }); + $win.find('.create-token-form').hide(); + $win.find('.token-input').val(token); + $win.find('.token-result').show(); + } catch ( e ) { + showError(e?.message ?? String(e)); + $btn.removeClass('disabled').prop('disabled', false); + } + }; + + $win.find('.create-token-btn').on('click', function () { + const label = ($win.find('.token-label-input').val() || '').trim(); + if ( ! label ) { + showError(i18n('token_label_required')); + $win.find('.token-label-input').focus(); + return; + } + doCreate(label); + }); + + // Enter in the label field submits. + $win.find('.token-label-input').on('keydown', function (e) { + if ( e.key === 'Enter' ) { + e.preventDefault(); + $win.find('.create-token-btn').trigger('click'); + } + }); + + $win.find('.copy-token-btn').on('click', function () { const $btn = $(this); - navigator.clipboard.writeText(window.auth_token).then(() => { - $(el_window).find('.token-copied-msg').fadeIn(); + navigator.clipboard.writeText($win.find('.token-input').val()).then(() => { + $win.find('.token-copied-msg').fadeIn(); $btn.text(i18n('token_copied')); setTimeout(() => { - $(el_window).find('.token-copied-msg').fadeOut(); + $win.find('.token-copied-msg').fadeOut(); $btn.text(i18n('copy')); }, 2000); }); }); - $(el_window).on('close', () => { + $win.find('.token-manage-sessions-link').on('click', function (e) { + e.preventDefault(); + UIWindowManageSessions({ + window_options: { + parent_uuid: $win.attr('data-element_uuid'), + backdrop: true, + close_on_backdrop_click: true, + stay_on_top: true, + }, + }); + }); + + $win.on('close', () => { resolve(); }); }); diff --git a/src/gui/src/helpers/create_access_token.js b/src/gui/src/helpers/create_access_token.js new file mode 100644 index 000000000..b5460ebed --- /dev/null +++ b/src/gui/src/helpers/create_access_token.js @@ -0,0 +1,70 @@ +/** + * 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 . + */ + +/** + * Mint a named, revocable **full-API-access** token for the current user. + * + * Unlike the raw GUI/session token this replaces, the returned access token + * can do everything the user can via the API (filesystem, drivers, KV, AI, + * apps, workers) but is rejected by the account-management endpoints + * (change password/email/username, 2FA, session-cookie sync, minting more + * tokens) — those gate on actor type, and an access-token actor never passes. + * + * The token is listed and revocable under Settings → Security → Manage + * sessions (it lands there as a labelled `access_token` session row). + * + * @param {object} [opts] + * @param {string|null} [opts.label] User-facing name (shown in manage-sessions). + * @param {string|null} [opts.expiresIn] jsonwebtoken duration, e.g. '30d'; null = never. + * @returns {Promise} the signed access token + */ +const create_access_token = async ({ label = null, expiresIn = null } = {}) => { + const body = { + // Sentinel grant — see FULL_API_ACCESS in the backend auth types. + permissions: ['full-api-access'], + }; + if ( label ) body.label = label; + if ( expiresIn ) body.expiresIn = expiresIn; + + const resp = await fetch(`${window.api_origin}/auth/create-access-token`, { + method: 'POST', + headers: { + Authorization: `Bearer ${puter.authToken || window.auth_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if ( ! resp.ok ) { + let message; + try { + message = (await resp.clone().json())?.message; + } catch { + try { + message = await resp.text(); + } catch { /* ignore */ } + } + throw new Error(message || `Failed to create token (${resp.status})`); + } + + const { token } = await resp.json(); + return token; +}; + +export default create_access_token; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 333d75e76..134b18095 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -613,6 +613,26 @@ const en = { copy_token_message: 'Your authentication token is shown below. Keep it secret \u2014 anyone with this token can access your account.', copy_token_description: 'View and copy your authentication token', + // API tokens (named, revocable, full API access \u2014 no account management) + api_token: 'API Token', + api_token_description: 'Create a named token for scripts, CLIs, and integrations', + create_api_token: 'Create API Token', + create_token: 'Create token', + create_token_message: 'Create a named token to use the Puter API from scripts, CLIs, agents, and integrations. It can do anything you can through the API, but cannot change your password or email, or delete your account.', + token_label: 'Label', + token_label_placeholder: 'e.g. My CLI, GitHub Actions', + token_label_required: 'Please enter a label for this token.', + token_label_external_app: 'External app', + token_expiry: 'Expiration', + token_expiry_never: 'Never', + token_expiry_7d: '7 days', + token_expiry_30d: '30 days', + token_expiry_90d: '90 days', + token_shown_once_warning: 'Copy this token now \u2014 it won\u2019t be shown again. Keep it secret; anyone with it can access your account\u2019s data through the API.', + token_manage_hint: 'You can revoke this token any time under', + shared_api_token: 'A restricted API token', + shared_api_token_note: 'The app can use the Puter API on your behalf, but cannot change your password or email, or delete your account. You can revoke it any time under Settings \u2192 Security \u2192 Manage sessions.', + // AuthMe dialog authorization_required: 'Authorization Required', external_site_auth_request: 'An app is requesting access to your account.', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 9f1ae32c0..e02f7a05b 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -35,6 +35,7 @@ import UIWindowSessionList from './UI/UIWindowSessionList.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js'; import { PROCESS_RUNNING } from './definitions.js'; +import create_access_token from './helpers/create_access_token.js'; import item_icon from './helpers/item_icon.js'; import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js'; import update_mouse_position from './helpers/update_mouse_position.js'; @@ -855,8 +856,24 @@ window.initgui = async function (options) { redirect_url: redirectURL, }); if ( approved ) { + // Hand the app a named, revocable full-API-access + // token instead of the raw GUI/session token: it can + // use the whole API but can't manage the account. + let host = ''; + try { host = new URL(redirectURL).host; } catch ( e ) { /* ignore */ } + let token; + try { + token = await create_access_token({ + label: host + ? `${i18n('token_label_external_app')} (${host})` + : i18n('token_label_external_app'), + }); + } catch ( e ) { + await UIAlert({ message: e?.message ?? String(e) }); + return; + } const url = new URL(redirectURL); - url.searchParams.set('token', window.auth_token); + url.searchParams.set('token', token); window.location.href = url.href; return; } @@ -1412,8 +1429,24 @@ window.initgui = async function (options) { redirect_url: redirectURL, }); if ( approved ) { + // Hand the app a named, revocable full-API-access token + // instead of the raw GUI/session token: it can use the + // whole API but can't manage the account. + let host = ''; + try { host = new URL(redirectURL).host; } catch ( e ) { /* ignore */ } + let token; + try { + token = await create_access_token({ + label: host + ? `${i18n('token_label_external_app')} (${host})` + : i18n('token_label_external_app'), + }); + } catch ( e ) { + await UIAlert({ message: e?.message ?? String(e) }); + return; + } const url = new URL(redirectURL); - url.searchParams.set('token', window.auth_token); + url.searchParams.set('token', token); window.location.href = url.href; return; }