refactor(oauth): OAuth server tables through repositories

OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository
replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE
and the oauth_clients join (its FK actually targets client_id, not the
row id the generated relation assumes) go through em.getKysely();
OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is
em.upsert on the (client, user) unique key. revokeAllForUser built now
for Task 5. Every row-out read is disableIdentityMap: true; inserts go
through em.insert.
This commit is contained in:
jubnl
2026-09-22 21:18:08 +02:00
parent 44cf31c958
commit d82395d0ae
13 changed files with 1985 additions and 137 deletions
+24
View File
@@ -1,5 +1,6 @@
import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core';
import { SqlitePlatform } from '@mikro-orm/sql';
import { sql, type Expression } from 'kysely';
/**
* The only place a repository may spell a database function.
@@ -99,3 +100,26 @@ export function lower(platform: Platform, ref: string): RawQueryFragment & symbo
if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`);
return unsupported(platform);
}
/**
* `CURRENT_TIMESTAMP`, for a query built through `em.getKysely()` — the
* escape hatch for a recursive CTE (`OauthTokensRepository.collectChainIds`)
* or a join the QueryBuilder's relation metadata cannot express correctly
* (`OauthTokensRepository.listActiveByUser`/`listAllActiveWithClientAndUser`
* — `oauth_tokens.client_id` references `oauth_clients.client_id`, not its
* primary key, and the generated entity's `client` relation has no
* `referencedColumnNames` override, so a QueryBuilder `.innerJoin('ot.client',
* …)` silently joins on the wrong column; see those methods' docstrings).
* Kysely has its own expression system, entirely separate from MikroORM's
* `raw()` — `currentTimestamp` above returns a value this file's OTHER
* consumers (the QueryBuilder, `nativeUpdate`) accept, but a Kysely query
* cannot use a `RawQueryFragment` as an operand. This is the one place a
* repository may reach for the Kysely spelling of the same literal, kept
* here rather than in a repository file so `no-restricted-syntax`'s ban on
* spelling raw SQL directly in `src/db/repositories/**` still holds — this
* file is exempt from that rule by design (it IS the one approved place).
*/
export function currentTimestampKysely(platform: Platform): Expression<string> {
if (platform instanceof SqlitePlatform) return sql<string>`CURRENT_TIMESTAMP`;
return unsupported(platform);
}
@@ -1,4 +1,322 @@
import type { OauthClients } from '../entities/OauthClients.entity';
import { OauthClients } from '../entities/OauthClients.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { EntityRepository } from '@mikro-orm/sql';
export class OauthClientsRepository extends EntityRepository<OauthClients> {}
/**
* A full `oauth_clients` row (`SELECT * FROM oauth_clients WHERE ...`), as
* the API emits it. `user_id` is the `persist(false)` mirror of the `user`
* relation (same pattern as `InviteTokens.created_by`/`createdByRef`) — a
* scalar column for `AssertRowKeys` purposes even though it is never written
* directly.
*/
export interface OauthClientRow {
id: string;
user_id: number | null;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string | null;
is_public: number; // 0 | 1
created_via: string;
allows_client_credentials: number; // 0 | 1
}
const _oauthClientRowKeys: AssertRowKeys<OauthClientRow, OauthClients> = true;
/**
* OA1 (`listOAuthClients`) / OA5 (`createOAuthClient`'s re-select) — every
* column except `client_secret_hash`, which the settings panel and the
* create-response never carry.
*/
export interface OauthClientPublicRow {
id: string;
user_id: number | null;
name: string;
client_id: string;
redirect_uris: string;
allowed_scopes: string;
created_at: string | null;
is_public: number;
created_via: string;
allows_client_credentials: number;
}
/** `OauthService.createOAuthClient`'s write (OA4) — every column the legacy INSERT names explicitly. */
export interface NewOauthClientRow {
id: string;
user_id: number | null;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string;
allowed_scopes: string;
is_public: number;
created_via: string;
allows_client_credentials: number;
}
/**
* OA6 (`rotateOAuthClientSecret`'s ownership+404 check, 3 columns) / OA9
* (`deleteOAuthClient`'s ownership+404 check, 2 columns) — one method, the
* superset projection (same economy as `McpTokensRepository.findBasic`
* merging TK4/TK9): OA9's caller reads only `id`/`client_id` and ignores
* `is_public`.
*/
export interface OauthClientOwnedRow {
id: string;
client_id: string;
is_public: number;
}
/**
* OA15 (`getSdkClient`) — the exact row the MCP SDK's `clientsStore` adapter
* (`oauth-sdk.provider.ts`'s `rowToInfo`) maps to `OAuthClientInformationFull`.
* Never carries `client_secret_hash` — a separate method from
* `findByClientIdFull`, not a service-side projection of it (Task 4 brief
* ruling: decide from how the result is used and record the decision) —
* the SDK adapter's scope should never see the hash column pass through it
* even transiently.
*/
export interface OauthClientSdkRow {
client_id: string;
name: string;
redirect_uris: string;
allowed_scopes: string;
is_public: number;
created_via: string;
}
/** OA21 (`refreshTokens`'s client-auth read) — client auth needs only these three columns. */
export interface OauthClientAuthRow {
client_id: string;
client_secret_hash: string;
is_public: number;
}
/**
* OAuth 2.1 client registrations (self-service + DCR + machine clients),
* Plan 3b Task 4, inventory §3 OA1-OA10/OA15/OA21/OA29/OA30. Every row-out
* read passes `disableIdentityMap: true` (program RULING, Task 1 review B1
* — see `Users.repository.ts`'s class-level docstring): a partial-field
* `find`/`findOne` merged into the request's identity map can go stale and
* get flushed back over an unrelated `nativeUpdate` in the same transaction;
* a `disableIdentityMap: true` read is always isolated and discarded.
* `client_secret_hash` is never hashed or compared here — that stays in
* `OauthService` (`hashToken`/`timingSafeEqualHex`).
*/
export class OauthClientsRepository extends EntityRepository<OauthClients> {
// ---------------------------------------------------------------------
// OA1 — self-service client list
// ---------------------------------------------------------------------
/**
* `SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes,
* created_at, is_public, created_via, allows_client_credentials FROM
* oauth_clients WHERE user_id = ? ORDER BY created_at DESC`
*
* `fields` names the RELATION property `'user'`, not the `persist(false)`
* mirror `'user_id'` — verified empirically (not from the `.d.ts`, per
* `McpTokensRepository.findBasic`'s docstring): naming the mirror directly
* leaves `row.user_id` `undefined` (it has no column of its own to
* select); the relation's `Ref` loads instead, and its `.id` reads
* synchronously without a second query.
*/
async listByUser(userId: number): Promise<OauthClientPublicRow[]> {
const rows = await this.find(
{ user: userId },
{
fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'],
orderBy: { created_at: 'desc' },
disableIdentityMap: true,
},
);
return rows.map((row) => ({
id: row.id as string,
user_id: row.user?.id ?? null,
name: row.name,
client_id: row.client_id,
redirect_uris: row.redirect_uris,
allowed_scopes: row.allowed_scopes,
created_at: row.created_at ?? null,
is_public: row.is_public,
created_via: row.created_via,
allows_client_credentials: row.allows_client_credentials,
}));
}
// ---------------------------------------------------------------------
// OA2 / OA3 — registration caps
// ---------------------------------------------------------------------
/** `SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?` (the 10-per-user cap). */
async countByUser(userId: number): Promise<number> {
return this.count({ user: userId });
}
/** `SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL` (the 500 anonymous-DCR cap). */
async countAnonymous(): Promise<number> {
return this.count({ user: null });
}
// ---------------------------------------------------------------------
// OA4 / OA5 — client registration
// ---------------------------------------------------------------------
/**
* `INSERT INTO oauth_clients (id, user_id, name, client_id,
* client_secret_hash, redirect_uris, allowed_scopes, is_public,
* created_via, allows_client_credentials) VALUES (...)` (OA4,
* **security-sensitive**: client registration), followed by `findPublicById`
* (OA5's re-select — same column list `listByUser` selects, `WHERE id = ?`).
*
* `em.insert` (program RULING), never `create()` + `persist().flush()`:
* `flush()` commits the request EntityManager's WHOLE unit of work, not
* just this row (`Users.repository.ts::insertUser`'s docstring). `id` is
* application-generated (`randomUUID()`, the service's job, never this
* repository's) — the legacy INSERT names it explicitly, so `em.insert`
* does too, never relying on a DB-generated default.
*/
async insertClient(row: NewOauthClientRow): Promise<OauthClientPublicRow> {
await this.getEntityManager().insert(OauthClients, {
id: row.id,
user: row.user_id,
name: row.name,
client_id: row.client_id,
client_secret_hash: row.client_secret_hash,
redirect_uris: row.redirect_uris,
allowed_scopes: row.allowed_scopes,
is_public: row.is_public,
created_via: row.created_via,
allows_client_credentials: row.allows_client_credentials,
});
const inserted = await this.findPublicById(row.id);
if (!inserted) {
throw new Error(`OauthClientsRepository.insertClient: row ${row.id} not found immediately after insert`);
}
return inserted;
}
/** OA1's exact column set, `WHERE id = ?` — `insertClient`'s re-select (OA5). */
async findPublicById(id: string): Promise<OauthClientPublicRow | null> {
const row = await this.findOne(
{ id },
{
fields: ['id', 'user', 'name', 'client_id', 'redirect_uris', 'allowed_scopes', 'created_at', 'is_public', 'created_via', 'allows_client_credentials'],
disableIdentityMap: true,
},
);
return row
? {
id: row.id as string,
user_id: row.user?.id ?? null,
name: row.name,
client_id: row.client_id,
redirect_uris: row.redirect_uris,
allowed_scopes: row.allowed_scopes,
created_at: row.created_at ?? null,
is_public: row.is_public,
created_via: row.created_via,
allows_client_credentials: row.allows_client_credentials,
}
: null;
}
// ---------------------------------------------------------------------
// OA6 / OA9 — ownership + 404 checks (secret rotation / delete)
// ---------------------------------------------------------------------
/**
* `SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND
* user_id = ?` (OA6, `rotateOAuthClientSecret`) and `SELECT id, client_id
* FROM oauth_clients WHERE id = ? AND user_id = ?` (OA9,
* `deleteOAuthClient`) — ONE method, the superset projection (OA9's two
* columns are a strict subset of OA6's three, same economy as
* `McpTokensRepository.findBasic`'s TK4/TK9 merge). 404-never-403: scoping
* by `user` in the WHERE, not a separate ownership check after an
* unscoped read.
*/
async findOwned(id: string, userId: number): Promise<OauthClientOwnedRow | null> {
const row = await this.findOne(
{ id, user: userId },
{ fields: ['id', 'client_id', 'is_public'], disableIdentityMap: true },
);
return row ? { id: row.id as string, client_id: row.client_id, is_public: row.is_public } : null;
}
// ---------------------------------------------------------------------
// OA7 — secret rotation write
// ---------------------------------------------------------------------
/** `UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?` — **security-sensitive**. */
async updateSecretHash(id: string, hash: string): Promise<void> {
await this.nativeUpdate({ id }, { client_secret_hash: hash });
}
// ---------------------------------------------------------------------
// OA10 — delete
// ---------------------------------------------------------------------
/** `DELETE FROM oauth_clients WHERE id = ?` */
async remove(id: string): Promise<void> {
await this.nativeDelete({ id });
}
// ---------------------------------------------------------------------
// OA15 — MCP SDK clientsStore adapter read
// ---------------------------------------------------------------------
/**
* `SELECT client_id, name, redirect_uris, allowed_scopes, is_public,
* created_via FROM oauth_clients WHERE client_id = ?` — see
* `OauthClientSdkRow`'s docstring for why this stays its own method
* rather than a service-side projection of `findByClientIdFull`.
*/
async findSdkProjection(clientId: string): Promise<OauthClientSdkRow | null> {
const row = await this.findOne(
{ client_id: clientId },
{ fields: ['client_id', 'name', 'redirect_uris', 'allowed_scopes', 'is_public', 'created_via'], disableIdentityMap: true },
);
return row
? { client_id: row.client_id, name: row.name, redirect_uris: row.redirect_uris, allowed_scopes: row.allowed_scopes, is_public: row.is_public, created_via: row.created_via }
: null;
}
// ---------------------------------------------------------------------
// OA21 — refresh-token-grant client authentication
// ---------------------------------------------------------------------
/**
* `SELECT client_id, client_secret_hash, is_public FROM oauth_clients
* WHERE client_id = ?` (`refreshTokens`'s client-auth read —
* **security-sensitive**). A distinct projection from OA15's — not merged
* with it (D4: mirror each statement's exact column set; a prior draft of
* this repository conflated the two, corrected per the Task 4 controller
* addendum).
*/
async findAuthRow(clientId: string): Promise<OauthClientAuthRow | null> {
const row = await this.findOne(
{ client_id: clientId },
{ fields: ['client_id', 'client_secret_hash', 'is_public'], disableIdentityMap: true },
);
return row ? { client_id: row.client_id, client_secret_hash: row.client_secret_hash, is_public: row.is_public } : null;
}
// ---------------------------------------------------------------------
// OA29 / OA30 — authorize-flow + token-endpoint client lookups
// ---------------------------------------------------------------------
/**
* `SELECT * FROM oauth_clients WHERE client_id = ?` — OA29
* (`validateAuthorizeRequest`) and OA30 (`authenticateClient`,
* **security-sensitive**: token-endpoint client auth), identical
* statement text, two call sites — ONE method, the full row (D4: both
* legacy statements are genuinely `SELECT *`, unlike OA15/OA21's narrow
* projections).
*/
async findByClientIdFull(clientId: string): Promise<OauthClientRow | null> {
const row = await this.findOne({ client_id: clientId }, { disableIdentityMap: true });
return row ? (toRow(row) as OauthClientRow) : null;
}
}
@@ -1,4 +1,84 @@
import type { OauthConsents } from '../entities/OauthConsents.entity';
import { OauthConsents } from '../entities/OauthConsents.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { EntityRepository } from '@mikro-orm/sql';
export class OauthConsentsRepository extends EntityRepository<OauthConsents> {}
/**
* Per-(client, user) OAuth consent grants, Plan 3b Task 4, inventory §3
* OA11/OA12. `oauth_consents` has a genuine composite unique index on
* `(client_id, user_id)` (`Migration20200101012500_oauth_2.ts`:
* `UNIQUE(client_id, user_id)`, mirrored on the entity as `uniques: [{
* properties: ['client', 'user'] }]`), so OA12's upsert decision (task
* brief ruling) is `em.upsert`, not a `nativeDelete` + `insert` pair — the
* natural key IS unique, no fallback needed. Scope union-not-narrow (M5:
* never narrow stored consent) stays in the SERVICE — this repository
* writes exactly the scopes JSON it is given.
*/
export class OauthConsentsRepository extends EntityRepository<OauthConsents> {
// ---------------------------------------------------------------------
// OA11 — consent-sufficiency read
// ---------------------------------------------------------------------
/**
* `SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?`
* `null` when no consent has been granted yet — the JSON parse of a
* present-but-empty `scopes` value ("consent granted, zero scopes",
* structurally distinct from "never consented") stays in the service,
* unchanged.
*
* Named `findScopes`, not `find` — `EntityRepository#find` already exists
* with an incompatible signature (`(where, options) => Promise<Entity[]>`);
* shadowing it would either fail to compile or silently hide the base
* method (same rule `upsertGrant` below follows for `#upsert`).
*/
async findScopes(clientId: string, userId: number): Promise<{ scopes: string } | null> {
const row = await this.findOne(
{ client: clientId, user: userId },
{ fields: ['scopes'], disableIdentityMap: true },
);
return row ? { scopes: row.scopes } : null;
}
// ---------------------------------------------------------------------
// OA12 — consent grant (upsert)
// ---------------------------------------------------------------------
/**
* `INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes,
* updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)` —
* **security-sensitive** (authorization grant). `em.upsert` with
* `onConflictFields: ['client', 'user']` (the RELATION properties, not
* their `persist(false)` mirrors — `Settings.repository.ts::upsertForUser`'s
* precedent) against the entity's own `uniques: [{ properties: ['client',
* 'user'] }]` — the natural key the migration's `UNIQUE(client_id,
* user_id)` declares, verified against the migration before choosing this
* over a delete+insert fallback (task brief ruling).
*
* `updated_at` is explicit in the payload, not left to the column's
* `defaultRaw('CURRENT_TIMESTAMP')`: a default only fires on INSERT, and
* `em.upsert`'s `'merge'` action updates ONLY the fields present in the
* payload on a conflict (verified against the installed 7.2.1 behaviour
* and the MikroORM docs: "values generated by property `onCreate` hooks…
* are automatically excluded from the merge clause") — the legacy
* statement's `INSERT OR REPLACE` always re-writes `updated_at` to
* `CURRENT_TIMESTAMP` regardless of whether the row existed (SQLite's
* `OR REPLACE` deletes-then-inserts, so there is no "update" branch to
* distinguish), so this repository stamps it explicitly on every call
* through `currentTimestamp(platform)`, matching that unconditional
* behaviour rather than relying on a default that would only cover the
* insert half.
*
* Named `upsertGrant`, not `upsert` — `EntityRepository#upsert` already
* exists with an incompatible signature (`(entityOrData, options) =>
* Promise<Entity>`); shadowing it would either fail to compile or silently
* hide the base method, the same collision `Users.repository.ts::insertUser`
* avoided with `create`.
*/
async upsertGrant(clientId: string, userId: number, scopes: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.getEntityManager().upsert(
OauthConsents,
{ client: clientId, user: userId, scopes, updated_at: currentTimestamp(platform) },
{ onConflictFields: ['client', 'user'], onConflictAction: 'merge' },
);
}
}
@@ -1,4 +1,606 @@
import type { OauthTokens } from '../entities/OauthTokens.entity';
import { OauthTokens } from '../entities/OauthTokens.entity';
import { type AssertRowKeys } from './_shared/rows';
import { columnRef, currentTimestamp, currentTimestampKysely } from '../dialect/sql-functions';
import { EntityRepository } from '@mikro-orm/sql';
export class OauthTokensRepository extends EntityRepository<OauthTokens> {}
/**
* The Kysely-side table shapes `em.getKysely()` needs for the two escape
* hatches in this repository (the recursive CTE and the `oauth_clients`
* join — see `collectChainIds`/`listActiveByUser`'s docstrings for why the
* QueryBuilder can't express either). Column-shaped, not entity-shaped: this
* is the raw driver's view of the tables, independent of MikroORM metadata.
*/
interface OauthTokensKyselyDB {
oauth_tokens: {
id: number;
client_id: string;
user_id: number;
scopes: string;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
created_at: string | null;
parent_token_id: number | null;
};
oauth_clients: {
client_id: string;
name: string;
};
users: {
id: number;
username: string;
};
}
/** `OauthService.issueTokens`/`issueClientCredentialsToken`'s write (OA13/OA14) — same column set, one method. */
export interface NewOauthTokenRow {
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string;
audience: string | null;
access_token_expires_at: string;
refresh_token_expires_at: string;
parent_token_id: number | null;
}
/** OA16 (`getUserByAccessToken`) — every MCP request's bearer check, **security-sensitive**. */
export interface OauthTokenWithUserRow {
scopes: string;
audience: string | null;
revoked_at: string | null;
access_token_expires_at: string;
user_id: number;
client_id: string;
username: string;
email: string;
role: string;
}
/** OA17 (`findChainRoot`'s upward-walk read). */
export interface OauthTokenParentRow {
id: number;
parent_token_id: number | null;
}
/** OA22 (`refreshTokens`'s refresh-token-hash read). */
export interface OauthTokenRefreshRow {
id: number;
client_id: string;
user_id: number;
scopes: string;
audience: string | null;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
/** OA24 (`revokeToken`'s user-id-for-session-revoke lookup). */
export interface OauthTokenUserIdRow {
user_id: number;
}
/** OA27 (`revokeSession`'s ownership+404 check). */
export interface OauthTokenOwnedRow {
id: number;
client_id: string;
}
/** OA32 (`adminRevokeOAuthSession`'s 404 check — unscoped, needs `user_id` for the session-revoke call). */
export interface OauthTokenAdminRow {
id: number;
user_id: number;
client_id: string;
}
/** OA26 (`listOAuthSessions`, the user's own "active sessions" panel). */
export interface OauthSessionRow {
id: number;
client_id: string;
client_name: string;
scopes: string;
access_token_expires_at: string;
refresh_token_expires_at: string;
created_at: string | null;
}
/** OA31 (`listAllOAuthSessions`, the admin panel) — OA26's row plus the token owner. */
export interface OauthSessionWithUserRow extends OauthSessionRow {
user_id: number;
username: string;
}
/**
* No site in this domain does `SELECT * FROM oauth_tokens` (unlike
* `oauth_clients`'s OA29/OA30) — every method below mirrors a narrower
* legacy projection. `OauthTokenScalarColumns` is not a return type of any
* method here; it exists only so `AssertRowKeys` still pins that every
* scalar column of the entity is accounted for SOMEWHERE across this file's
* narrower row interfaces, the same compile-time guarantee the other two
* repositories in this domain get from a genuine full-row type.
*/
interface OauthTokenScalarColumns {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string;
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
created_at: string | null;
parent_token_id: number | null;
audience: string | null;
}
const _oauthTokenScalarProbe: AssertRowKeys<OauthTokenScalarColumns, OauthTokens> = true;
/**
* OAuth 2.1 bearer/refresh tokens, Plan 3b Task 4, inventory §3
* OA8/OA13/OA14/OA16-OA20/OA22-OA28/OA31-OA33 — the busiest table in this
* domain. Every row-out read passes `disableIdentityMap: true` (program
* RULING, Task 1 review B1 — see `Users.repository.ts`'s class-level
* docstring). No method here hashes, compares or generates a token —
* `OauthService` does (`hashToken`/`generateAccessToken`/…), this
* repository only ever sees an already-computed hash string.
*
* **The `client` relation targets the wrong column.** The generator built
* `client: p.manyToOne(OauthClients).ref().name('client_id')` with no
* `referencedColumnNames` override, so MikroORM's QueryBuilder joins it as
* `ot.client_id = oc.id` (`OauthClients`'s primary key) — verified
* empirically (`qb.getFormattedQuery()`). The actual schema
* (`Migration20200101012500_oauth_2.ts`) has `oauth_tokens.client_id
* REFERENCES oauth_clients(client_id)`, the UNIQUE natural key, not `oc.id`
* — the two are independently generated UUIDs
* (`OauthService.createOAuthClient`'s `id`/`clientId` locals). A plain
* equality filter/write on the `client` property (`{ client: clientId }` in
* a `find`/`nativeUpdate`) is unaffected — it binds the given scalar
* straight into the `client_id` column, no join involved, verified
* empirically — but an ORM-level JOIN through this relation
* (`.innerJoin('ot.client', 'oc')`) silently returns the WRONG rows. This
* repository never joins that relation: `listActiveByUser`/
* `listAllActiveWithClientAndUser` (OA26/OA31, the two legacy statements
* that actually join `oauth_clients`) go through `em.getKysely()` instead,
* joining on the real `oc.client_id` column directly, entity metadata
* bypassed entirely. This is a pre-existing entity-generation defect
* (outside this task's scope to fix — `src/db/entities/**` is off limits
* here); flagged for Task 7 / the entity generator's own follow-up.
*/
export class OauthTokensRepository extends EntityRepository<OauthTokens> {
// ---------------------------------------------------------------------
// OA13 / OA14 — token issuance
// ---------------------------------------------------------------------
/**
* `INSERT INTO oauth_tokens (client_id, user_id, access_token_hash,
* refresh_token_hash, scopes, audience, access_token_expires_at,
* refresh_token_expires_at, parent_token_id) VALUES (...)` — OA13
* (`issueTokens`) and OA14 (`issueClientCredentialsToken`, whose
* `refresh_token_hash` is an unusable random placeholder and whose
* `refresh_token_expires_at` is already-expired — both decided by the
* caller, not this repository), same column list — ONE method.
* **Security-sensitive** (token mint). Neither legacy call site
* re-selects after the INSERT (the response is built from the already-
* computed raw values), so this returns nothing, matching exactly.
*
* Named `insertToken`, not `create` — `EntityRepository#create` already
* exists with an incompatible signature (a SYNCHRONOUS, non-persisting
* factory); shadowing it would fail to compile, the same collision
* `Users.repository.ts::insertUser` and `McpTokens.repository.ts
* ::insertToken` avoid.
*/
async insertToken(row: NewOauthTokenRow): Promise<void> {
await this.getEntityManager().insert(OauthTokens, {
client: row.client_id,
user: row.user_id,
access_token_hash: row.access_token_hash,
refresh_token_hash: row.refresh_token_hash,
scopes: row.scopes,
audience: row.audience,
access_token_expires_at: row.access_token_expires_at,
refresh_token_expires_at: row.refresh_token_expires_at,
parentToken: row.parent_token_id,
});
}
// ---------------------------------------------------------------------
// AU18 / AU46 (AuthService, cross-domain — Task 4 brief ruling: not one of
// the 33 numbered OA sites, but built NOW so it exists for Task 5)
// ---------------------------------------------------------------------
/**
* `UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id =
* ? AND revoked_at IS NULL` — `AuthService.changePassword` (AU18) and
* `resetPassword` (AU46), both inside the same transaction as the
* password write itself (**security-sensitive**: every OAuth session the
* user held stops working the moment their password changes). Built here
* because this table belongs to `nest/oauth`, per the inventory's §6
* cross-domain ruling (mirrors `McpTokensRepository.deleteAllForUser`'s
* precedent for the same AuthService sites on `mcp_tokens`) — Task 5 is
* the only caller. The legacy call sites wrap this statement in a
* `try/catch` ("oauth_tokens table may not exist in very old installs")
* — that tolerance is `AuthService`'s own concern, not reproduced here;
* this repository always assumes the table exists, the same as every
* other method in this file.
*/
async revokeAllForUser(userId: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ user: userId, revoked_at: null }, { revoked_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// OA8 — mass revoke on secret rotation
// ---------------------------------------------------------------------
/**
* `UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id =
* ? AND revoked_at IS NULL` — **security-sensitive** (mass revoke on
* secret rotation). The legacy statement spells `datetime('now')` rather
* than `CURRENT_TIMESTAMP` (every other revoke in this file uses the
* latter) — both route through `currentTimestamp(platform)` (Task 0's
* `SQLF-008` proves the two spellings are byte-identical on SQLite), one
* dialect path, not two.
*/
async revokeAllForClient(clientId: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ client: clientId, revoked_at: null }, { revoked_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// OA16 — MCP bearer-token verification
// ---------------------------------------------------------------------
/**
* `SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
* ot.user_id, ot.client_id, u.username, u.email, u.role FROM oauth_tokens
* ot JOIN users u ON ot.user_id = u.id WHERE ot.access_token_hash = ?` —
* **security-sensitive**, every MCP request's bearer check. `ot.user`
* (unlike `ot.client`) is a NORMAL relation — its column
* (`user_id`, default naming, no override) genuinely references
* `users.id`, verified empirically — so `.innerJoin('ot.user', 'u')` joins
* correctly and this stays on the QueryBuilder (D4's T5 tier), unlike
* OA26/OA31 below. `columnRef` for both FK scalars in the select list:
* `ot.user_id` because the same relation is also joined (the
* `McpTokensRepository.listAllWithUsername` finding — selecting a
* relation property while also joining it collapses into the join rather
* than emitting the FK column); `ot.client_id` for symmetry and because
* it is itself a `persist(false)` mirror with no column of its own to
* `.select()` by property name.
*/
async findByAccessTokenHashWithUser(hash: string): Promise<OauthTokenWithUserRow | null> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('ot')
.innerJoin('ot.user', 'u')
.select([
'ot.scopes', 'ot.audience', 'ot.revoked_at', 'ot.access_token_expires_at',
columnRef(platform, 'ot.user_id'), columnRef(platform, 'ot.client_id'),
'u.username', 'u.email', 'u.role',
])
.where({ 'ot.access_token_hash': hash })
.execute<OauthTokenWithUserRow | undefined>('get', false);
return row ?? null;
}
// ---------------------------------------------------------------------
// OA17 — rotation-chain upward walk (findChainRoot, looped in the service)
// ---------------------------------------------------------------------
/**
* `SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?` —
* **security-sensitive** (replay-detection chain walk). `findChainRoot`'s
* upward loop (≤ 100 iterations) stays in `OauthService`, calling this
* once per hop — NOT a second recursive CTE (Task 4 brief ruling): the
* loop already terminates on `parent_token_id === null`, and a CTE would
* need to walk UP the parent chain from an arbitrary starting id, which
* requires knowing the root in advance (the opposite direction from
* `collectChainIds`'s downward walk from a known root).
*
* `fields` names the RELATION property `'parentToken'`, not the
* `persist(false)` mirror `'parent_token_id'` (same rule as
* `McpTokensRepository.findBasic`'s `'user'`/`user_id'` finding): naming
* the mirror directly leaves it `undefined`.
*/
async findParent(id: number): Promise<OauthTokenParentRow | null> {
const row = await this.findOne({ id }, { fields: ['id', 'parentToken'], disableIdentityMap: true });
return row ? { id: row.id, parent_token_id: row.parentToken?.id ?? null } : null;
}
// ---------------------------------------------------------------------
// OA18 — recursive CTE (revokeChain)
// ---------------------------------------------------------------------
/**
* ```sql
* WITH RECURSIVE chain(id) AS (
* SELECT id FROM oauth_tokens WHERE id = ?
* UNION ALL
* SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
* )
* SELECT id FROM chain
* ```
*
* **Security-sensitive** — finds every token in a rotation chain to mass-
* revoke. The MikroORM QueryBuilder has no recursive-CTE support (D4's T6
* tier, the program's canonical `em.getKysely()` case, per the task
* brief); `getKysely()` reuses the ambient transaction connection inside
* `em.transactional()`/`uow.transactional()` (confirmed by its own doc
* comment and proven directly — see the repository test that revokes
* inside a transaction which then rolls back and asserts nothing was
* revoked), so D6's single-connection serialisation still holds.
*
* Proven equal to the legacy statement, run raw on the same seeded rows,
* on a 4-level chain plus a sibling branch that must NOT be collected
* (`OAUTHTOKREPO` CTE test) — see the task report for the exact generated
* SQL and both result sets side by side.
*/
async collectChainIds(rootId: number): Promise<number[]> {
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
.withRecursive('chain', (db) =>
db.selectFrom('oauth_tokens')
.select('id')
.where('id', '=', rootId)
.unionAll((eb) =>
eb.selectFrom('oauth_tokens as ot2')
.innerJoin('chain as c', 'c.id', 'ot2.parent_token_id')
.select('ot2.id'),
),
)
.selectFrom('chain')
.select('id')
.execute();
return rows.map((r) => r.id);
}
// ---------------------------------------------------------------------
// OA19 — chain-wide revoke
// ---------------------------------------------------------------------
/**
* `` UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN
* (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL `` —
* **security-sensitive**. The legacy hand-built a dynamic-length
* placeholder list; `nativeUpdate({ id: { $in: ids } })` replaces it
* outright (D4/D10's base case for exactly this shape — the task brief's
* "one statement, assert the count"). The legacy also guards `ids.length
* > 0` before running the statement at all (an empty `IN ()` is either a
* syntax error or a vacuous no-op depending on driver, never attempted) —
* reproduced here rather than trusting `nativeUpdate` to no-op safely on
* an empty `$in` array.
*/
async revokeByIds(ids: number[]): Promise<void> {
if (ids.length === 0) return;
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id: { $in: ids }, revoked_at: null }, { revoked_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// OA20 — concurrent-rotation detection
// ---------------------------------------------------------------------
/**
* `SELECT id FROM oauth_tokens WHERE parent_token_id = ? AND revoked_at
* IS NULL LIMIT 1` — **security-sensitive**, distinguishes concurrent-
* refresh from theft (`isConcurrentRotation`). `parentToken` is a NORMAL
* self-referential relation (references `oauth_tokens.id`, its own
* table's primary key — no `.name()` override, unlike `client`), so a
* plain equality filter is unaffected by this file's class-level finding.
*/
async findSuccessorAlive(parentId: number): Promise<{ id: number } | null> {
const row = await this.findOne({ parentToken: parentId, revoked_at: null }, { fields: ['id'], disableIdentityMap: true });
return row ? { id: row.id } : null;
}
// ---------------------------------------------------------------------
// OA22 — refresh-token-hash lookup
// ---------------------------------------------------------------------
/**
* `SELECT id, client_id, user_id, scopes, audience,
* refresh_token_expires_at, revoked_at, parent_token_id FROM oauth_tokens
* WHERE refresh_token_hash = ?` — **security-sensitive**. `fields` names
* every FK's RELATION property (`'client'`, `'user'`, `'parentToken'`),
* never the `persist(false)` mirrors — verified empirically that
* `row.client.id` reads back the RAW `client_id` COLUMN VALUE (not a
* dereference through the relation's — wrong — declared target): a `Ref`'s
* `.id` is simply the value stored in this entity's own FK column,
* synchronous, no second query, unaffected by what column the relation
* nominally references. Confirmed with a throwaway probe before writing
* this method (see the task report).
*/
async findByRefreshTokenHash(hash: string): Promise<OauthTokenRefreshRow | null> {
const row = await this.findOne(
{ refresh_token_hash: hash },
{ fields: ['id', 'client', 'user', 'scopes', 'audience', 'refresh_token_expires_at', 'revoked_at', 'parentToken'], disableIdentityMap: true },
);
return row
? {
id: row.id,
client_id: row.client.id,
user_id: row.user.id,
scopes: row.scopes,
audience: row.audience ?? null,
refresh_token_expires_at: row.refresh_token_expires_at,
revoked_at: row.revoked_at ?? null,
parent_token_id: row.parentToken?.id ?? null,
}
: null;
}
// ---------------------------------------------------------------------
// OA23 / OA28 / OA33 — revoke by id
// ---------------------------------------------------------------------
/**
* `UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?` —
* **security-sensitive**. THREE call sites share this exact statement:
* OA23 (`refreshTokens`'s rotation revoke-old-pair — stays NON-
* transactional with `issueTokens`, parity, see the deferred list in the
* task report), OA28 (`revokeSession`), OA33
* (`adminRevokeOAuthSession`). **Not** OA25 (`revokeToken`'s UPDATE half)
* — that statement has a genuinely different WHERE
* (`(access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?`,
* not `id = ?`); the inventory's summary prose conflated the two, the
* detailed per-site table (verified against `oauth.service.ts:536-540`)
* does not — see `revokeByAccessOrRefreshHashAndClient` below, and the
* task report's inventory-correction note.
*/
async revokeById(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { revoked_at: currentTimestamp(platform) });
}
// ---------------------------------------------------------------------
// OA24 / OA25 — revoke by presented token
// ---------------------------------------------------------------------
/**
* `SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR
* refresh_token_hash = ?) AND client_id = ?` (`revokeToken`'s user-id
* lookup, so the caller can also revoke the user's live MCP sessions) —
* **security-sensitive**.
*/
async findByAccessOrRefreshHashAndClient(hash: string, clientId: string): Promise<OauthTokenUserIdRow | null> {
const row = await this.findOne(
{ $or: [{ access_token_hash: hash }, { refresh_token_hash: hash }], client: clientId },
{ fields: ['user'], disableIdentityMap: true },
);
return row ? { user_id: row.user.id } : null;
}
/**
* `UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE
* (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?` —
* **security-sensitive**, the UPDATE half of `revokeToken`. Stays NON-
* transactional with `findByAccessOrRefreshHashAndClient` above (parity —
* see the task report's deferred list); a concurrent revoke/lookup race
* on the same token is the same legacy window this repository preserves,
* not widens or narrows.
*/
async revokeByAccessOrRefreshHashAndClient(hash: string, clientId: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ $or: [{ access_token_hash: hash }, { refresh_token_hash: hash }], client: clientId },
{ revoked_at: currentTimestamp(platform) },
);
}
// ---------------------------------------------------------------------
// OA26 — the user's own "active sessions" panel
// ---------------------------------------------------------------------
/**
* ```sql
* SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
* ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
* FROM oauth_tokens ot
* JOIN oauth_clients oc ON ot.client_id = oc.client_id
* WHERE ot.user_id = ? AND ot.revoked_at IS NULL
* AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
* ORDER BY ot.created_at DESC
* ```
*
* `em.getKysely()`, NOT the QueryBuilder (this class's docstring): the
* join is `ot.client_id = oc.client_id`, the real FK target per the
* migration, which `.innerJoin('ot.client', 'oc')` cannot express
* correctly (it would join `ot.client_id = oc.id` instead — silently
* wrong, not an error). `currentTimestampKysely` for the same
* `CURRENT_TIMESTAMP` literal `currentTimestamp` spells for the
* QueryBuilder/`nativeUpdate` — Kysely has its own expression system and
* cannot consume a MikroORM `RawQueryFragment` directly (see
* `sql-functions.ts`'s docstring on that helper). Verified empirically
* against a real seeded row before committing to this shape (see the
* task report).
*/
async listActiveByUser(userId: number): Promise<OauthSessionRow[]> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
.selectFrom('oauth_tokens as ot')
.innerJoin('oauth_clients as oc', 'oc.client_id', 'ot.client_id')
.select(['ot.id', 'ot.client_id', 'oc.name as client_name', 'ot.scopes', 'ot.access_token_expires_at', 'ot.refresh_token_expires_at', 'ot.created_at'])
.where('ot.user_id', '=', userId)
.where('ot.revoked_at', 'is', null)
.where('ot.refresh_token_expires_at', '>', currentTimestampKysely(platform))
.orderBy('ot.created_at', 'desc')
.execute();
return rows.map((row) => ({
id: row.id,
client_id: row.client_id,
client_name: row.client_name,
scopes: row.scopes,
access_token_expires_at: row.access_token_expires_at,
refresh_token_expires_at: row.refresh_token_expires_at,
created_at: row.created_at ?? null,
}));
}
// ---------------------------------------------------------------------
// OA27 — session-revoke ownership + 404 check
// ---------------------------------------------------------------------
/** `SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?` — 404-never-403, ownership scoped in the WHERE. */
async findOwnedById(id: number, userId: number): Promise<OauthTokenOwnedRow | null> {
const row = await this.findOne({ id, user: userId }, { fields: ['id', 'client'], disableIdentityMap: true });
return row ? { id: row.id, client_id: row.client.id } : null;
}
// ---------------------------------------------------------------------
// OA31 — admin "live sessions" panel
// ---------------------------------------------------------------------
/**
* ```sql
* SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
* ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
* FROM oauth_tokens ot
* JOIN oauth_clients oc ON ot.client_id = oc.client_id
* JOIN users u ON u.id = ot.user_id
* WHERE ot.revoked_at IS NULL AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
* ORDER BY ot.created_at DESC
* ```
*
* `em.getKysely()`, same reason as `listActiveByUser` (OA26) — the
* `oauth_clients` join. `users` joins correctly either way (its relation
* is normally mapped), joined here through Kysely too for one consistent
* query shape rather than mixing a QueryBuilder join into a Kysely query.
* Malformed `scopes` JSON degrading to `null` per-row (not a 500) stays
* in the SERVICE, unchanged — this repository returns the raw column
* text.
*/
async listAllActiveWithClientAndUser(): Promise<OauthSessionWithUserRow[]> {
const rows = await this.getEntityManager()
.getKysely<OauthTokensKyselyDB>()
.selectFrom('oauth_tokens as ot')
.innerJoin('oauth_clients as oc', 'oc.client_id', 'ot.client_id')
.innerJoin('users as u', 'u.id', 'ot.user_id')
.select(['ot.id', 'ot.client_id', 'oc.name as client_name', 'ot.user_id', 'u.username', 'ot.scopes', 'ot.access_token_expires_at', 'ot.refresh_token_expires_at', 'ot.created_at'])
.where('ot.revoked_at', 'is', null)
.where('ot.refresh_token_expires_at', '>', currentTimestampKysely(this.getEntityManager().getPlatform()))
.orderBy('ot.created_at', 'desc')
.execute();
return rows.map((row) => ({
id: row.id,
client_id: row.client_id,
client_name: row.client_name,
user_id: row.user_id,
username: row.username,
scopes: row.scopes,
access_token_expires_at: row.access_token_expires_at,
refresh_token_expires_at: row.refresh_token_expires_at,
created_at: row.created_at ?? null,
}));
}
// ---------------------------------------------------------------------
// OA32 — admin revoke's 404 check
// ---------------------------------------------------------------------
/** `SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?` — unscoped (admin), distinct from `findOwnedById` (OA27). */
async findById(id: number): Promise<OauthTokenAdminRow | null> {
const row = await this.findOne({ id }, { fields: ['id', 'user', 'client'], disableIdentityMap: true });
return row ? { id: row.id, user_id: row.user.id, client_id: row.client.id } : null;
}
}
+16 -1
View File
@@ -1,6 +1,7 @@
import { RateLimitModule } from '../common/rate-limit.module';
import { Module } from '@nestjs/common';
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import { OauthPublicController } from './oauth-public.controller';
@@ -11,6 +12,10 @@ import { AuditModule } from '../audit/audit.module';
import { AddonsModule } from '../addons/addons.module';
import { AddonsService } from '../addons/addons.service';
import { createMcpAddonGate } from '../addons/mcp-addon-gate';
import { OauthClients } from '../../db/entities/OauthClients.entity';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import { OauthConsents } from '../../db/entities/OauthConsents.entity';
import { Users } from '../../db/entities/Users.entity';
/**
* OAuth 2.1 server (MCP). Public token/userinfo/revoke endpoints + the SPA's
@@ -35,9 +40,19 @@ import { createMcpAddonGate } from '../addons/mcp-addon-gate';
*
* Exports OauthService for AdminController (admin OAuth-session panel) and the
* MCP transport's token verification.
*
* `MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents,
* Users])` (Plan 3b Task 4 deliverable): `Users` is registered for cross-
* module availability (inventory §6 — `OauthTokensRepository` needs to be
* importable from `AuthModule` too, Task 5's `revokeAllForUser`, the same
* shape `McpTokensRepository`/`InviteTokensRepository` already use across
* `nest/tokens`/`nest/auth`/`nest/oidc`) even though `OauthService` itself
* never injects `UsersRepository` directly — OA16/OA31 read `users` columns
* THROUGH `OauthTokensRepository`'s own join methods, not a separate
* `Users` call (Task 1's ruling: "do not add users methods" for this join).
*/
@Module({
imports: [RateLimitModule, AuditModule, AddonsModule],
imports: [RateLimitModule, AuditModule, AddonsModule, MikroOrmModule.forFeature([OauthClients, OauthTokens, OauthConsents, Users])],
controllers: [OauthPublicController, OauthApiController],
providers: [OauthService, TrekClientsStore, TrekOAuthProvider],
exports: [OauthService],
+94 -120
View File
@@ -1,5 +1,6 @@
import crypto, { randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ADDON_IDS } from '../../addons';
import { getMcpSafeUrl } from '../../app-config';
// Import from scopes/sessionManager directly, NOT the ../../mcp barrel: the
@@ -13,7 +14,13 @@ import { User } from '../../types';
import { AddonsService } from '../addons/addons.service';
import { AuditService } from '../audit/audit.service';
import { logWarn } from '../audit/audit-log.logger';
import { DatabaseService } from '../database/database.service';
import { toRowId } from '../common/row-id';
import { OauthClients } from '../../db/entities/OauthClients.entity';
import type { OauthClientRow, OauthClientsRepository } from '../../db/repositories/OauthClients.repository';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import type { OauthTokenRefreshRow, OauthTokensRepository } from '../../db/repositories/OauthTokens.repository';
import { OauthConsents } from '../../db/entities/OauthConsents.entity';
import type { OauthConsentsRepository } from '../../db/repositories/OauthConsents.repository';
import {
ACCESS_TOKEN_TTL_S,
CODE_CHALLENGE_RE,
@@ -27,12 +34,9 @@ import {
parseSqliteUtc,
redirectUriMatches,
timingSafeEqualHex,
type OAuthClientRow,
type OAuthTokenRow,
} from './oauth.helpers';
import { AUTH_CODE_TTL_MS, putPendingCode, takePendingCode, type PendingCode } from './oauth.pending-codes';
export type { OAuthClientRow, OAuthTokenRow } from './oauth.helpers';
export type { PendingCode } from './oauth.pending-codes';
export interface OAuthTokenInfo {
@@ -80,7 +84,9 @@ export interface ValidateAuthorizeResult {
@Injectable()
export class OauthService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(OauthClients) private readonly clients: OauthClientsRepository,
@InjectRepository(OauthTokens) private readonly tokens: OauthTokensRepository,
@InjectRepository(OauthConsents) private readonly consents: OauthConsentsRepository,
private readonly addons: AddonsService,
private readonly audit: AuditService,
) {}
@@ -93,10 +99,7 @@ export class OauthService {
// -------------------------------------------------------------------------
async listOAuthClients(userId: number): Promise<Record<string, unknown>[]> {
const rows = this.db.all<OAuthClientRow>(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC',
userId,
);
const rows = await this.clients.listByUser(userId);
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
@@ -136,11 +139,11 @@ export class OauthService {
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
if (userId !== null) {
const count = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?', userId)!.count;
const count = await this.clients.countByUser(userId);
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
} else {
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
const count = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL')!.count;
const count = await this.clients.countAnonymous();
if (count >= 500) return { error: 'server_error', status: 503 };
}
@@ -153,15 +156,18 @@ export class OauthService {
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
this.db.run(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0,
);
const row = this.db.get<OAuthClientRow>(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?',
const row = await this.clients.insertClient({
id,
)!;
user_id: userId,
name: name.trim(),
client_id: clientId,
client_secret_hash: secretHash,
redirect_uris: JSON.stringify(redirectUris),
allowed_scopes: JSON.stringify(allowedScopes),
is_public: isPublic ? 1 : 0,
created_via: createdVia,
allows_client_credentials: isMachineClient ? 1 : 0,
});
await this.audit.writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
@@ -188,17 +194,17 @@ export class OauthService {
clientRowId: string,
ip?: string | null,
): Promise<{ error?: string; status?: number; client_secret?: string }> {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
const row = await this.clients.findOwned(clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
this.db.run('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?', secretHash, clientRowId);
await this.clients.updateSecretHash(clientRowId, secretHash);
// Revoke all existing tokens for this client so old sessions are invalidated
this.db.run("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL", row.client_id);
await this.tokens.revokeAllForClient(row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
@@ -213,9 +219,9 @@ export class OauthService {
clientRowId: string,
ip?: string | null,
): Promise<{ error?: string; status?: number; success?: boolean }> {
const row = this.db.get<OAuthClientRow>('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?', clientRowId, userId);
const row = await this.clients.findOwned(clientRowId, userId);
if (!row) return { error: 'Client not found', status: 404 };
this.db.run('DELETE FROM oauth_clients WHERE id = ?', clientRowId);
await this.clients.remove(clientRowId);
await this.audit.writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
@@ -247,9 +253,7 @@ export class OauthService {
// -------------------------------------------------------------------------
async getConsent(clientId: string, userId: number): Promise<string[] | null> {
const row = this.db.get<{ scopes: string }>(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?', clientId, userId,
);
const row = await this.consents.findScopes(clientId, userId);
return row ? JSON.parse(row.scopes) : null;
}
@@ -257,10 +261,7 @@ export class OauthService {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = (await this.getConsent(clientId, userId)) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
this.db.run(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
clientId, userId, JSON.stringify(merged),
);
await this.consents.upsertGrant(clientId, userId, JSON.stringify(merged));
await this.audit.writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
@@ -294,11 +295,17 @@ export class OauthService {
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
this.db.run(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
await this.tokens.insertToken({
client_id: clientId,
user_id: userId,
access_token_hash: accessHash,
refresh_token_hash: refreshHash,
scopes: JSON.stringify(scopes),
audience,
access_token_expires_at: accessExpiry.toISOString(),
refresh_token_expires_at: refreshExpiry.toISOString(),
parent_token_id: parentTokenId,
});
return {
access_token: rawAccess,
@@ -334,11 +341,17 @@ export class OauthService {
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
this.db.run(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
await this.tokens.insertToken({
client_id: clientId,
user_id: userId,
access_token_hash: accessHash,
refresh_token_hash: placeholderHash,
scopes: JSON.stringify(scopes),
audience,
access_token_expires_at: accessExpiry.toISOString(),
refresh_token_expires_at: now.toISOString(),
parent_token_id: null,
});
return {
access_token: rawAccess,
@@ -361,22 +374,13 @@ export class OauthService {
allowed_scopes: string;
is_public: number;
created_via: string;
} | undefined> {
return this.db.get(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?',
clientId,
);
} | null> {
return this.clients.findSdkProjection(clientId);
}
async getUserByAccessToken(rawToken: string): Promise<OAuthTokenInfo | null> {
const hash = hashToken(rawToken);
const row = this.db.get<OAuthTokenRow & { username: string; email: string; role: string }>(`
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`, hash);
const row = await this.tokens.findByAccessTokenHashWithUser(hash);
if (!row) return null;
if (row.revoked_at) return null;
@@ -398,7 +402,7 @@ export class OauthService {
private async findChainRoot(tokenId: number): Promise<number> {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = this.db.get<{ id: number; parent_token_id: number | null }>('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?', current);
const row = await this.tokens.findParent(current);
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
@@ -407,21 +411,8 @@ export class OauthService {
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
private async revokeChain(rootId: number): Promise<number[]> {
const rows = this.db.all<{ id: number }>(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`, rootId);
const ids = rows.map(r => r.id);
if (ids.length > 0) {
this.db.run(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`,
...ids,
);
}
const ids = await this.tokens.collectChainIds(rootId);
await this.tokens.revokeByIds(ids);
return ids;
}
@@ -435,14 +426,11 @@ export class OauthService {
* explicit revoke leaves no live child, and a chain revoked after a real replay
* has every child revoked with it, so neither can slip through here.
*/
private async isConcurrentRotation(row: OAuthTokenRow): Promise<boolean> {
private async isConcurrentRotation(row: OauthTokenRefreshRow): Promise<boolean> {
const revokedAt = parseSqliteUtc(row.revoked_at);
if (!revokedAt) return false;
if (Date.now() - revokedAt.getTime() > REFRESH_ROTATION_GRACE_MS) return false;
const successor = this.db.get<{ id: number }>(
'SELECT id FROM oauth_tokens WHERE parent_token_id = ? AND revoked_at IS NULL LIMIT 1',
row.id,
);
const successor = await this.tokens.findSuccessorAlive(row.id);
return !!successor;
}
@@ -452,7 +440,7 @@ export class OauthService {
clientSecret: string | undefined,
ip?: string | null,
): Promise<{ error?: string; status?: number; tokens?: Awaited<ReturnType<OauthService['issueTokens']>> }> {
const client = this.db.get<OAuthClientRow>('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId);
const client = await this.clients.findAuthRow(clientId);
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
@@ -461,10 +449,7 @@ export class OauthService {
}
const hash = hashToken(rawRefreshToken);
const row = this.db.get<OAuthTokenRow>(`
SELECT id, client_id, user_id, scopes, audience, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`, hash);
const row = await this.tokens.findByRefreshTokenHash(hash);
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
@@ -512,7 +497,13 @@ export class OauthService {
// already re-validates session.userId/clientId against the new token on every
// request. Killing the session on every routine hourly refresh broke long-lived
// MCP connections (#1475).
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
//
// Stays NON-transactional with the issueTokens() call below — legacy
// parity (Task 4 brief ruling, §9.6 of the inventory): revoke-old then
// issue-new are two separate statements, not one transaction. A crash
// between the two would leave the old token revoked with no successor —
// the same window the legacy code always had.
await this.tokens.revokeById(row.id);
const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
@@ -527,17 +518,14 @@ export class OauthService {
async revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): Promise<void> {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = this.db.get<{ user_id: number }>(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?',
hash, hash, clientId,
);
// Get the user_id for the token so we can revoke its MCP sessions.
//
// Stays NON-transactional with the UPDATE below — legacy parity (Task 4
// brief ruling, §9.6): a SELECT-then-UPDATE with no transaction between
// them, same window the legacy code always had.
const row = await this.tokens.findByAccessOrRefreshHashAndClient(hash, clientId);
this.db.run(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`, hash, hash, clientId);
await this.tokens.revokeByAccessOrRefreshHashAndClient(hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
@@ -551,17 +539,8 @@ export class OauthService {
// -------------------------------------------------------------------------
async listOAuthSessions(userId: number): Promise<Record<string, unknown>[]> {
const rows = this.db.all<Record<string, unknown>>(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`, userId);
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
const rows = await this.tokens.listActiveByUser(userId);
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
}
async revokeSession(
@@ -569,10 +548,13 @@ export class OauthService {
sessionId: number,
ip?: string | null,
): Promise<{ error?: string; status?: number; success?: boolean }> {
const row = this.db.get<{ id: number; client_id: string }>('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?', sessionId, userId);
const id = toRowId(sessionId);
if (id === null) return { error: 'Session not found', status: 404 };
const row = await this.tokens.findOwnedById(id, userId);
if (!row) return { error: 'Session not found', status: 404 };
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', sessionId);
await this.tokens.revokeById(id);
revokeUserSessionsForClient(userId, row.client_id);
@@ -610,7 +592,7 @@ export class OauthService {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = this.db.get<OAuthClientRow>('SELECT * FROM oauth_clients WHERE client_id = ?', params.client_id);
const client = await this.clients.findByClientIdFull(params.client_id);
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
@@ -692,8 +674,8 @@ export class OauthService {
// Client authentication (for token endpoint)
// -------------------------------------------------------------------------
async authenticateClient(clientId: string, clientSecret: string | undefined): Promise<OAuthClientRow | null> {
const client = this.db.get<OAuthClientRow>('SELECT * FROM oauth_clients WHERE client_id = ?', clientId);
async authenticateClient(clientId: string, clientSecret: string | undefined): Promise<OauthClientRow | null> {
const client = await this.clients.findByClientIdFull(clientId);
if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
@@ -712,16 +694,7 @@ export class OauthService {
// ---------------------------------------------------------------------------
async listAllOAuthSessions() {
const rows = this.db.all<Record<string, unknown> & { scopes: string }>(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
JOIN users u ON u.id = ot.user_id
WHERE ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`);
const rows = await this.tokens.listAllActiveWithClientAndUser();
// One malformed row must not 500 the whole admin OAuth-sessions panel.
return rows.map((r) => {
let scopes: unknown;
@@ -735,11 +708,12 @@ export class OauthService {
}
async adminRevokeOAuthSession(id: string) {
const row = this.db.get<{ id: number; user_id: number; client_id: string }>(
'SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?', id,
);
const tokenId = toRowId(id);
if (tokenId === null) return { error: 'Session not found', status: 404 };
const row = await this.tokens.findById(tokenId);
if (!row) return { error: 'Session not found', status: 404 };
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', id);
await this.tokens.revokeById(tokenId);
revokeUserSessionsForClient(row.user_id, row.client_id);
return {};
}
+10
View File
@@ -813,6 +813,16 @@ describe('OAuth sessions', () => {
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
});
it('ADMIN-026B — DELETE /admin/oauth-sessions/abc (non-numeric id) returns the legacy 404, not a 500 (Plan 3b Task 4 controller addendum, per Task 2 review F1)', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/admin/oauth-sessions/abc')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Session not found' });
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -41,18 +41,19 @@ import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { createTestAddonsService } from '../helpers/test-addons';
import { AuditService } from '../../src/nest/audit/audit.service';
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
// The consent controller writes pending codes through the container instance;
// the SDK-mounted authorize path reads them back. The map is module-scoped in
// oauth.pending-codes.ts, so a hand-built service instance shares it — the
// same single-instance property the full auth-code loop below proves end-to-end.
const oauthDbs = new DatabaseService(testDb);
let containerSideOauth: OauthService;
let nestApp: INestApplication;
@@ -85,7 +86,7 @@ beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
t = await createTestOrm(testDb);
containerSideOauth = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog), t.repo(Users)));
containerSideOauth = new OauthService(t.repo(OauthClients), t.repo(OauthTokens), t.repo(OauthConsents), await createTestAddonsService(testDb), new AuditService(t.repo(AuditLog), t.repo(Users)));
});
beforeEach(() => {
+13 -3
View File
@@ -38,19 +38,20 @@ import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { ALL_SCOPES } from '../../src/mcp/scopes';
import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { createTestAddonsService } from '../helpers/test-addons';
import { AuditService } from '../../src/nest/audit/audit.service';
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
// In production the consent controller writes pending codes through the
// container instance and the SDK routes read them back through the same
// injected singleton. These tests write codes the way the consent controller
// does: through a service instance. The pending-code map is module-scoped in
// oauth.pending-codes.ts, so the routes under test see every code written here.
const oauthDbs = new DatabaseService(testDb);
let containerSideOauth: OauthService;
// Arrow wrappers rather than `.bind`: with `strictBindCallApply: false` a bound
// alias is typed `any`, which hides a missing `await` on the now-async methods.
@@ -83,7 +84,7 @@ beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
t = await createTestOrm(testDb);
containerSideOauth = new OauthService(oauthDbs, await createTestAddonsService(testDb, oauthDbs), new AuditService(t.repo(AuditLog), t.repo(Users)));
containerSideOauth = new OauthService(t.repo(OauthClients), t.repo(OauthTokens), t.repo(OauthConsents), await createTestAddonsService(testDb), new AuditService(t.repo(AuditLog), t.repo(Users)));
});
beforeEach(() => {
@@ -1136,6 +1137,15 @@ describe('Sessions — /api/oauth/sessions', () => {
expect(res.status).toBe(404);
});
it('OAUTH-043B — DELETE /sessions/abc (non-numeric id) returns the legacy 404, not a 500 (Plan 3b Task 4 controller addendum, per Task 2 review F1)', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.delete('/api/oauth/sessions/abc')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Session not found' });
});
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
setMcpEnabled(false);
const { user } = createUser(testDb);
@@ -0,0 +1,233 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { OauthClients } from '../../../../src/db/entities/OauthClients.entity';
import type { OauthClientsRepository } from '../../../../src/db/repositories/OauthClients.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let clients: OauthClientsRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
clients = t.repo(OauthClients);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); testDb.exec('DELETE FROM oauth_clients'); });
afterAll(async () => { await t.close(); testDb.close(); });
function seedClient(
overrides: Partial<{ id: string; userId: number | null; name: string; clientId: string; secretHash: string; redirectUris: string; allowedScopes: string; isPublic: number; createdVia: string; allowsClientCredentials: number }> = {},
): { id: string; userId: number | null; clientId: string } {
const id = overrides.id ?? `row-${Math.random().toString(36).slice(2)}`;
const clientId = overrides.clientId ?? `proto-${Math.random().toString(36).slice(2)}`;
testDb.prepare(
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
overrides.userId ?? null,
overrides.name ?? 'Test Client',
clientId,
overrides.secretHash ?? 'hash-value',
overrides.redirectUris ?? '["https://example.com/cb"]',
overrides.allowedScopes ?? '["trips:read"]',
overrides.isPublic ?? 0,
overrides.createdVia ?? 'settings_ui',
overrides.allowsClientCredentials ?? 0,
);
return { id, userId: overrides.userId ?? null, clientId };
}
describe('OauthClientsRepository', () => {
describe('listByUser (OA1)', () => {
it('OAUTHCLIENTREPO-001: returns the caller\'s clients only, newest first, without the secret hash', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient({ userId: user.id, name: 'first', createdVia: 'settings_ui' });
testDb.exec("UPDATE oauth_clients SET created_at = '2020-01-01 00:00:00' WHERE name = 'first'");
seedClient({ userId: user.id, name: 'second' });
testDb.exec("UPDATE oauth_clients SET created_at = '2021-01-01 00:00:00' WHERE name = 'second'");
seedClient({ userId: other.id, name: 'not-mine' });
const rows = await clients.listByUser(user.id);
expect(rows.map((r) => r.name)).toEqual(['second', 'first']);
expect(rows[0]).not.toHaveProperty('client_secret_hash');
expect(rows[0].user_id).toBe(user.id);
});
it('OAUTHCLIENTREPO-002: empty for a user with no clients', async () => {
const { user } = createUser(testDb);
expect(await clients.listByUser(user.id)).toEqual([]);
});
});
describe('countByUser / countAnonymous (OA2/OA3)', () => {
it('OAUTHCLIENTREPO-003: countByUser only counts the given user\'s rows', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient({ userId: user.id });
seedClient({ userId: user.id });
seedClient({ userId: other.id });
expect(await clients.countByUser(user.id)).toBe(2);
expect(await clients.countByUser(other.id)).toBe(1);
});
it('OAUTHCLIENTREPO-004: countAnonymous counts only NULL-user rows', async () => {
const { user } = createUser(testDb);
seedClient({ userId: user.id });
seedClient({ userId: null });
seedClient({ userId: null });
expect(await clients.countAnonymous()).toBe(2);
});
});
describe('insertClient / findPublicById (OA4/OA5)', () => {
it('OAUTHCLIENTREPO-005: writes exactly the given columns and the re-select matches', async () => {
const { user } = createUser(testDb);
const row = await clients.insertClient({
id: 'client-row-1',
user_id: user.id,
name: 'My Client',
client_id: 'proto-abc',
client_secret_hash: 'secrethash',
redirect_uris: '["https://a.example.com/cb"]',
allowed_scopes: '["trips:read","trips:write"]',
is_public: 0,
created_via: 'settings_ui',
allows_client_credentials: 0,
});
expect(row).toMatchObject({
id: 'client-row-1',
user_id: user.id,
name: 'My Client',
client_id: 'proto-abc',
redirect_uris: '["https://a.example.com/cb"]',
allowed_scopes: '["trips:read","trips:write"]',
is_public: 0,
created_via: 'settings_ui',
allows_client_credentials: 0,
});
const raw = testDb.prepare('SELECT * FROM oauth_clients WHERE id = ?').get('client-row-1') as Record<string, unknown>;
expect(raw.client_secret_hash).toBe('secrethash');
});
it('OAUTHCLIENTREPO-006: insertClient writes a NULL user_id for anonymous DCR clients', async () => {
const row = await clients.insertClient({
id: 'client-row-2',
user_id: null,
name: 'DCR Client',
client_id: 'proto-dcr',
client_secret_hash: 'x',
redirect_uris: '[]',
allowed_scopes: '[]',
is_public: 1,
created_via: 'dcr',
allows_client_credentials: 0,
});
expect(row.user_id).toBeNull();
});
});
describe('findOwned (OA6/OA9)', () => {
it('OAUTHCLIENTREPO-007: matches id+user, returns the superset projection', async () => {
const { user } = createUser(testDb);
const { id, clientId } = seedClient({ userId: user.id, isPublic: 1 });
const row = await clients.findOwned(id, user.id);
expect(row).toEqual({ id, client_id: clientId, is_public: 1 });
});
it('OAUTHCLIENTREPO-008: 404-shape null for another user\'s client (never 403)', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const { id } = seedClient({ userId: other.id });
expect(await clients.findOwned(id, user.id)).toBeNull();
});
});
describe('updateSecretHash (OA7)', () => {
it('OAUTHCLIENTREPO-009: writes the new hash, nothing else', async () => {
const { id } = seedClient({ secretHash: 'old' });
await clients.updateSecretHash(id, 'new-hash');
const row = testDb.prepare('SELECT client_secret_hash FROM oauth_clients WHERE id = ?').get(id) as { client_secret_hash: string };
expect(row.client_secret_hash).toBe('new-hash');
});
});
describe('remove (OA10)', () => {
it('OAUTHCLIENTREPO-010: deletes the row', async () => {
const { id } = seedClient();
await clients.remove(id);
expect(testDb.prepare('SELECT id FROM oauth_clients WHERE id = ?').get(id)).toBeUndefined();
});
});
describe('findSdkProjection (OA15)', () => {
it('OAUTHCLIENTREPO-011: never carries client_secret_hash', async () => {
const { clientId } = seedClient({ secretHash: 'super-secret', name: 'SDK Client' });
const row = await clients.findSdkProjection(clientId);
expect(row).toEqual({
client_id: clientId,
name: 'SDK Client',
redirect_uris: '["https://example.com/cb"]',
allowed_scopes: '["trips:read"]',
is_public: 0,
created_via: 'settings_ui',
});
expect(row).not.toHaveProperty('client_secret_hash');
});
it('OAUTHCLIENTREPO-012: null for unknown client_id', async () => {
expect(await clients.findSdkProjection('nonexistent')).toBeNull();
});
});
describe('findAuthRow (OA21)', () => {
it('OAUTHCLIENTREPO-013: exactly the three auth columns, a distinct projection from OA15', async () => {
const { clientId } = seedClient({ secretHash: 'auth-secret', isPublic: 0 });
const row = await clients.findAuthRow(clientId);
expect(row).toEqual({ client_id: clientId, client_secret_hash: 'auth-secret', is_public: 0 });
});
});
describe('findByClientIdFull (OA29/OA30)', () => {
it('OAUTHCLIENTREPO-014: the full row, including the secret hash', async () => {
const { user } = createUser(testDb);
const { clientId } = seedClient({ userId: user.id, secretHash: 'full-row-secret' });
const row = await clients.findByClientIdFull(clientId);
expect(row?.client_secret_hash).toBe('full-row-secret');
expect(row?.user_id).toBe(user.id);
});
it('OAUTHCLIENTREPO-015: null for unknown client_id', async () => {
expect(await clients.findByClientIdFull('nope')).toBeNull();
});
});
describe('identity-map write-back regression (program RULING, D-shape)', () => {
it('OAUTHCLIENTREPO-016: listByUser then findAuthRow then updateSecretHash inside uow.transactional — the write survives', async () => {
const { user } = createUser(testDb);
const { id, clientId } = seedClient({ userId: user.id, secretHash: 'original', name: 'kept-name' });
await withRequestContext(t.orm, async () => {
// Projection A
await clients.listByUser(user.id);
// Projection B — a different field set on the same row
await clients.findAuthRow(clientId);
// The intended write, inside the request's own transaction.
await uow.transactional(async () => {
await clients.updateSecretHash(id, 'rotated');
});
});
const row = testDb.prepare('SELECT client_secret_hash, name FROM oauth_clients WHERE id = ?').get(id) as { client_secret_hash: string; name: string };
expect(row.client_secret_hash).toBe('rotated'); // the nativeUpdate must stick
expect(row.name).toBe('kept-name'); // untouched by either read
});
});
});
@@ -0,0 +1,112 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { OauthConsents } from '../../../../src/db/entities/OauthConsents.entity';
import type { OauthConsentsRepository } from '../../../../src/db/repositories/OauthConsents.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let consents: OauthConsentsRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
consents = t.repo(OauthConsents);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); testDb.exec('DELETE FROM oauth_consents'); testDb.exec('DELETE FROM oauth_clients'); });
afterAll(async () => { await t.close(); testDb.close(); });
function seedClient(userId: number, clientId: string): void {
testDb.prepare(
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`,
).run(`row-${clientId}`, userId, 'C', clientId, 'hash');
}
describe('OauthConsentsRepository', () => {
describe('findScopes (OA11)', () => {
it('OAUTHCONSENTREPO-001: null when no consent has been granted', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-1');
expect(await consents.findScopes('proto-1', user.id)).toBeNull();
});
it('OAUTHCONSENTREPO-002: returns the stored scopes JSON text', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-2');
testDb.prepare(`INSERT INTO oauth_consents (client_id, user_id, scopes) VALUES (?, ?, ?)`).run('proto-2', user.id, '["trips:read"]');
expect(await consents.findScopes('proto-2', user.id)).toEqual({ scopes: '["trips:read"]' });
});
});
describe('upsertGrant (OA12)', () => {
it('OAUTHCONSENTREPO-003: inserts a new row exactly as the legacy INSERT would', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-3');
await consents.upsertGrant('proto-3', user.id, '["a"]');
const row = testDb.prepare('SELECT client_id, user_id, scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-3', user.id);
expect(row).toEqual({ client_id: 'proto-3', user_id: user.id, scopes: '["a"]' });
});
it('OAUTHCONSENTREPO-004: on an existing (client, user) it replaces the row in place — no duplicate, matching ON CONFLICT(client_id, user_id) DO UPDATE', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-4');
await consents.upsertGrant('proto-4', user.id, '["a"]');
await consents.upsertGrant('proto-4', user.id, '["a","b"]');
const rows = testDb.prepare('SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').all('proto-4', user.id);
expect(rows).toHaveLength(1);
expect((rows[0] as { scopes: string }).scopes).toBe('["a","b"]');
});
it('OAUTHCONSENTREPO-005: updated_at is bumped on every call, insert or merge alike (unconditional, matching INSERT OR REPLACE)', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-5');
await consents.upsertGrant('proto-5', user.id, '["a"]');
const first = testDb.prepare('SELECT updated_at FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-5', user.id) as { updated_at: string };
testDb.exec(`UPDATE oauth_consents SET updated_at = '2000-01-01 00:00:00' WHERE client_id = 'proto-5'`);
await consents.upsertGrant('proto-5', user.id, '["a","b"]');
const second = testDb.prepare('SELECT updated_at FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-5', user.id) as { updated_at: string };
expect(second.updated_at).not.toBe('2000-01-01 00:00:00');
expect(first.updated_at).toBeTruthy();
});
it('OAUTHCONSENTREPO-006: the same client scoped by different users stores two separate rows, not a collision', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
seedClient(a.id, 'proto-6');
await consents.upsertGrant('proto-6', a.id, '["a"]');
await consents.upsertGrant('proto-6', b.id, '["b"]');
expect(await consents.findScopes('proto-6', a.id)).toEqual({ scopes: '["a"]' });
expect(await consents.findScopes('proto-6', b.id)).toEqual({ scopes: '["b"]' });
});
});
describe('identity-map isolation (program RULING, D-shape)', () => {
it('OAUTHCONSENTREPO-007: findScopes leaves no managed entity behind — a raw write to the same row on the same transaction connection survives the transaction\'s closing flush', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-7');
testDb.prepare(`INSERT INTO oauth_consents (client_id, user_id, scopes) VALUES (?, ?, ?)`).run('proto-7', user.id, '["a"]');
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
// Projection: the only read shape this repository has.
await consents.findScopes('proto-7', user.id);
// A write bypassing the ORM entirely, on the SAME single SQLite
// connection the open transaction holds (D3/D6) — the shape any
// nativeUpdate write from elsewhere in the same request takes. If
// findScopes had left a managed entity in the identity map (the
// B1 mechanism), the transaction's closing flush would try to
// write the READ snapshot back over this change.
testDb.prepare('UPDATE oauth_consents SET scopes = ? WHERE client_id = ? AND user_id = ?').run('["a","b"]', 'proto-7', user.id);
});
});
const row = testDb.prepare('SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?').get('proto-7', user.id) as { scopes: string };
expect(row.scopes).toBe('["a","b"]');
});
});
});
@@ -0,0 +1,459 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { OauthTokens } from '../../../../src/db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../../../src/db/repositories/OauthTokens.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let tokens: OauthTokensRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
tokens = t.repo(OauthTokens);
uow = new UnitOfWork(t.em);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
testDb.exec('DELETE FROM oauth_tokens');
testDb.exec('DELETE FROM oauth_consents');
testDb.exec('DELETE FROM oauth_clients');
});
afterAll(async () => { await t.close(); testDb.close(); });
function seedClient(userId: number, clientId: string, name = 'Client'): void {
testDb.prepare(
`INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes) VALUES (?, ?, ?, ?, ?, '[]', '[]')`,
).run(`row-${clientId}`, userId, name, clientId, 'hash');
}
function seedToken(
overrides: Partial<{
id: number; clientId: string; userId: number; accessHash: string; refreshHash: string; scopes: string;
audience: string | null; accessExpiresAt: string; refreshExpiresAt: string; revokedAt: string | null; parentId: number | null;
}> = {},
): number {
const info = testDb.prepare(
`INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, revoked_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
overrides.clientId,
overrides.userId,
overrides.accessHash ?? `acc-${Math.random().toString(36).slice(2)}`,
overrides.refreshHash ?? `ref-${Math.random().toString(36).slice(2)}`,
overrides.scopes ?? '["trips:read"]',
overrides.audience ?? null,
overrides.accessExpiresAt ?? new Date(Date.now() + 3600_000).toISOString(),
overrides.refreshExpiresAt ?? new Date(Date.now() + 30 * 24 * 3600_000).toISOString(),
overrides.revokedAt ?? null,
overrides.parentId ?? null,
);
const id = Number(info.lastInsertRowid);
if (overrides.id !== undefined) {
testDb.prepare('UPDATE oauth_tokens SET id = ? WHERE id = ?').run(overrides.id, id);
return overrides.id;
}
return id;
}
describe('OauthTokensRepository', () => {
describe('insertToken (OA13/OA14)', () => {
it('OAUTHTOKREPO-001: writes exactly the given columns', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-1');
const accessExpiry = new Date(Date.now() + 3600_000).toISOString();
const refreshExpiry = new Date(Date.now() + 30 * 24 * 3600_000).toISOString();
await tokens.insertToken({
client_id: 'proto-1',
user_id: user.id,
access_token_hash: 'acchash',
refresh_token_hash: 'refhash',
scopes: '["trips:read"]',
audience: 'https://mcp.example.com',
access_token_expires_at: accessExpiry,
refresh_token_expires_at: refreshExpiry,
parent_token_id: null,
});
const row = testDb.prepare('SELECT * FROM oauth_tokens WHERE access_token_hash = ?').get('acchash') as Record<string, unknown>;
expect(row).toMatchObject({
client_id: 'proto-1',
user_id: user.id,
access_token_hash: 'acchash',
refresh_token_hash: 'refhash',
scopes: '["trips:read"]',
audience: 'https://mcp.example.com',
revoked_at: null,
parent_token_id: null,
});
});
it('OAUTHTOKREPO-002: parent_token_id is written when given (rotation)', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-2');
const parentId = seedToken({ clientId: 'proto-2', userId: user.id });
await tokens.insertToken({
client_id: 'proto-2', user_id: user.id, access_token_hash: 'a2', refresh_token_hash: 'r2', scopes: '[]', audience: null,
access_token_expires_at: new Date().toISOString(), refresh_token_expires_at: new Date().toISOString(), parent_token_id: parentId,
});
const row = testDb.prepare('SELECT parent_token_id FROM oauth_tokens WHERE access_token_hash = ?').get('a2') as { parent_token_id: number };
expect(row.parent_token_id).toBe(parentId);
});
});
describe('revokeAllForClient (OA8) — the two spellings of "now"', () => {
it("OAUTHTOKREPO-003: revokeAllForClient (legacy datetime('now')) and revokeById (legacy CURRENT_TIMESTAMP) store byte-identical text", async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-3');
const idA = seedToken({ clientId: 'proto-3', userId: user.id });
const idB = seedToken({ clientId: 'proto-3', userId: user.id });
await tokens.revokeAllForClient('proto-3'); // legacy: datetime('now')
await tokens.revokeById(idB); // legacy: CURRENT_TIMESTAMP
const rowA = testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string };
const rowB = testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string };
expect(rowA.revoked_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(rowB.revoked_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('OAUTHTOKREPO-004: revokeAllForClient only touches un-revoked rows of that client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-4');
seedClient(user.id, 'proto-4b');
const idOther = seedToken({ clientId: 'proto-4b', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-4', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idLive = seedToken({ clientId: 'proto-4', userId: user.id });
await tokens.revokeAllForClient('proto-4');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idOther) as { revoked_at: string | null }).revoked_at).toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive) as { revoked_at: string | null }).revoked_at).not.toBeNull();
});
});
describe('findByAccessTokenHashWithUser (OA16)', () => {
it('OAUTHTOKREPO-005: joins the token owner\'s username/email/role', async () => {
const { user } = createUser(testDb, { username: 'bearer-user', email: 'bearer@example.test' });
seedClient(user.id, 'proto-5');
seedToken({ clientId: 'proto-5', userId: user.id, accessHash: 'bearer-hash', scopes: '["trips:read"]', audience: 'aud' });
const row = await tokens.findByAccessTokenHashWithUser('bearer-hash');
expect(row).toMatchObject({
scopes: '["trips:read"]', audience: 'aud', revoked_at: null, user_id: user.id, client_id: 'proto-5',
username: 'bearer-user', email: 'bearer@example.test', role: 'user',
});
});
it('OAUTHTOKREPO-006: null for an unknown hash', async () => {
expect(await tokens.findByAccessTokenHashWithUser('nope')).toBeNull();
});
});
describe('findParent (OA17)', () => {
it('OAUTHTOKREPO-007: returns id + parent_token_id', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-7');
const rootId = seedToken({ clientId: 'proto-7', userId: user.id });
const childId = seedToken({ clientId: 'proto-7', userId: user.id, parentId: rootId });
expect(await tokens.findParent(childId)).toEqual({ id: childId, parent_token_id: rootId });
expect(await tokens.findParent(rootId)).toEqual({ id: rootId, parent_token_id: null });
});
});
describe('collectChainIds (OA18) — recursive CTE, proven against the legacy raw statement', () => {
it('OAUTHTOKREPO-008: a 4-level chain is fully collected, a sibling branch is excluded — matches the legacy WITH RECURSIVE statement exactly', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-8');
const id1 = seedToken({ clientId: 'proto-8', userId: user.id });
const id2 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id1 });
const id3 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id2 });
const id4 = seedToken({ clientId: 'proto-8', userId: user.id, parentId: id3 });
// A sibling branch off a DIFFERENT root — must not be collected.
const sibRoot = seedToken({ clientId: 'proto-8', userId: user.id });
const sibChild = seedToken({ clientId: 'proto-8', userId: user.id, parentId: sibRoot });
const legacyRows = testDb.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`).all(id1) as Array<{ id: number }>;
const legacyIds = legacyRows.map((r) => r.id).sort((a, b) => a - b);
const ids = (await tokens.collectChainIds(id1)).sort((a, b) => a - b);
expect(ids).toEqual(legacyIds);
expect(ids).toEqual([id1, id2, id3, id4].sort((a, b) => a - b));
expect(ids).not.toContain(sibRoot);
expect(ids).not.toContain(sibChild);
});
it('OAUTHTOKREPO-009: a token with no children collects only itself', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-9');
const id = seedToken({ clientId: 'proto-9', userId: user.id });
expect(await tokens.collectChainIds(id)).toEqual([id]);
});
});
describe('revokeByIds (OA19) — the $in revoke', () => {
it('OAUTHTOKREPO-010: revokes exactly the given ids, only if still live', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-10');
const idLive1 = seedToken({ clientId: 'proto-10', userId: user.id });
const idLive2 = seedToken({ clientId: 'proto-10', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-10', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idUntouched = seedToken({ clientId: 'proto-10', userId: user.id });
await tokens.revokeByIds([idLive1, idLive2, idAlready]);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive1) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idLive2) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00'); // untouched, not re-stamped
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idUntouched) as { revoked_at: string | null }).revoked_at).toBeNull();
});
it('OAUTHTOKREPO-011: an empty array is a no-op (matches the legacy `if (ids.length > 0)` guard)', async () => {
await expect(tokens.revokeByIds([])).resolves.toBeUndefined();
});
});
describe('collectChainIds + revokeByIds inside a transaction — proves it joins the OPEN transaction', () => {
it('OAUTHTOKREPO-012: revoking a chain inside a uow.transactional that then ROLLS BACK revokes nothing', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-12');
const rootId = seedToken({ clientId: 'proto-12', userId: user.id });
const childId = seedToken({ clientId: 'proto-12', userId: user.id, parentId: rootId });
let caught: unknown;
try {
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
const ids = await tokens.collectChainIds(rootId);
expect(ids.sort((a, b) => a - b)).toEqual([rootId, childId].sort((a, b) => a - b));
await tokens.revokeByIds(ids);
throw new Error('force rollback');
});
});
} catch (e) { caught = e; }
expect((caught as Error).message).toBe('force rollback');
const rows = testDb.prepare('SELECT id, revoked_at FROM oauth_tokens WHERE id IN (?, ?)').all(rootId, childId) as Array<{ id: number; revoked_at: string | null }>;
expect(rows.every((r) => r.revoked_at === null)).toBe(true);
});
it('OAUTHTOKREPO-013: the same sequence, committed, actually revokes the chain', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-13');
const rootId = seedToken({ clientId: 'proto-13', userId: user.id });
const childId = seedToken({ clientId: 'proto-13', userId: user.id, parentId: rootId });
await withRequestContext(t.orm, async () => {
await uow.transactional(async () => {
const ids = await tokens.collectChainIds(rootId);
await tokens.revokeByIds(ids);
});
});
const rows = testDb.prepare('SELECT id, revoked_at FROM oauth_tokens WHERE id IN (?, ?)').all(rootId, childId) as Array<{ id: number; revoked_at: string | null }>;
expect(rows.every((r) => r.revoked_at !== null)).toBe(true);
});
});
describe('findSuccessorAlive (OA20)', () => {
it('OAUTHTOKREPO-014: a revoked parent with a live child returns the child', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-14');
const parentId = seedToken({ clientId: 'proto-14', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const childId = seedToken({ clientId: 'proto-14', userId: user.id, parentId });
expect(await tokens.findSuccessorAlive(parentId)).toEqual({ id: childId });
});
it('OAUTHTOKREPO-015: no live child returns null', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-15');
const parentId = seedToken({ clientId: 'proto-15', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
seedToken({ clientId: 'proto-15', userId: user.id, parentId, revokedAt: '2020-01-01 00:00:01' });
expect(await tokens.findSuccessorAlive(parentId)).toBeNull();
});
});
describe('findByRefreshTokenHash (OA22)', () => {
it('OAUTHTOKREPO-016: returns every column the legacy statement selects, FK scalars read from the raw column value', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-16');
const parentId = seedToken({ clientId: 'proto-16', userId: user.id });
const id = seedToken({ clientId: 'proto-16', userId: user.id, refreshHash: 'refresh-16', parentId, audience: 'aud-16' });
const row = await tokens.findByRefreshTokenHash('refresh-16');
expect(row).toEqual({
id, client_id: 'proto-16', user_id: user.id, scopes: '["trips:read"]', audience: 'aud-16',
refresh_token_expires_at: expect.any(String), revoked_at: null, parent_token_id: parentId,
});
});
it('OAUTHTOKREPO-017: null for an unknown hash', async () => {
expect(await tokens.findByRefreshTokenHash('nope')).toBeNull();
});
});
describe('revokeById (OA23/OA28/OA33)', () => {
it('OAUTHTOKREPO-018: revokes the given id only', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-18');
const idA = seedToken({ clientId: 'proto-18', userId: user.id });
const idB = seedToken({ clientId: 'proto-18', userId: user.id });
await tokens.revokeById(idA);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string | null }).revoked_at).toBeNull();
});
});
describe('findByAccessOrRefreshHashAndClient / revokeByAccessOrRefreshHashAndClient (OA24/OA25)', () => {
it('OAUTHTOKREPO-019: finds by access hash OR refresh hash, scoped to the client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-19');
seedToken({ clientId: 'proto-19', userId: user.id, accessHash: 'acc-19', refreshHash: 'ref-19' });
expect(await tokens.findByAccessOrRefreshHashAndClient('acc-19', 'proto-19')).toEqual({ user_id: user.id });
expect(await tokens.findByAccessOrRefreshHashAndClient('ref-19', 'proto-19')).toEqual({ user_id: user.id });
});
it('OAUTHTOKREPO-020: does not match a token belonging to a different client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-20a');
seedClient(user.id, 'proto-20b');
seedToken({ clientId: 'proto-20a', userId: user.id, accessHash: 'acc-20' });
expect(await tokens.findByAccessOrRefreshHashAndClient('acc-20', 'proto-20b')).toBeNull();
});
it('OAUTHTOKREPO-021: revokeByAccessOrRefreshHashAndClient revokes by either hash, scoped to the client', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-21');
const id = seedToken({ clientId: 'proto-21', userId: user.id, accessHash: 'acc-21', refreshHash: 'ref-21' });
await tokens.revokeByAccessOrRefreshHashAndClient('acc-21', 'proto-21');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(id) as { revoked_at: string | null }).revoked_at).not.toBeNull();
});
});
describe('listActiveByUser (OA26) — Kysely join on the real oauth_clients.client_id column', () => {
it('OAUTHTOKREPO-022: lists only this user\'s non-revoked, non-expired-refresh sessions, newest first, with the client name joined in', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(user.id, 'proto-22a', 'Client A');
seedClient(user.id, 'proto-22b', 'Client B');
seedToken({ clientId: 'proto-22a', userId: user.id, scopes: '["trips:read"]' });
testDb.exec("UPDATE oauth_tokens SET created_at = '2020-01-01 00:00:00' WHERE client_id = 'proto-22a'");
seedToken({ clientId: 'proto-22b', userId: user.id });
testDb.exec("UPDATE oauth_tokens SET created_at = '2021-01-01 00:00:00' WHERE client_id = 'proto-22b'");
// revoked — excluded
seedToken({ clientId: 'proto-22a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
// expired refresh — excluded
seedToken({ clientId: 'proto-22a', userId: user.id, refreshExpiresAt: '2000-01-01 00:00:00' });
// another user's session — excluded
seedClient(other.id, 'proto-22c');
seedToken({ clientId: 'proto-22c', userId: other.id });
const rows = await tokens.listActiveByUser(user.id);
expect(rows.map((r) => r.client_name)).toEqual(['Client B', 'Client A']);
expect(rows[0].scopes).toBeDefined();
});
});
describe('findOwnedById (OA27)', () => {
it('OAUTHTOKREPO-023: matches id+user', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-23');
const id = seedToken({ clientId: 'proto-23', userId: user.id });
expect(await tokens.findOwnedById(id, user.id)).toEqual({ id, client_id: 'proto-23' });
});
it('OAUTHTOKREPO-024: 404-shape null for another user\'s session', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(other.id, 'proto-24');
const id = seedToken({ clientId: 'proto-24', userId: other.id });
expect(await tokens.findOwnedById(id, user.id)).toBeNull();
});
});
describe('listAllActiveWithClientAndUser (OA31) — the admin panel', () => {
it('OAUTHTOKREPO-025: lists every active session across all users, with client name and username joined in', async () => {
const { user: a } = createUser(testDb, { username: 'admin-panel-a' });
const { user: b } = createUser(testDb, { username: 'admin-panel-b' });
seedClient(a.id, 'proto-25a', 'Client A');
seedClient(b.id, 'proto-25b', 'Client B');
seedToken({ clientId: 'proto-25a', userId: a.id });
seedToken({ clientId: 'proto-25b', userId: b.id, revokedAt: '2020-01-01 00:00:00' }); // excluded
const rows = await tokens.listAllActiveWithClientAndUser();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ client_name: 'Client A', username: 'admin-panel-a', user_id: a.id });
});
});
describe('findById (OA32)', () => {
it('OAUTHTOKREPO-026: unscoped by owner — for the admin 404 check', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-26');
const id = seedToken({ clientId: 'proto-26', userId: user.id });
expect(await tokens.findById(id)).toEqual({ id, user_id: user.id, client_id: 'proto-26' });
});
it('OAUTHTOKREPO-027: null for an unknown id', async () => {
expect(await tokens.findById(999999)).toBeNull();
});
});
describe('revokeAllForUser (AU18/AU46 — built now for Task 5)', () => {
it('OAUTHTOKREPO-028: revokes every live session for the user, across every client, leaves other users untouched', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
seedClient(user.id, 'proto-28a');
seedClient(user.id, 'proto-28b');
seedClient(other.id, 'proto-28c');
const idA = seedToken({ clientId: 'proto-28a', userId: user.id });
const idB = seedToken({ clientId: 'proto-28b', userId: user.id });
const idAlready = seedToken({ clientId: 'proto-28a', userId: user.id, revokedAt: '2020-01-01 00:00:00' });
const idOther = seedToken({ clientId: 'proto-28c', userId: other.id });
await tokens.revokeAllForUser(user.id);
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idA) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idB) as { revoked_at: string | null }).revoked_at).not.toBeNull();
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idAlready) as { revoked_at: string }).revoked_at).toBe('2020-01-01 00:00:00');
expect((testDb.prepare('SELECT revoked_at FROM oauth_tokens WHERE id = ?').get(idOther) as { revoked_at: string | null }).revoked_at).toBeNull();
});
});
describe('identity-map write-back regression (program RULING, D-shape)', () => {
it('OAUTHTOKREPO-029: findByRefreshTokenHash (projection A) then findParent (projection B) then revokeById inside uow.transactional — the write survives', async () => {
const { user } = createUser(testDb);
seedClient(user.id, 'proto-29');
const id = seedToken({ clientId: 'proto-29', userId: user.id, refreshHash: 'refresh-29', scopes: '["kept-scope"]' });
await withRequestContext(t.orm, async () => {
// Projection A — a wide fields set (id, client, user, scopes, audience, refresh_token_expires_at, revoked_at, parentToken).
await tokens.findByRefreshTokenHash('refresh-29');
// Projection B — a narrow, DIFFERENT field set on the same row.
await tokens.findParent(id);
// The intended write, inside the request's own transaction.
await uow.transactional(async () => {
await tokens.revokeById(id);
});
});
const row = testDb.prepare('SELECT revoked_at, scopes FROM oauth_tokens WHERE id = ?').get(id) as { revoked_at: string | null; scopes: string };
expect(row.revoked_at).not.toBeNull(); // the nativeUpdate must stick
expect(row.scopes).toBe('["kept-scope"]'); // untouched by either read
});
});
});
+14 -4
View File
@@ -59,7 +59,6 @@ function makePkce() {
}
import { OauthService } from '../../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { getMcpSafeUrl } from '../../../src/app-config';
@@ -70,14 +69,22 @@ import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
import { Users } from '../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { OauthClients } from '../../../src/db/entities/OauthClients.entity';
import type { OauthClientsRepository } from '../../../src/db/repositories/OauthClients.repository';
import { OauthTokens } from '../../../src/db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../../src/db/repositories/OauthTokens.repository';
import { OauthConsents } from '../../../src/db/entities/OauthConsents.entity';
import type { OauthConsentsRepository } from '../../../src/db/repositories/OauthConsents.repository';
const dbs = new DatabaseService(testDb);
// Stubbed rather than real: every case drives the MCP gate through this one
// flag, exactly as the addons.bridge mock did before the fold.
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
let svc: OauthService;
let auditLogRepo: AuditLogRepository;
let usersRepo: UsersRepository;
let clientsRepo: OauthClientsRepository;
let tokensRepo: OauthTokensRepository;
let consentsRepo: OauthConsentsRepository;
let t: TestOrm;
// Legacy free-function names delegating to the service, so the moved cases below
@@ -109,7 +116,10 @@ beforeAll(async () => {
t = await createTestOrm(testDb);
auditLogRepo = t.repo(AuditLog);
usersRepo = t.repo(Users);
svc = new OauthService(dbs, addonsStub, new AuditService(auditLogRepo, usersRepo));
clientsRepo = t.repo(OauthClients);
tokensRepo = t.repo(OauthTokens);
consentsRepo = t.repo(OauthConsents);
svc = new OauthService(clientsRepo, tokensRepo, consentsRepo, addonsStub, new AuditService(auditLogRepo, usersRepo));
});
beforeEach(() => {
@@ -1298,7 +1308,7 @@ describe('module-scoped OAuth state', () => {
codeChallengeMethod: 'S256',
})!;
const secondInstance = new OauthService(dbs, addonsStub, new AuditService(auditLogRepo, usersRepo));
const secondInstance = new OauthService(clientsRepo, tokensRepo, consentsRepo, addonsStub, new AuditService(auditLogRepo, usersRepo));
expect(secondInstance.consumeAuthCode(code)?.userId).toBe(42);
});
});