From d747b32186bed9a2c48d84e133d2f646664735c7 Mon Sep 17 00:00:00 2001 From: Jacob Schlecht Date: Fri, 24 Jul 2026 20:20:54 -0600 Subject: [PATCH 1/2] fix: Require MFA ticket for session revoke Signed-off-by: Jacob Schlecht --- src/classes/MFA.ts | 19 +++++++++++-------- src/classes/Session.ts | 15 +++++++++++++-- src/collections/SessionCollection.ts | 18 ++++++++++++++---- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/classes/MFA.ts b/src/classes/MFA.ts index 916d0720..4819afd2 100644 --- a/src/classes/MFA.ts +++ b/src/classes/MFA.ts @@ -108,9 +108,12 @@ export class MFATicket { } /** - * Use the ticket + * @internal + * Use this ticket + * + * You probably shouldn't be calling this method outside the stoat.js client */ - #consume(): void { + consume(): void { if (this.#used) throw "Already used this ticket!"; this.#used = true; } @@ -120,7 +123,7 @@ export class MFATicket { * @returns List of codes */ fetchRecoveryCodes(): Promise { - this.#consume(); + this.consume(); return this.#client.api.post("/auth/mfa/recovery", undefined, { headers: { "X-MFA-Ticket": this.token, @@ -133,7 +136,7 @@ export class MFATicket { * @returns List of codes */ async generateRecoveryCodes(): Promise { - this.#consume(); + this.consume(); const codes = await this.#client.api.patch( "/auth/mfa/recovery", @@ -154,7 +157,7 @@ export class MFATicket { * @returns Secret */ async generateAuthenticatorSecret(): Promise { - this.#consume(); + this.consume(); return ( await this.#client.api.post("/auth/mfa/totp", undefined, { headers: { @@ -168,7 +171,7 @@ export class MFATicket { * Disable authenticator */ async disableAuthenticator(): Promise { - this.#consume(); + this.consume(); await this.#client.api.delete("/auth/mfa/totp", undefined, { headers: { @@ -183,7 +186,7 @@ export class MFATicket { * Disable account */ disableAccount(): Promise { - this.#consume(); + this.consume(); return this.#client.api.post("/auth/account/disable", undefined, { headers: { "X-MFA-Ticket": this.token, @@ -195,7 +198,7 @@ export class MFATicket { * Delete account */ deleteAccount(): Promise { - this.#consume(); + this.consume(); return this.#client.api.post("/auth/account/delete", undefined, { headers: { "X-MFA-Ticket": this.token, diff --git a/src/classes/Session.ts b/src/classes/Session.ts index bbf244ec..9352749c 100644 --- a/src/classes/Session.ts +++ b/src/classes/Session.ts @@ -2,6 +2,8 @@ import { decodeTime } from "ulid"; import type { SessionCollection } from "../collections/SessionCollection.js"; +import { MFATicket } from "./MFA.js"; + /** * Session Class */ @@ -70,8 +72,17 @@ export class Session { /** * Delete a session */ - async delete(): Promise { - await this.#collection.client.api.delete(`/auth/session/${this.id as ""}`); + async delete(ticket: MFATicket): Promise { + ticket.consume(); + await this.#collection.client.api.delete( + `/auth/session/${this.id as ""}`, + undefined, + { + headers: { + "X-MFA-Ticket": ticket.token, + }, + }, + ); this.#collection.delete(this.id); } } diff --git a/src/collections/SessionCollection.ts b/src/collections/SessionCollection.ts index acae155c..80863a58 100644 --- a/src/collections/SessionCollection.ts +++ b/src/collections/SessionCollection.ts @@ -2,6 +2,7 @@ import { batch } from "solid-js"; import type { SessionInfo } from "stoat-api"; +import { MFATicket } from "../classes/MFA.js"; import { Session } from "../classes/Session.js"; import type { HydratedSession } from "../hydration/session.js"; @@ -29,10 +30,19 @@ export class SessionCollection extends ClassCollection< * Delete all sessions, optionally including self * @param revokeSelf Whether to remove current session too */ - async deleteAll(revokeSelf = false): Promise { - await this.client.api.delete("/auth/session/all", { - revoke_self: revokeSelf, - }); + async deleteAll(ticket: MFATicket, revokeSelf = false): Promise { + ticket.consume(); + await this.client.api.delete( + "/auth/session/all", + { + revoke_self: revokeSelf, + }, + { + headers: { + "X-MFA-Ticket": ticket.token, + }, + }, + ); for (const entry of this.toList()) { if (!revokeSelf && entry.current) continue; From d71880b5cf990475b088201d8a717166536152c3 Mon Sep 17 00:00:00 2001 From: Jacob Schlecht Date: Fri, 24 Jul 2026 23:23:43 -0600 Subject: [PATCH 2/2] refactor: Add _ prefix to consume function Signed-off-by: Jacob Schlecht --- src/classes/MFA.ts | 16 +++++++--------- src/classes/Session.ts | 2 +- src/collections/SessionCollection.ts | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/classes/MFA.ts b/src/classes/MFA.ts index 4819afd2..a37a5b4f 100644 --- a/src/classes/MFA.ts +++ b/src/classes/MFA.ts @@ -110,10 +110,8 @@ export class MFATicket { /** * @internal * Use this ticket - * - * You probably shouldn't be calling this method outside the stoat.js client */ - consume(): void { + _consume(): void { if (this.#used) throw "Already used this ticket!"; this.#used = true; } @@ -123,7 +121,7 @@ export class MFATicket { * @returns List of codes */ fetchRecoveryCodes(): Promise { - this.consume(); + this._consume(); return this.#client.api.post("/auth/mfa/recovery", undefined, { headers: { "X-MFA-Ticket": this.token, @@ -136,7 +134,7 @@ export class MFATicket { * @returns List of codes */ async generateRecoveryCodes(): Promise { - this.consume(); + this._consume(); const codes = await this.#client.api.patch( "/auth/mfa/recovery", @@ -157,7 +155,7 @@ export class MFATicket { * @returns Secret */ async generateAuthenticatorSecret(): Promise { - this.consume(); + this._consume(); return ( await this.#client.api.post("/auth/mfa/totp", undefined, { headers: { @@ -171,7 +169,7 @@ export class MFATicket { * Disable authenticator */ async disableAuthenticator(): Promise { - this.consume(); + this._consume(); await this.#client.api.delete("/auth/mfa/totp", undefined, { headers: { @@ -186,7 +184,7 @@ export class MFATicket { * Disable account */ disableAccount(): Promise { - this.consume(); + this._consume(); return this.#client.api.post("/auth/account/disable", undefined, { headers: { "X-MFA-Ticket": this.token, @@ -198,7 +196,7 @@ export class MFATicket { * Delete account */ deleteAccount(): Promise { - this.consume(); + this._consume(); return this.#client.api.post("/auth/account/delete", undefined, { headers: { "X-MFA-Ticket": this.token, diff --git a/src/classes/Session.ts b/src/classes/Session.ts index 9352749c..80bdcb30 100644 --- a/src/classes/Session.ts +++ b/src/classes/Session.ts @@ -73,7 +73,7 @@ export class Session { * Delete a session */ async delete(ticket: MFATicket): Promise { - ticket.consume(); + ticket._consume(); await this.#collection.client.api.delete( `/auth/session/${this.id as ""}`, undefined, diff --git a/src/collections/SessionCollection.ts b/src/collections/SessionCollection.ts index 80863a58..c60590f8 100644 --- a/src/collections/SessionCollection.ts +++ b/src/collections/SessionCollection.ts @@ -31,7 +31,7 @@ export class SessionCollection extends ClassCollection< * @param revokeSelf Whether to remove current session too */ async deleteAll(ticket: MFATicket, revokeSelf = false): Promise { - ticket.consume(); + ticket._consume(); await this.client.api.delete( "/auth/session/all", {