Revert "feat (PUT-1091): have gui tokens labeled and be different than reques…"

This reverts commit 1335b46404.
This commit is contained in:
Daniel Salazar
2026-06-08 11:11:56 -07:00
committed by GitHub
parent def7290c48
commit 00bca1d47e
16 changed files with 30 additions and 696 deletions
@@ -39,7 +39,6 @@ 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 ────────────────────────────────────────────────────
@@ -1288,71 +1287,6 @@ 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 };
expect(decoded.type).toBe('access-token');
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.map((r) => r.permission)).toContain(FULL_API_ACCESS);
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();
+2 -17
View File
@@ -2212,25 +2212,13 @@ export class AuthController extends PuterController {
requireAuth: true,
})
async handleCreateAccessToken(req: Request, res: Response): Promise<void> {
const { permissions, expiresIn, label } = req.body;
const { permissions, expiresIn } = 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];
@@ -2245,10 +2233,7 @@ export class AuthController extends PuterController {
const token = await this.services.auth.createAccessToken(
req.actor!,
normalized as never,
{
...(expiresIn ? { expiresIn } : {}),
...(normalizedLabel ? { label: normalizedLabel } : {}),
},
expiresIn ? { expiresIn } : {},
);
res.json({ token });
}
@@ -1,92 +0,0 @@
/**
* 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 { 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);
});
+2 -13
View File
@@ -19,7 +19,6 @@
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';
@@ -199,25 +198,15 @@ export class AppIconService extends PuterService {
}
// Register the `puter-app-icons` subdomain pointing at that dir.
// 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.
// Idempotent: skip if it already exists.
const already =
await this.stores.subdomain.existsBySubdomain(APP_ICONS_SUBDOMAIN);
if (already) return;
try {
if (!already) {
await this.stores.subdomain.create({
userId: adminUser.id,
subdomain: APP_ICONS_SUBDOMAIN,
rootDirId: dirEntry.id ?? null,
});
} catch (e) {
if (!isUniqueViolation(e)) throw e;
}
}
@@ -24,7 +24,6 @@ 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 {
@@ -77,20 +76,6 @@ 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 ───────────────────────────────────
@@ -1666,102 +1651,6 @@ 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;
};
expect(decoded.type).toBe('access-token');
// The full-access grant is stored verbatim (no issuer-subset
// check rejects it).
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.map((r) => r.permission)).toContain(
FULL_API_ACCESS,
);
// 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();
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(
await server.services.permission.check(
scopedActor!,
`fs:${fileEntry!.uuid}:read`,
),
).toBe(false);
});
});
describe('private-asset / public hosted-actor tokens', () => {
+4 -33
View File
@@ -30,7 +30,6 @@ 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,
@@ -1442,7 +1441,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; label?: string | null } = {},
options: { expiresIn?: string | number } = {},
): Promise<string> {
if (!actor.user)
throw new HttpError(403, 'Actor must be a user', {
@@ -1458,20 +1457,6 @@ 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)
@@ -1480,19 +1465,12 @@ 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. The full-access sentinel is excluded — it
// isn't a real permission the issuer "holds"; its gate is the
// user-actor check above.
// many-permission mints.
const requestedPerms = [
...new Set(
permissions
.map(([p]) => p)
.filter(
(p): p is string =>
typeof p === 'string' &&
!!p &&
p !== FULL_API_ACCESS,
),
.filter((p): p is string => typeof p === 'string' && !!p),
),
];
if (requestedPerms.length > 0) {
@@ -1531,9 +1509,6 @@ 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,
@@ -1564,11 +1539,7 @@ export class AuthService extends PuterService {
const jwt = this.services.token.sign(
'auth',
jwtPayload,
// 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 }
: {},
options as { expiresIn?: number },
);
// Store each permission grant
@@ -23,7 +23,6 @@ import { Context } from '../../core/context';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterService } from '../types';
import {
FULL_API_ACCESS,
MANAGE_PERM_PREFIX,
PERMISSION_SCAN_CACHE_TTL_SECONDS,
} from './consts';
@@ -432,31 +431,6 @@ export class PermissionService extends PuterService {
): Promise<void> {
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. Account-management endpoints stay closed because they gate
// on actor type (requireUserActor / session cookie), not permissions.
const hasFullAccess = await this.stores.permission.hasAccessTokenPerm(
actor.accessToken.uid,
FULL_API_ACCESS,
);
if (hasFullAccess) {
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(
-16
View File
@@ -29,19 +29,3 @@ 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';
-53
View File
@@ -1,53 +0,0 @@
/**
* 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 { 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);
});
});
-44
View File
@@ -1,44 +0,0 @@
/**
* 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/>.
*/
/**
* 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;
}
+4 -4
View File
@@ -101,18 +101,18 @@ const TabAccount = {
h += '</div>';
}
// API token card
// Auth token card
h += '<div class="dashboard-card dashboard-settings-card">';
h += '<div class="dashboard-settings-card-content">';
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('api_token')}</strong>`;
h += `<span>${i18n('api_token_description')}</span>`;
h += `<strong>${i18n('auth_token')}</strong>`;
h += `<span>${i18n('copy_token_description')}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
h += `<button class="button copy-auth-token">${i18n('copy') || 'Copy'}</button>`;
h += '</div>';
// Danger zone
+1 -2
View File
@@ -116,9 +116,8 @@ async function UIWindowAuthMe (options = {}) {
h += `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>`;
h += `<span style="font-size: 13px; color: #374151;">${i18n('shared_api_token')}</span>`;
h += `<span style="font-size: 13px; color: #374151;">${i18n('your_auth_token')}</span>`;
h += '</div>';
h += `<p style="margin: 8px 0 0; font-size: 12px; color: #6b7280; line-height: 1.4;">${i18n('shared_api_token_note')}</p>`;
h += '</div>';
// Buttons
+15 -94
View File
@@ -18,14 +18,7 @@
*/
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 = '';
@@ -53,49 +46,29 @@ function UIWindowCopyToken (options = {}) {
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>`;
h += '</div>';
h += `<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: white;">${i18n('create_api_token')}</h2>`;
h += `<p style="margin: 6px 0 0; font-size: 13px; color: rgba(255,255,255,0.8); text-align: center; line-height: 1.4;">${i18n('create_token_message')}</p>`;
h += `<h2 style="margin: 0; font-size: 17px; font-weight: 600; color: white;">${i18n('auth_token')}</h2>`;
h += `<p style="margin: 6px 0 0; font-size: 13px; color: rgba(255,255,255,0.8); text-align: center; line-height: 1.4;">${i18n('copy_token_message')}</p>`;
h += '</div>';
}
h += '<div class="copy-token" style="padding: 20px; border-bottom: 1px solid #ced7e1;">';
if ( ! options.show_header ) {
h += `<div class="form-label" style="margin-bottom: 5px; font-size: 13px; color: #666;">${i18n('copy_token_message')}</div>`;
}
if ( options.show_close_button ) {
h += '<div class="qr-code-window-close-btn generic-close-window-button"> &times; </div>';
}
// -- Phase A: create form --
h += '<div class="create-token-form">';
if ( ! options.show_header ) {
h += `<div class="form-label" style="margin-bottom: 12px; font-size: 13px; color: #666;">${i18n('create_token_message')}</div>`;
}
h += '<div class="form-error-msg" style="display:none; margin-bottom: 12px;"></div>';
h += `<label class="form-label" style="font-size: 13px;" for="token-label-input">${i18n('token_label')}</label>`;
h += `<input id="token-label-input" type="text" class="token-label-input" maxlength="64" autocomplete="off" placeholder="${i18n('token_label_placeholder')}" style="width: 100%; box-sizing: border-box; margin: 5px 0 15px;" />`;
h += `<label class="form-label" style="font-size: 13px;" for="token-expiry-select">${i18n('token_expiry')}</label>`;
h += `<select id="token-expiry-select" class="token-expiry-select" style="width: 100%; box-sizing: border-box; margin: 5px 0 15px;">`;
h += `<option value="">${i18n('token_expiry_never')}</option>`;
h += `<option value="7d">${i18n('token_expiry_7d')}</option>`;
h += `<option value="30d">${i18n('token_expiry_30d')}</option>`;
h += `<option value="90d">${i18n('token_expiry_90d')}</option>`;
h += '</select>';
h += `<button class="button button-primary button-block create-token-btn">${i18n('create_token')}</button>`;
h += '</div>';
// -- Phase B: result (shown once, after mint) --
h += '<div class="token-result" style="display:none;">';
h += `<div class="token-result-warning" style="margin-bottom: 12px; font-size: 13px; color: #b54708; background: #fffaeb; border: 1px solid #fedf89; border-radius: 6px; padding: 10px;">${i18n('token_shown_once_warning')}</div>`;
h += '<div style="display: flex; gap: 8px; margin-bottom: 12px;">';
h += '<input type="text" class="token-input" readonly value="" style="flex: 1; font-family: monospace; font-size: 13px;" />';
h += `<div style="display: flex; gap: 8px; margin-top: ${options.show_header ? '0' : '15'}px; margin-bottom: 15px;">`;
h += `<input type="text" class="token-input" readonly value="${html_encode(window.auth_token)}" style="flex: 1; font-family: monospace; font-size: 13px;" />`;
h += `<button class="button button-primary copy-token-btn">${i18n('copy')}</button>`;
h += '</div>';
h += `<div class="token-copied-msg form-success-msg" style="display: none; text-align: center; margin-bottom: 8px;">${i18n('token_copied')}</div>`;
h += `<div style="font-size: 12px; color: #666;">${i18n('token_manage_hint')} <a href="#" class="token-manage-sessions-link">${i18n('ui_manage_sessions')}</a>.</div>`;
h += '<div class="token-copied-msg form-success-msg" style="display: none; text-align: center;">';
h += i18n('token_copied');
h += '</div>';
h += '</div>';
const el_window = await UIWindow({
title: i18n('create_api_token'),
title: i18n('auth_token'),
app: 'copy-token',
single_instance: true,
icon: null,
@@ -126,71 +99,19 @@ function UIWindowCopyToken (options = {}) {
...options.window_options,
});
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 () {
$(el_window).find('.copy-token-btn').on('click', function () {
const $btn = $(this);
navigator.clipboard.writeText($win.find('.token-input').val()).then(() => {
$win.find('.token-copied-msg').fadeIn();
navigator.clipboard.writeText(window.auth_token).then(() => {
$(el_window).find('.token-copied-msg').fadeIn();
$btn.text(i18n('token_copied'));
setTimeout(() => {
$win.find('.token-copied-msg').fadeOut();
$(el_window).find('.token-copied-msg').fadeOut();
$btn.text(i18n('copy'));
}, 2000);
});
});
$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', () => {
$(el_window).on('close', () => {
resolve();
});
});
@@ -1,70 +0,0 @@
/**
* 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/>.
*/
/**
* 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<string>} 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;
-20
View File
@@ -613,26 +613,6 @@ 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.',
+2 -35
View File
@@ -35,7 +35,6 @@ 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';
@@ -856,24 +855,8 @@ 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', token);
url.searchParams.set('token', window.auth_token);
window.location.href = url.href;
return;
}
@@ -1429,24 +1412,8 @@ 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', token);
url.searchParams.set('token', window.auth_token);
window.location.href = url.href;
return;
}