diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index b23cc351f..b5ba35fd9 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -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', () => { diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 0e1b76e67..95d2a8a0e 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -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 { + // 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 { + 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 { 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 --------------------------------------------------- diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index e1c00d5bd..9f61d4f72 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -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 diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index c8b95a9cf..f3c55e2ad 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -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