fix: check-app reported every app as already authorized

The token grant writes `flag:app-is-authenticated` on the user->app row,
but the check asked for `service:<app_uid>:ii:flag:app-is-authenticated`.
Those never match, and the parent walk reduces the question to a bare
`service` — a root every user holds by default — so the check answered
true for any app_uid and handed back an app-under-user token for an app
the user had never opened.

Read the row the grant writes, through one shared constant so the two
cannot drift again, and mint against the resolved uid.

A cancelled account picker no longer leaves `popup_signin_consent` set:
with no relationship there is no token to flip it.
This commit is contained in:
Juan Castro
2026-09-21 16:13:53 -04:00
parent 4de1d1fb39
commit 259cb69fde
4 changed files with 46 additions and 21 deletions
@@ -2970,6 +2970,35 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(typeof body.token).toBe('string');
});
// What decides whether a cancelled "Sign in with Puter" still hands over a token.
it('check-app reports an app the user never opened as unauthorized, with no token', async () => {
const untouched = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `ca-${uuidv4()}`,
title: 'Never opened',
index_url: 'https://never-opened.example.test/index.html',
},
{ ownerUserId: user.id },
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(
makeReq({ app_uid: untouched.uid }, { actor }),
res,
),
);
expect(res.body).toEqual({
app_uid: untouched.uid,
authenticated: false,
});
});
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
// Create a brand-new actor with no app-related history so the
// permission scan can't cache-hit anything from prior tests, AND
@@ -3032,16 +3061,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
authenticated: boolean;
token?: string;
};
expect(body.app_uid).toBe(otherApp.uid);
expect(typeof body.authenticated).toBe('boolean');
// Whether `authenticated` is true depends on the user's full
// permission set (default group, owned-app implicits, etc.) — this
// test only pins the response *shape*, since the substantive case
// (`authenticated: true` after a paired get-user-app-token) is
// covered by the test above.
if (!body.authenticated) {
expect(body.token).toBeUndefined();
}
expect(body).toEqual({ app_uid: otherApp.uid, authenticated: false });
});
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
+14 -9
View File
@@ -109,6 +109,8 @@ const FINGERPRINT_MAX_LENGTH = 128;
// crafted request from turning a single grant call into a bulk write.
const MAX_PERMISSIONS_PER_REQUEST = 16;
const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
// -- Post-login route limits -----------------------------------------
//
@@ -3871,7 +3873,7 @@ export class AuthController extends PuterController {
this.services.permission.grantUserAppPermission(
req.actor!,
app_uid,
'flag:app-is-authenticated',
APP_AUTHENTICATED_FLAG,
{},
{},
);
@@ -3955,13 +3957,15 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
// Check if the app is authenticated for this user
const authenticated = await this.services.permission
.check(
req.actor!,
`service:${app_uid}:ii:flag:app-is-authenticated`,
)
.catch(() => false);
// The exact row, not a scan: a `service:`-shaped check falls open on the `service` root.
const app = await this.stores.app.resolveApp(app_uid);
const userId = req.actor!.user?.id;
const authenticated =
!!app?.id &&
!!userId &&
(await this.stores.permission
.hasUserAppPerm(userId, app.id, APP_AUTHENTICATED_FLAG)
.catch(() => false));
const result: {
app_uid: string;
@@ -3969,9 +3973,10 @@ export class AuthController extends PuterController {
token?: string;
} = { app_uid, authenticated };
if (authenticated) {
// The resolved uid: the token carries it as the app's identity.
result.token = await this.services.auth.getUserAppToken(
req.actor!,
app_uid,
app!.uid,
);
}
res.json(result);
+1 -1
View File
@@ -2573,7 +2573,7 @@ window.getUserAppToken = async function (origin) {
window.checkUserSiteRelationship = async function (origin) {
try {
const response = await fetch(`${window.api_origin }/auth/check-app `, {
const response = await fetch(`${window.api_origin }/auth/check-app`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
+1 -1
View File
@@ -1679,7 +1679,7 @@ window.initgui = async function (options) {
let response = await window.checkUserSiteRelationship(
window.openerOrigin,
);
window.userAppToken = response.token;
window.userAppToken = response?.token;
if (
!picked_a_user_for_sdk_login &&