diff --git a/src/backend/controllers/auth/AuthController.http.test.ts b/src/backend/controllers/auth/AuthController.http.test.ts index 3dbe5ad1f..157a63196 100644 --- a/src/backend/controllers/auth/AuthController.http.test.ts +++ b/src/backend/controllers/auth/AuthController.http.test.ts @@ -350,6 +350,70 @@ describe('revoke-own-access-token over HTTP', () => { } expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200); }); + + // These three run last in the file's shared `env.users.user`: each one + // revokes a session belonging to `owner.token` / `owner.workerToken`, so + // later tests can't rely on those credentials still being live. + + it('a revoked access token yields 401 with no reauth_token or auth_id', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + const readToken = await mintReadToken(owner.token, file.uid); + + const revoke = await call( + 'POST', + '/auth/revoke-own-access-token', + owner.token, + { token: readToken }, + ); + expect(revoke.status).toBe(200); + + const res = await call('GET', '/whoami', readToken); + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.code).toBe('reauth_required'); + expect(body.reauth_token).toBeUndefined(); + expect(body.auth_id).toBeUndefined(); + }); + + it('a revoked worker session gets no reauth_token even though it rides the session token type', async () => { + const owner = env.users.user; + const decoded = env.server.services.token.verify( + 'auth', + owner.workerToken, + ) as { session_uid: string }; + await env.server.stores.session.removeByUuid(decoded.session_uid); + + const res = await call('GET', '/whoami', owner.workerToken); + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.code).toBe('reauth_required'); + expect(body.reauth_token).toBeUndefined(); + expect(body.auth_id).toBeUndefined(); + }); + + it('a revoked GUI session still gets a reauth_token', async () => { + const owner = env.users.user; + const decoded = env.server.services.token.verify('auth', owner.token) as { + session_uid: string; + }; + await env.server.stores.session.removeByUuid(decoded.session_uid); + + const res = await call('GET', '/whoami', owner.token); + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.code).toBe('reauth_required'); + expect(typeof body.reauth_token).toBe('string'); + + const user = await env.server.stores.user.getByUsername( + owner.username, + ); + expect( + env.server.services.auth.verifyReauthToken( + body.reauth_token as string, + ).authId, + ).toBe(user!.uuid); + }); }); /** diff --git a/src/backend/services/auth/AuthService.appDeletion.test.ts b/src/backend/services/auth/AuthService.appDeletion.test.ts index c4f0c31cb..7937950f9 100644 --- a/src/backend/services/auth/AuthService.appDeletion.test.ts +++ b/src/backend/services/auth/AuthService.appDeletion.test.ts @@ -259,4 +259,20 @@ describe('an app uid that returns after its app was deleted', () => { expect(await isRevoked(sessionUid(old))).toBe(true); expect((await authenticate(fresh)).actor).toBeTruthy(); }); + + it('rejects a stale app token with no auth_id or reauth_token', async () => { + const { app, actor } = await createApp(); + const token = await env.server.services.auth.getUserAppToken( + actor, + app.uid, + ); + await backdateSession(sessionUid(token), 3600); + + const res = await api('/whoami', token); + expect(res.status).toBe(401); + const body = (await res.json()) as Record; + expect(body.code).toBe('reauth_required'); + expect(body.reauth_token).toBeUndefined(); + expect(body.auth_id).toBeUndefined(); + }); }); diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 25d24ad78..03b4c4367 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -340,10 +340,9 @@ describe('AuthService (integration)', () => { const result = await authService.authenticate(appToken); expect(result.actor).toBeUndefined(); - expect(result.reauth).toEqual({ - reason: 'session_revoked', - auth_id: user.uuid, - }); + // No `auth_id`: a reauth token is only ever minted for the + // user's own session/GUI token, never an app token. + expect(result.reauth).toEqual({ reason: 'session_revoked' }); }); it('app-under-user: returns reauth.session_expired when the app session expires_at is in the past', async () => { @@ -372,10 +371,7 @@ describe('AuthService (integration)', () => { const result = await authService.authenticate(appToken); expect(result.actor).toBeUndefined(); - expect(result.reauth).toEqual({ - reason: 'session_expired', - auth_id: user.uuid, - }); + expect(result.reauth).toEqual({ reason: 'session_expired' }); }); // ── Access-token verify path ─────────────────────────────── @@ -404,10 +400,8 @@ describe('AuthService (integration)', () => { const result = await authService.authenticate(accessToken); expect(result.actor).toBeUndefined(); - expect(result.reauth).toEqual({ - reason: 'session_revoked', - auth_id: user.uuid, - }); + // No `auth_id`: access tokens never get a reauth token. + expect(result.reauth).toEqual({ reason: 'session_revoked' }); }); it('access-token: returns reauth.session_expired when the access-token session expires_at is in the past', async () => { @@ -440,10 +434,27 @@ describe('AuthService (integration)', () => { const result = await authService.authenticate(accessToken); expect(result.actor).toBeUndefined(); - expect(result.reauth).toEqual({ - reason: 'session_expired', - auth_id: user.uuid, - }); + expect(result.reauth).toEqual({ reason: 'session_expired' }); + }); + + it('a revoked worker session gets no auth_id even though it rides the session token type', async () => { + const user = await makeUser(); + const actor: Actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + }; + const { token, session } = + await authService.createWorkerSessionToken( + actor, + user, + `w-${uuidv4()}`, + ); + await server.stores.session.removeByUuid( + (session as { uuid: string }).uuid, + ); + + const result = await authService.authenticate(token); + expect(result.actor).toBeUndefined(); + expect(result.reauth).toEqual({ reason: 'session_revoked' }); }); }); @@ -1515,10 +1526,9 @@ describe('AuthService (integration)', () => { const result = await authService.authenticate(token); expect(result.actor).toBeUndefined(); - expect(result.reauth).toEqual({ - reason: 'session_revoked', - auth_id: user.uuid, - }); + // No `auth_id`: a worker credential isn't a browser session, + // even though it rides the session/gui token type. + expect(result.reauth).toEqual({ reason: 'session_revoked' }); }); it('createWorkerSessionToken after revoke mints a new session uuid (composite cache invalidates)', async () => { diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index bb18674d5..502479886 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -2125,7 +2125,11 @@ export class AuthService extends PuterService { ): Promise { const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return { invalid: true }; - const auth_id = this.#authIdFor(user as UserRow); + // A worker credential rides the session/gui token type but isn't a + // browser session — never hand back a reauth token for one. + const auth_id = decoded.worker + ? undefined + : this.#authIdFor(user as UserRow); // v2 tokens prefer `session_uid`; v1 only carries `uuid`. Both // store the web-session uuid. @@ -2166,7 +2170,6 @@ export class AuthService extends PuterService { ): Promise { const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return { invalid: true }; - const auth_id = this.#authIdFor(user as UserRow); const app = await this.stores.app.getByUid(decoded.app_uid); if (!app) return { invalid: true }; @@ -2193,17 +2196,29 @@ export class AuthService extends PuterService { )) as SessionRow | null; } + // An app token never carries `auth_id` on its reauth result: only a + // user's own session/GUI token can be reattached via a reauth token. if (rawRow?.revoked_at != null) { - return { reauth: { reason: 'session_revoked', auth_id } }; + return { reauth: { reason: 'session_revoked' } }; } if (rawRow?.expires_at != null && rawRow.expires_at <= nowSeconds()) { - return { reauth: { reason: 'session_expired', auth_id } }; + return { reauth: { reason: 'session_expired' } }; } const session: SessionRow | null = rawRow; if (!session) return { invalid: true }; + // An origin app's uid is derived from the origin, so a deleted app's + // uid returns with the next app on that origin. A session older than + // the app was made for the earlier one. Checked before `touch()` so a + // rejected token doesn't slide the old session's expiry. + const notBefore = this.#appSessionsNotBefore(app); + const createdAt = Number(session.created_at); + if (notBefore !== null && createdAt > 0 && createdAt < notBefore) { + return { reauth: { reason: 'session_revoked' } }; + } + this.stores.session .touch({ uuid: session?.uuid, @@ -2213,15 +2228,6 @@ export class AuthService extends PuterService { }) .catch(() => {}); - // An origin app's uid is derived from the origin, so a deleted app's - // uid returns with the next app on that origin. A session older than - // the app was made for the earlier one. - const notBefore = this.#appSessionsNotBefore(app); - const createdAt = Number(session.created_at); - if (notBefore !== null && createdAt > 0 && createdAt < notBefore) { - return { reauth: { reason: 'session_revoked', auth_id } }; - } - const actor = this.#buildAppUnderUserActor(user, app, session); this.#applyHandlerDepth(actor, decoded); return { actor }; @@ -2248,21 +2254,22 @@ export class AuthService extends PuterService { const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return { invalid: true }; - const auth_id = this.#authIdFor(user as UserRow); let session: SessionRow | null = null; if (decoded.session_uid) { const rawRow = (await this.stores.session.getByUuidAny( decoded.session_uid, )) as SessionRow | null; + // No `auth_id` on an access token's reauth result: a reauth token + // is only ever minted for the user's own session/GUI token. if (rawRow?.revoked_at != null) { - return { reauth: { reason: 'session_revoked', auth_id } }; + return { reauth: { reason: 'session_revoked' } }; } if ( rawRow?.expires_at != null && rawRow.expires_at <= nowSeconds() ) { - return { reauth: { reason: 'session_expired', auth_id } }; + return { reauth: { reason: 'session_expired' } }; } if (!rawRow) return { invalid: true }; session = rawRow; diff --git a/src/backend/services/auth/types.ts b/src/backend/services/auth/types.ts index f23900fd0..f29053682 100644 --- a/src/backend/services/auth/types.ts +++ b/src/backend/services/auth/types.ts @@ -51,6 +51,8 @@ export interface SessionTokenPayload extends TokenPayloadBase { uuid: string; /** User uuid (plain). */ user_uid: string; + /** Set on a worker credential riding this token type; not a browser session. */ + worker?: boolean; } /**