Félix Malfait 116c04d8b2 Record and enforce per-user OAuth application authorizations (#23678)
Sits on `main` now that #23642 has merged. 18 files changed.

## Why

Application tokens are stateless JWTs. When a user completes an OAuth
`authorization_code` exchange, the server issues an access/refresh pair
carrying `userId` as a claim and stores nothing. So today:

- there is no record that a person ever authorized an app, hence nothing
to list on a settings screen
- there is no way for that person to take an app's access away. The
only revocation that exists is uninstalling the app, which is
workspace-wide and admin-only
- `/oauth/revoke` accepted a refresh token, logged it and did nothing,
because there was no state to change

`client_credentials` is unaffected: no user is involved and it returns
an access token with no refresh token.

## What

**`core."applicationAuthorization"`**, one row per (user, application),
unique on that pair so re-authorizing updates in place. Written at the
`authorization_code` exchange, before the token pair is issued, so a
refresh token is never handed out without the grant that makes it
redeemable.

A dedicated table rather than a new `AppTokenType`: this is a grant
keyed on identity, not a token keyed on a secret, and `appToken` is
already overloaded.

FKs to user, workspace, application and userWorkspace all cascade, which
covers hard deletes. Membership removal soft-deletes the `userWorkspace`
row, so that cascade does not fire and the grant outlives the
membership. The refresh path therefore rechecks membership on every
renewal rather than trusting the row's existence.

**Enforcement.** `refresh_token` checks the row when the token carries a
user, and returns `invalid_grant` if it is revoked. Revoking does not
kill live access tokens, so access ends within one access-token window
(`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than
instantly. The alternative is a DB read on every API request, which is
not worth it for a 30 minute tail; the UI should say so.

**RFC 7009 revocation now revokes.** Revoking a refresh token revokes
the authorization behind it. It also now checks the token was issued to
the client asking, which it never did before. That check did not matter
while revocation was a no-op; it does now.

**Introspection** reports a refresh token inactive once its
authorization is revoked. Access tokens keep reporting active until they
expire, because they genuinely still work.

**API:** `currentUserApplicationAuthorizations` and
`revokeApplicationAuthorization`, both behind `UserAuthGuard`. The
mutation scopes by `userId` inside the `UPDATE` rather than
read-then-write, so one user cannot revoke another's authorization
by guessing an id.

## Backwards compatibility

Refresh tokens already in the wild have no row. Rejecting them would
sign every live integration out on deploy, so the first refresh
backfills the grant that was always implied. A revoked authorization
keeps its row, so this never resurrects access someone turned off, and
the backfill is insert-only so it cannot overwrite a real consent. If
the user has since left the workspace, the refresh fails instead.

Those tokens carry no scope claim and no record of when consent was
given, so `scopes` and `lastAuthorizedAt` are nullable and left null on
a backfilled row. Null means "the original consent is not on
record" rather than a guess assembled from what the application
declares today; a real re-authorization fills both in. Revoking such a
token lays the row down before marking it, so the revocation sticks
instead of being undone by the next refresh.

## Not in this PR

The settings UI, following how #23643 shipped the sessions API and
#23645 the devices screen.

Introspection still reports a refresh token active once the membership
is gone. That matches access tokens, which genuinely keep working in
that case, so closing it belongs with the wider question of validating
membership on every application-token request.

## Testing

- 29 unit tests across the authorization service and the three OAuth
grant paths
- 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL
API: scopes as granted are recorded, revoking blocks the next refresh,
re-authorizing reinstates, a pre-record token backfills without
inventing a consent, a revoked pre-record token stays revoked, the
authorization is listed to the user who granted it, revoking from that
list stops the refresh token being redeemed, a repeated revocation
reports no-op, and another user can neither see nor revoke it
- the cross-user isolation and revoke-from-list tests are
mutation-checked: dropping the `userId` scoping from
`revokeAuthorizationById` fails only the isolation test, and disabling
the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list
test plus two pre-existing ones
- full `twenty-server` suite green
- instance command applied against a fresh `database:reset`,
table/index/FK shape verified against `information_schema`

Closes part of https://github.com/twentyhq/core-team-issues/issues/2747

---------

Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com>
2026-08-03 20:59:28 +02:00
2026-06-11 11:02:28 +02:00

Twenty logo

The #1 Open-Source CRM

Website · Documentation · Roadmap · Discord · Figma

Twenty banner


Why Twenty

Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.

Learn more about why we built Twenty


Installation

Cloud

The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.

Build an app

Scaffold a new app with the Twenty CLI:

npx create-twenty-app my-app

Define objects, fields, and views as code:

import { defineObject, FieldType } from 'twenty-sdk/define';

export default defineObject({
  nameSingular: 'deal',
  namePlural: 'deals',
  labelSingular: 'Deal',
  labelPlural: 'Deals',
  fields: [
    { name: 'name', label: 'Name', type: FieldType.TEXT },
    { name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
    { name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
  ],
});

Then ship it to your workspace:

npx twenty app:publish --private

See the app development guide for objects, views, agents, and logic functions.

Self-hosting

Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.



Everything you need

Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.

Want to go deeper? Read the User Guide for product walkthroughs, or the Documentation for developer reference.

Create your apps

Learn more about apps in doc

Stay on top with version control

Learn more about version control in doc

All the tools you need to build anything

Learn more about primitives in doc

Customize your layouts

Learn more about layouts in doc

AI agents and chats

Learn more about AI in doc

Plus all the tools of a good CRM

Learn more about CRM features in doc


Stack

Thanks

Greptile      Sentry      Crowdin

Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).

Join the Community

Star the repo · Discord · Feature requests · Releases · X · LinkedIn · Crowdin · Contribute

S
Description
The open alternative to Salesforce, designed for AI.
Readme AGPL-3.0 1.4 GiB
Languages
TypeScript 79.6%
MDX 17.3%
JavaScript 2.7%
Python 0.2%
SCSS 0.1%