mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
fix: PUT-1429 (#3508)
This commit is contained in:
@@ -2340,6 +2340,131 @@ describe('AuthService (integration)', () => {
|
||||
);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it('revokes a full-access token by raw token_uid, which has no grant rows to resolve against', 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],
|
||||
]);
|
||||
const decoded = server.services.token.verify('auth', jwt) as {
|
||||
token_uid: string;
|
||||
session_uid: string;
|
||||
};
|
||||
|
||||
await authService.revokeAccessToken(actor, decoded.token_uid);
|
||||
|
||||
expect(
|
||||
await server.stores.session.getByUuid(decoded.session_uid),
|
||||
).toBeNull();
|
||||
expect(await authService.authenticateFromToken(jwt)).toBeNull();
|
||||
});
|
||||
|
||||
it('404s when another user names a full-access token by raw token_uid', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const ownerActor = {
|
||||
user: {
|
||||
id: owner.id,
|
||||
uuid: owner.uuid,
|
||||
username: owner.username,
|
||||
},
|
||||
} as Actor;
|
||||
const otherActor = {
|
||||
user: {
|
||||
id: other.id,
|
||||
uuid: other.uuid,
|
||||
username: other.username,
|
||||
},
|
||||
} as Actor;
|
||||
const jwt = await authService.createAccessToken(ownerActor, [
|
||||
[FULL_API_ACCESS],
|
||||
]);
|
||||
const decoded = server.services.token.verify('auth', jwt) as {
|
||||
token_uid: string;
|
||||
};
|
||||
|
||||
await expect(
|
||||
authService.revokeAccessToken(otherActor, decoded.token_uid),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(await authService.authenticateFromToken(jwt)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeSession on access-token rows', () => {
|
||||
// The manage-sessions UI only ever holds the session uuid — the
|
||||
// token itself is shown once at mint and never again — so revoking
|
||||
// by uuid has to be enough to both kill the token and clear what it
|
||||
// was allowed to do.
|
||||
|
||||
it('stops a full-access token authenticating when revoked by session uuid', 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],
|
||||
]);
|
||||
const decoded = server.services.token.verify('auth', jwt) as {
|
||||
session_uid: string;
|
||||
};
|
||||
|
||||
expect(await authService.authenticateFromToken(jwt)).toBeTruthy();
|
||||
|
||||
await authService.revokeSession(decoded.session_uid);
|
||||
|
||||
expect(await authService.authenticateFromToken(jwt)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the grant manifest of a scoped token revoked by session uuid', 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, [
|
||||
[`user:${user.uuid}:email:read`],
|
||||
]);
|
||||
const decoded = server.services.token.verify('auth', jwt) as {
|
||||
token_uid: string;
|
||||
session_uid: string;
|
||||
};
|
||||
|
||||
await authService.revokeSession(decoded.session_uid);
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT 1 FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
|
||||
[decoded.token_uid],
|
||||
)) as unknown[];
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(await authService.authenticateFromToken(jwt)).toBeNull();
|
||||
});
|
||||
|
||||
it('clears grants of token rows parented to a revoked session', async () => {
|
||||
const user = await makeUser();
|
||||
const parent = await server.stores.session.create(user.id, {
|
||||
kind: 'app',
|
||||
});
|
||||
const tokenUid = uuidv4();
|
||||
await server.stores.session.create(user.id, {
|
||||
kind: 'access_token',
|
||||
parent_session_id: parent.uuid,
|
||||
access_token_uid: tokenUid,
|
||||
});
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `authorizer_app_id`, `permission`, `extra`) VALUES (?, ?, ?, ?, ?)',
|
||||
[tokenUid, user.id, null, 'driver:test:call', '{}'],
|
||||
);
|
||||
|
||||
await authService.revokeSession(parent.uuid);
|
||||
|
||||
const rows = (await server.clients.db.read(
|
||||
'SELECT 1 FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
|
||||
[tokenUid],
|
||||
)) as unknown[];
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeAllSessions', () => {
|
||||
|
||||
@@ -585,9 +585,21 @@ export class AuthService extends PuterService {
|
||||
* Revoke a session by uuid, cascading to any rows whose `parent_session_id`
|
||||
* points at it. Used by the manage-sessions UI and by
|
||||
* `removeSessionByToken` — semantics are identical.
|
||||
*
|
||||
* This is the revoke path for _every_ session kind, access tokens included:
|
||||
* the uuid is what `listSessions` hands the UI, and ownership is checked
|
||||
* against the row itself by the caller. Their grants are dropped here so
|
||||
* the two entry points leave the same state behind.
|
||||
*/
|
||||
async revokeSession(uuid: string): Promise<void> {
|
||||
// Read first — after the cascade these rows carry `revoked_at` and
|
||||
// no longer count as active.
|
||||
const tokenUids =
|
||||
await this.stores.session.accessTokenUidsForCascade(uuid);
|
||||
await this.stores.session.revokeCascade(uuid);
|
||||
for (const tokenUid of tokenUids) {
|
||||
await this.#dropAccessTokenGrants(tokenUid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1545,7 +1557,13 @@ export class AuthService extends PuterService {
|
||||
|
||||
// A signature-verified JWT is itself proof of who issued the token —
|
||||
// the body's `user_uid` was set by createAccessToken at mint time.
|
||||
// For raw-uuid input we fall back to the persisted authorizer.
|
||||
// For raw-uuid input the session row is the primary authority: a
|
||||
// full-access token carries its grant as a signed claim and writes
|
||||
// no `access_token_permissions` row to resolve against, so reading
|
||||
// ownership from the manifest alone leaves the broadest token we
|
||||
// issue unrevokable. The manifest stays as a fallback for rows that
|
||||
// predate session-backed access tokens.
|
||||
let sessionRow: SessionRow | null = null;
|
||||
if (issuerUuidFromJwt !== undefined) {
|
||||
if (issuerUuidFromJwt !== actor.user.uuid) {
|
||||
throw new HttpError(404, 'Access token not found', {
|
||||
@@ -1553,40 +1571,66 @@ export class AuthService extends PuterService {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `authorizer_user_id` FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
|
||||
[tokenUid],
|
||||
)) as Array<{ authorizer_user_id?: number | null }>;
|
||||
const ownerId = rows[0]?.authorizer_user_id ?? null;
|
||||
if (ownerId === null || ownerId !== actor.user.id) {
|
||||
sessionRow =
|
||||
await this.stores.session.findActiveByAccessTokenUid(tokenUid);
|
||||
const ownerId =
|
||||
sessionRow?.user_id ??
|
||||
(await this.#accessTokenAuthorizerId(tokenUid));
|
||||
if (ownerId == null || ownerId !== actor.user.id) {
|
||||
throw new HttpError(404, 'Access token not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Permissions rows still DELETE — the "no DELETE on revoke"
|
||||
// rule scoped to the `sessions` table (where the audit trail of
|
||||
// when a session existed/was revoked is load-bearing for forensic
|
||||
// queries and the cascade graph). `access_token_permissions`
|
||||
// rows are the grant manifest for an *active* token; once its
|
||||
// session is soft-revoked, the grants are dead-weight cache
|
||||
// entries that would only confuse `checkMany`. If we later need
|
||||
// permission-grant history for audit, that becomes a
|
||||
// `revoked_at` column on this table, not a behavior change here.
|
||||
await this.#dropAccessTokenGrants(tokenUid);
|
||||
|
||||
if (sessionUidFromJwt) {
|
||||
await this.stores.session.removeByUuid(sessionUidFromJwt);
|
||||
} else {
|
||||
// A v1 JWT carries no `session_uid`, so the row still has to be
|
||||
// found by token identity here.
|
||||
const row =
|
||||
sessionRow ??
|
||||
(await this.stores.session.findActiveByAccessTokenUid(
|
||||
tokenUid,
|
||||
));
|
||||
if (row) await this.stores.session.removeByUuid(row.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted authorizer of an access token, from its grant manifest. Returns
|
||||
* null for a token with no grants — which every full-access token is, so
|
||||
* callers need another source of ownership before treating null as "not
|
||||
* yours".
|
||||
*/
|
||||
async #accessTokenAuthorizerId(tokenUid: string): Promise<number | null> {
|
||||
const rows = (await this.clients.db.read(
|
||||
'SELECT `authorizer_user_id` FROM `access_token_permissions` WHERE `token_uid` = ? LIMIT 1',
|
||||
[tokenUid],
|
||||
)) as Array<{ authorizer_user_id?: number | null }>;
|
||||
return rows[0]?.authorizer_user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an access token's grant manifest.
|
||||
*
|
||||
* These rows DELETE rather than soft-revoke — the "no DELETE on revoke"
|
||||
* rule is scoped to the `sessions` table, where the audit trail of when a
|
||||
* session existed and when it died is load-bearing for forensic queries and
|
||||
* the cascade graph. `access_token_permissions` rows are the grant manifest
|
||||
* for an _active_ token; once its session is soft-revoked they are
|
||||
* dead-weight cache entries that would only confuse `checkMany`. If we
|
||||
* later need grant history for audit, that becomes a `revoked_at` column on
|
||||
* this table, not a behavior change here.
|
||||
*/
|
||||
async #dropAccessTokenGrants(tokenUid: string): Promise<void> {
|
||||
await this.clients.db.write(
|
||||
'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?',
|
||||
[tokenUid],
|
||||
);
|
||||
await this.stores.permission.invalidateAccessTokenPerms(tokenUid);
|
||||
|
||||
if (sessionUidFromJwt) {
|
||||
await this.stores.session.removeByUuid(sessionUidFromJwt);
|
||||
} else {
|
||||
const row =
|
||||
await this.stores.session.findActiveByAccessTokenUid(tokenUid);
|
||||
if (row) await this.stores.session.removeByUuid(row.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
@@ -374,6 +374,30 @@ export class SessionStore extends PuterStore {
|
||||
await this.publishCacheKeys({ keys, broadcast: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-token identities among the rows `revokeCascade(rootUuid)` would
|
||||
* affect — the root itself when it is a token row, plus any token row
|
||||
* parented to it. Callers use these to drop grants that would otherwise
|
||||
* outlive the revoked row. Must be read _before_ the cascade, while the
|
||||
* rows still qualify as active.
|
||||
*
|
||||
* Both identity columns are consulted: v2 rows carry `access_token_uid`
|
||||
* from mint, v1 rows get `legacy_token_uid` backfilled on first verify.
|
||||
*/
|
||||
async accessTokenUidsForCascade(rootUuid) {
|
||||
if (!rootUuid) return [];
|
||||
const rows = await this.clients.db.read(
|
||||
"SELECT `access_token_uid`, `legacy_token_uid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `kind` = 'access_token' AND `revoked_at` IS NULL",
|
||||
[rootUuid, rootUuid],
|
||||
);
|
||||
const uids = [];
|
||||
for (const row of rows) {
|
||||
const uid = row.access_token_uid ?? row.legacy_token_uid;
|
||||
if (uid) uids.push(uid);
|
||||
}
|
||||
return uids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active session row whose access-token identity matches `tokenUid`. Covers
|
||||
* both v2 (`access_token_uid` set at mint) and v1 lazy-backfill
|
||||
|
||||
@@ -560,26 +560,19 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
|
||||
const anti_csrf = await services.get('anti-csrf').token();
|
||||
|
||||
// Route access-token rows to the dedicated endpoint
|
||||
// so `access_token_permissions` is cleared in addition
|
||||
// to the session row being soft-revoked. Everything
|
||||
// else (web/app/asset/worker) goes through cascade-
|
||||
// capable /auth/revoke-session.
|
||||
const isAccessToken = session.kind === 'access_token';
|
||||
const url = isAccessToken
|
||||
? `${window.api_origin}/auth/revoke-access-token`
|
||||
: `${window.api_origin}/auth/revoke-session`;
|
||||
const body = isAccessToken
|
||||
? { tokenOrUuid: session.uuid, anti_csrf }
|
||||
: { uuid: session.uuid, anti_csrf };
|
||||
|
||||
const resp = await fetch(url, {
|
||||
// Every kind revokes by session uuid, access tokens
|
||||
// included: the uuid is what list-sessions gave us, and
|
||||
// it's the only identifier we hold — the token itself is
|
||||
// shown once at mint and never again. /auth/revoke-session
|
||||
// resolves ownership from the row and cascades, and
|
||||
// clears the token's grants on the way through.
|
||||
const resp = await fetch(`${window.api_origin}/auth/revoke-session`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${puter.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ uuid: session.uuid, anti_csrf }),
|
||||
});
|
||||
if ( resp.ok ) {
|
||||
// Full reload — cascade may have killed children
|
||||
|
||||
Reference in New Issue
Block a user