fix(auth): issue reauth tokens only for browser sessions

A rejected token's 401 carried a signed reauth_token, which /signup turns
into a session on a temp account. Any revoked app or access token, a stale
token from a deleted app whose uid was reused, or a worker credential could
get one. Only a GUI/browser session's rejection now carries a reauth_token;
everything else still gets reauth_required without it. The stale-app check
also runs before the session is touched.
This commit is contained in:
Daniel Salazar committed 2026-10-04 05:18:36 -07:00
1 parent 5e34258da5
commit 4e2b3ed423
5 files changed
+135 -36

No files matched your search

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
});
/**
@@ -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<string, unknown>;
expect(body.code).toBe('reauth_required');
expect(body.reauth_token).toBeUndefined();
expect(body.auth_id).toBeUndefined();
});
});
+30 -20
View File
@@ -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 () => {
+23 -16
View File
@@ -2125,7 +2125,11 @@ export class AuthService extends PuterService {
): Promise<AuthResult> {
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<AuthResult> {
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;
+2
View File
@@ -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;
}
/**