mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-10 22:01:40 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user