mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e1597c13 | |||
| 3403e1a71d | |||
| e9fb4f6f1e | |||
| a173a870d7 | |||
| 968c3d2eeb |
@@ -22,6 +22,15 @@ JWT_ALG=HS256
|
||||
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
|
||||
#SSO_TRUSTED_DOMAINS=company.com,corp.local
|
||||
|
||||
# OAuth 2.1 for third-party MCP clients (ChatGPT, Claude connectors).
|
||||
# Dynamic Client Registration is on by default; a cloud client cannot connect
|
||||
# without it, since it has no way to pre-register with your instance. Turn it
|
||||
# off on a private install that only uses manually seeded clients.
|
||||
#OAUTH_DYNAMIC_REGISTRATION=false
|
||||
# Public URL of this API. Used as the OAuth issuer in the discovery documents,
|
||||
# so it must be the URL clients actually reach — set it behind a proxy.
|
||||
#API_PUBLIC_URL=https://api.taskview.tech
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.domain.com
|
||||
SMTP_PORT=465
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RequireTokenPermission } from '../require-token-permission';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
|
||||
const runWith = (tokenPermissions: string[] | undefined) => {
|
||||
const next = vi.fn();
|
||||
const end = vi.fn();
|
||||
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
|
||||
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
|
||||
|
||||
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
|
||||
return { next, res };
|
||||
};
|
||||
|
||||
describe('RequireTokenPermission', () => {
|
||||
it('lets a browser session through — it carries no token permissions', () => {
|
||||
const { next, res } = runWith(undefined);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an unrestricted token through, keeping existing integrations working', () => {
|
||||
const { next } = runWith([]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a token holding the permission through', () => {
|
||||
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks a restricted token that was not given the permission', () => {
|
||||
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('does not accept a neighbouring permission from the same group', () => {
|
||||
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { $logger } from '../modules/logget';
|
||||
import AuthController from '../tv-modules/auth/AuthController';
|
||||
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
|
||||
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
|
||||
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/types';
|
||||
|
||||
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -13,7 +14,7 @@ export const appUserMiddleware = async (req: Request, res: Response, next: NextF
|
||||
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
|
||||
if (token && token.startsWith(TOKEN_PREFIX)) {
|
||||
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
|
||||
const record = await getApiTokensManager().validateToken(token);
|
||||
if (record) {
|
||||
const authManager = new AppUser().authManager;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { GoalPermissionType } from '../types/auth.types';
|
||||
|
||||
/**
|
||||
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
|
||||
* by an organization role or by project ownership rather than by the project RBAC
|
||||
* — organizations, SSO configuration, webhooks. Those checks never consult
|
||||
* GoalPermissionsFetcher, so without this the scope chosen when the token was
|
||||
* issued would simply not apply to them.
|
||||
*
|
||||
* It only ever removes access. Put it AFTER the role or ownership guard, so that
|
||||
* guard still has the final say on what the human behind the token may do:
|
||||
*
|
||||
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
|
||||
*
|
||||
* A browser session has no token permissions and passes. A token issued with an
|
||||
* empty permission list is unrestricted by design — the same meaning it carries
|
||||
* everywhere else — and also passes, which keeps existing integrations working.
|
||||
*/
|
||||
export const RequireTokenPermission = (permission: GoalPermissionType) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const tokenPermissions = req.appUser.getTokenPermissions();
|
||||
|
||||
if (!tokenPermissions || tokenPermissions.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (tokenPermissions.includes(permission)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
};
|
||||
@@ -750,5 +750,35 @@
|
||||
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
|
||||
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
|
||||
]
|
||||
},
|
||||
"59": {
|
||||
"version": "1.64.0",
|
||||
"name": "OAuth 2.1 authorization server",
|
||||
"releaseDate": "20260830",
|
||||
"scripts": [
|
||||
"/1.64.0/0.create-oauth-clients.sql",
|
||||
"/1.64.0/1.create-oauth-auth-codes.sql",
|
||||
"/1.64.0/2.create-oauth-grants.sql",
|
||||
"/1.64.0/3.alter-api-tokens-grant-id.sql"
|
||||
],
|
||||
"description": [
|
||||
"OAuth 2.1 authorization server so third-party MCP clients (ChatGPT, Claude connectors) can act on a user's behalf without the user pasting a permanent tvk_ API token into them.",
|
||||
"tv_auth.oauth_clients holds the client registry (manually seeded or created via RFC 7591 Dynamic Client Registration); public clients carry no secret and are authenticated by PKCE S256 alone.",
|
||||
"tv_auth.oauth_auth_codes holds single-use 60-second authorization codes; tv_auth.oauth_grants is one row per connected app and owns the rotating refresh token, with the previous hash kept to detect replay.",
|
||||
"Access tokens reuse tv_auth.api_tokens (new grant_id column) so validation, permission intersection and RejectApiTokenAuth all keep working unchanged; revoking a grant cascades to its live access tokens."
|
||||
]
|
||||
},
|
||||
"60": {
|
||||
"version": "1.65.0",
|
||||
"name": "Organization-level permissions",
|
||||
"releaseDate": "20260830",
|
||||
"scripts": [
|
||||
"/1.65.0/0.organization-permissions.sql"
|
||||
],
|
||||
"description": [
|
||||
"New permission group 'organization' with org_can_view, org_can_manage, org_can_manage_members, sso_can_manage and webhooks_can_manage.",
|
||||
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
|
||||
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- OAuth 2.1 client registry. Clients are either seeded manually by an operator
|
||||
-- or created through Dynamic Client Registration (RFC 7591) when it is enabled.
|
||||
-- Public clients (MCP clients such as ChatGPT or Claude) hold no secret and are
|
||||
-- authenticated by PKCE alone, so client_secret_hash stays NULL for them.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_clients (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
client_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
client_secret_hash VARCHAR(64),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
redirect_uris VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
created_via VARCHAR(16) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oauth_clients_created_via_check CHECK (created_via IN ('manual', 'dcr'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON tv_auth.oauth_clients(client_id);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Short-lived, single-use authorization codes issued by the consent screen and
|
||||
-- redeemed once at the token endpoint. Only the hash is stored, mirroring
|
||||
-- tv_auth.api_tokens. used_at is set on redemption: a second redemption of the
|
||||
-- same code is treated as replay and revokes the grant it produced.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_auth_codes (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
code_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
redirect_uri VARCHAR NOT NULL,
|
||||
code_challenge VARCHAR(128) NOT NULL,
|
||||
code_challenge_method VARCHAR(8) NOT NULL DEFAULT 'S256',
|
||||
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
|
||||
resource VARCHAR,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used_at TIMESTAMP,
|
||||
grant_id INTEGER,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oauth_auth_codes_challenge_method_check CHECK (code_challenge_method = 'S256')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code_hash ON tv_auth.oauth_auth_codes(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires_at ON tv_auth.oauth_auth_codes(expires_at);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- One row per (user, client) authorization — this is what the user sees and
|
||||
-- revokes as a "connected app". The refresh token hangs off the grant and is
|
||||
-- rotated on every use; refresh_token_prev_hash keeps the previous value so a
|
||||
-- replayed refresh token can be detected and the whole grant revoked.
|
||||
CREATE TABLE IF NOT EXISTS tv_auth.oauth_grants (
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
|
||||
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
|
||||
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
|
||||
resource VARCHAR,
|
||||
refresh_token_hash VARCHAR(64) UNIQUE,
|
||||
refresh_token_prev_hash VARCHAR(64),
|
||||
refresh_expires_at TIMESTAMP,
|
||||
last_used_at TIMESTAMP,
|
||||
revoked_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_user_id ON tv_auth.oauth_grants(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_refresh_token_hash ON tv_auth.oauth_grants(refresh_token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_grants_prev_refresh_hash ON tv_auth.oauth_grants(refresh_token_prev_hash);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'oauth_auth_codes_grant_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE tv_auth.oauth_auth_codes
|
||||
ADD CONSTRAINT oauth_auth_codes_grant_id_fkey
|
||||
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- OAuth access tokens live in tv_auth.api_tokens alongside manually issued
|
||||
-- tvk_ tokens: same opaque-token storage, same validation path, same permission
|
||||
-- intersection. grant_id ties an access token to the OAuth grant that minted it,
|
||||
-- so revoking a connected app deletes its live access tokens immediately.
|
||||
ALTER TABLE tv_auth.api_tokens
|
||||
ADD COLUMN IF NOT EXISTS grant_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_grant_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE tv_auth.api_tokens
|
||||
ADD CONSTRAINT api_tokens_grant_id_fkey
|
||||
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_grant_id ON tv_auth.api_tokens(grant_id);
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Permissions for the surfaces that were previously guarded only by an
|
||||
-- organization role or by project ownership, and therefore ignored the scope of
|
||||
-- an API / OAuth token entirely: a token issued with a single permission could
|
||||
-- still create organizations, add admins, configure SSO and create webhooks.
|
||||
--
|
||||
-- These keys let a token be narrowed on those actions too. They never grant
|
||||
-- anything: the role and ownership checks still run first, and RequireTokenPermission
|
||||
-- only removes what the token was not given.
|
||||
INSERT INTO tv_auth.permissions_group (id, name)
|
||||
VALUES (6, 'organization')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_view',
|
||||
'View the organization and its members',
|
||||
6,
|
||||
'{
|
||||
"en": "View organization. See the organization and the list of its members.",
|
||||
"ru": "Просмотр организации. Видеть организацию и список её участников.",
|
||||
"de": "Organisation ansehen. Die Organisation und ihre Mitglieder sehen.",
|
||||
"es": "Ver la organización. Ver la organización y la lista de sus miembros.",
|
||||
"pt-BR": "Ver a organização. Ver a organização e a lista de seus membros."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_manage',
|
||||
'Create, rename and delete organizations',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage organizations. Create, rename and delete organizations.",
|
||||
"ru": "Управление организациями. Создавать, переименовывать и удалять организации.",
|
||||
"de": "Organisationen verwalten. Organisationen erstellen, umbenennen und löschen.",
|
||||
"es": "Gestionar organizaciones. Crear, renombrar y eliminar organizaciones.",
|
||||
"pt-BR": "Gerenciar organizações. Criar, renomear e excluir organizações."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'org_can_manage_members',
|
||||
'Add and remove organization members and change their roles',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage members. Add and remove organization members and change their roles.",
|
||||
"ru": "Управление участниками. Добавлять и удалять участников организации, менять их роли.",
|
||||
"de": "Mitglieder verwalten. Mitglieder hinzufügen, entfernen und deren Rollen ändern.",
|
||||
"es": "Gestionar miembros. Añadir y quitar miembros de la organización y cambiar sus roles.",
|
||||
"pt-BR": "Gerenciar membros. Adicionar e remover membros da organização e alterar seus papéis."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'sso_can_manage',
|
||||
'Create and change the single sign-on configuration',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage SSO. Create and change the single sign-on configuration of the organization.",
|
||||
"ru": "Управление SSO. Создавать и изменять настройки единого входа организации.",
|
||||
"de": "SSO verwalten. Die Single-Sign-on-Konfiguration der Organisation erstellen und ändern.",
|
||||
"es": "Gestionar SSO. Crear y cambiar la configuración de inicio de sesión único de la organización.",
|
||||
"pt-BR": "Gerenciar SSO. Criar e alterar a configuração de login único da organização."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
|
||||
VALUES (
|
||||
'webhooks_can_manage',
|
||||
'Create, edit and delete project webhooks',
|
||||
6,
|
||||
'{
|
||||
"en": "Manage webhooks. Create, edit and delete webhooks of a project.",
|
||||
"ru": "Управление вебхуками. Создавать, изменять и удалять вебхуки проекта.",
|
||||
"de": "Webhooks verwalten. Webhooks eines Projekts erstellen, bearbeiten und löschen.",
|
||||
"es": "Gestionar webhooks. Crear, editar y eliminar webhooks de un proyecto.",
|
||||
"pt-BR": "Gerenciar webhooks. Criar, editar e excluir webhooks de um projeto."
|
||||
}'::jsonb
|
||||
)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
@@ -8,6 +8,8 @@ import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes
|
||||
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
|
||||
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
|
||||
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
|
||||
import OAuthRoutes from '../tv-modules/oauth/OAuthRoutes';
|
||||
import OAuthWellKnownRoutes from '../tv-modules/oauth/OAuthWellKnownRoutes';
|
||||
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
|
||||
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
|
||||
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
|
||||
@@ -42,6 +44,7 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/webhooks': WebhooksRoutes,
|
||||
'/module/messaging': MessagingRoutes,
|
||||
'/module/api-tokens': ApiTokensRoutes,
|
||||
'/module/oauth': OAuthRoutes,
|
||||
'/module/sessions': SessionsRoutes,
|
||||
'/module/organizations': OrganizationRoutes,
|
||||
'/module/sso': SsoRoutes,
|
||||
@@ -51,6 +54,7 @@ const routes: Record<string, RoutableConstructor> = {
|
||||
'/module/sprints': SprintsRoutes,
|
||||
'/module/recurrence': RecurrenceRoutes,
|
||||
'/scim/v2': ScimRoutes,
|
||||
'/.well-known': OAuthWellKnownRoutes,
|
||||
};
|
||||
|
||||
export default routes;
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Request, Response } from 'express';
|
||||
import { ArkErrors } from 'arktype';
|
||||
import { getApiTokensManager } from './ApiTokensManager';
|
||||
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
|
||||
import { Database } from '../../modules/db';
|
||||
|
||||
export class ApiTokensController {
|
||||
private get manager() { return getApiTokensManager(); }
|
||||
@@ -44,10 +43,7 @@ export class ApiTokensController {
|
||||
};
|
||||
|
||||
fetchPermissions = async (_req: Request, res: Response) => {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
|
||||
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
|
||||
);
|
||||
return res.tvJson(result?.rows ?? []);
|
||||
const result = await this.manager.fetchSelectablePermissions();
|
||||
return res.tvJson(result);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { ApiTokensRepository } from './ApiTokensRepository';
|
||||
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
|
||||
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { ApiTokensSchemaTypeForSelect, PermissionsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
|
||||
|
||||
@@ -42,6 +42,10 @@ export class ApiTokensManager {
|
||||
return tokens.map((t) => this.toClient(t));
|
||||
}
|
||||
|
||||
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
|
||||
return this.repository.fetchSelectablePermissions();
|
||||
}
|
||||
|
||||
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
|
||||
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
|
||||
const record = await this.repository.findByTokenHash(tokenHash);
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
|
||||
import {
|
||||
ApiTokensSchema,
|
||||
PermissionsSchema,
|
||||
type ApiTokensSchemaTypeForSelect,
|
||||
type PermissionsSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
|
||||
@@ -20,15 +25,21 @@ export class ApiTokensRepository {
|
||||
async delete(id: number, userId: number): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(ApiTokensSchema).where(
|
||||
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
|
||||
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
|
||||
)
|
||||
);
|
||||
return !!result?.rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only manually issued tokens. OAuth access tokens live in the same table but
|
||||
* belong to a grant - they are listed and revoked as connected apps instead.
|
||||
*/
|
||||
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
|
||||
this.db.dbDrizzle.select().from(ApiTokensSchema).where(
|
||||
and(eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
|
||||
)
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
@@ -40,6 +51,21 @@ export class ApiTokensRepository {
|
||||
return result?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions offered when scoping a token. Group 1 is excluded: those keys
|
||||
* exist in the table but are enforced nowhere in the code, so offering them
|
||||
* would promise a restriction that never happens.
|
||||
*/
|
||||
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select()
|
||||
.from(PermissionsSchema)
|
||||
.where(ne(PermissionsSchema.permissionGroup, 1))
|
||||
.orderBy(asc(PermissionsSchema.permissionGroup), asc(PermissionsSchema.id))
|
||||
);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
async updateLastUsedAt(id: number): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(ApiTokensSchema)
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { ArkErrors } from 'arktype'
|
||||
import { PublicApiUrl } from '../../modules/public-url'
|
||||
import { OAuthManager } from './OAuthManager'
|
||||
import {
|
||||
type SendTokenErrorArgs,
|
||||
type SendTokenSuccessArgs,
|
||||
OAuthAuthorizeArkType,
|
||||
OAuthConsentArkType,
|
||||
OAuthRegisterArkType,
|
||||
OAuthRevokeArkType,
|
||||
OAuthTokenArkType,
|
||||
} from './types'
|
||||
import {
|
||||
buildRedirectUrl,
|
||||
isAcceptableRedirectUri,
|
||||
isDcrEnabled,
|
||||
} from './oauth.utils'
|
||||
|
||||
export class OAuthController {
|
||||
private get manager() { return OAuthManager.getInstance() }
|
||||
|
||||
authorize = async (req: Request, res: Response) => {
|
||||
const params = OAuthAuthorizeArkType(req.query)
|
||||
if (params instanceof ArkErrors) {
|
||||
return res.status(400).send(params.summary)
|
||||
}
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: params.client_id,
|
||||
redirectUri: params.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
const consentUrl = buildRedirectUrl({
|
||||
redirectUri: `${process.env.APP_URL}/oauth/consent`,
|
||||
params: {
|
||||
client_id: params.client_id,
|
||||
client_name: client.value.name,
|
||||
redirect_uri: params.redirect_uri,
|
||||
code_challenge: params.code_challenge,
|
||||
code_challenge_method: params.code_challenge_method,
|
||||
state: params.state,
|
||||
resource: params.resource,
|
||||
},
|
||||
})
|
||||
return res.redirect(consentUrl)
|
||||
}
|
||||
|
||||
consent = async (req: Request, res: Response) => {
|
||||
const data = OAuthConsentArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: data.client_id,
|
||||
redirectUri: data.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
// Empty means "do not narrow", matching how a tvk_ token with no
|
||||
// permissions selected behaves. The user's own RBAC is still the ceiling.
|
||||
const code = await this.manager.issueAuthCode({
|
||||
clientId: data.client_id,
|
||||
userId,
|
||||
redirectUri: data.redirect_uri,
|
||||
codeChallenge: data.code_challenge,
|
||||
codeChallengeMethod: data.code_challenge_method,
|
||||
allowedPermissions: data.allowedPermissions ?? [],
|
||||
allowedGoalIds: data.allowedGoalIds ?? [],
|
||||
resource: data.resource ?? null,
|
||||
})
|
||||
if (!code) return res.status(500).end()
|
||||
|
||||
return res.tvJson({
|
||||
redirectUrl: buildRedirectUrl({
|
||||
redirectUri: data.redirect_uri,
|
||||
params: { code, state: data.state },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
denyConsent = async (req: Request, res: Response) => {
|
||||
const data = OAuthConsentArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: data.client_id,
|
||||
redirectUri: data.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
return res.tvJson({
|
||||
redirectUrl: buildRedirectUrl({
|
||||
redirectUri: data.redirect_uri,
|
||||
params: { error: 'access_denied', state: data.state },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
token = async (req: Request, res: Response) => {
|
||||
const data = OAuthTokenArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: data.summary })
|
||||
}
|
||||
|
||||
const basic = this.readBasicAuth(req)
|
||||
const clientId = basic?.clientId ?? data.client_id
|
||||
const clientSecret = basic?.clientSecret ?? data.client_secret
|
||||
if (!clientId) {
|
||||
return this.sendTokenError({ res, status: 401, error: 'invalid_client', description: 'client_id is required' })
|
||||
}
|
||||
|
||||
const client = await this.manager.authenticateClient({ clientId, clientSecret })
|
||||
if (!client.ok) {
|
||||
return this.sendTokenError({ res, status: 401, error: client.error, description: client.description })
|
||||
}
|
||||
|
||||
const resource = data.resource ?? null
|
||||
|
||||
if (data.grant_type === 'authorization_code') {
|
||||
if (!data.code || !data.code_verifier || !data.redirect_uri) {
|
||||
return this.sendTokenError({
|
||||
res,
|
||||
status: 400,
|
||||
error: 'invalid_request',
|
||||
description: 'code, code_verifier and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
const result = await this.manager.exchangeCode({
|
||||
code: data.code,
|
||||
codeVerifier: data.code_verifier,
|
||||
clientId,
|
||||
redirectUri: data.redirect_uri,
|
||||
resource,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return this.sendTokenError({ res, status: 400, error: result.error, description: result.description })
|
||||
}
|
||||
return this.sendTokenSuccess({ res, body: result.value })
|
||||
}
|
||||
|
||||
if (!data.refresh_token) {
|
||||
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: 'refresh_token is required' })
|
||||
}
|
||||
const refreshed = await this.manager.refreshTokens({
|
||||
refreshToken: data.refresh_token,
|
||||
clientId,
|
||||
resource,
|
||||
})
|
||||
if (!refreshed.ok) {
|
||||
return this.sendTokenError({ res, status: 400, error: refreshed.error, description: refreshed.description })
|
||||
}
|
||||
return this.sendTokenSuccess({ res, body: refreshed.value })
|
||||
}
|
||||
|
||||
register = async (req: Request, res: Response) => {
|
||||
if (!isDcrEnabled()) {
|
||||
return res.status(403).json({
|
||||
error: 'access_denied',
|
||||
error_description: 'Dynamic client registration is disabled on this instance',
|
||||
})
|
||||
}
|
||||
|
||||
const data = OAuthRegisterArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: data.summary })
|
||||
}
|
||||
|
||||
const rejected = data.redirect_uris.find((uri) => !isAcceptableRedirectUri(uri))
|
||||
if (rejected) {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `redirect_uri must be https, loopback http, or an app scheme, and carry no fragment: ${rejected}`,
|
||||
})
|
||||
}
|
||||
|
||||
const name = data.client_name?.trim() || 'Unnamed client'
|
||||
const isPublic = (data.token_endpoint_auth_method ?? 'none') === 'none'
|
||||
const result = await this.manager.registerClient({
|
||||
name,
|
||||
redirectUris: data.redirect_uris,
|
||||
isPublic,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ error: result.error, error_description: result.description })
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
client_id: result.value.clientId,
|
||||
// RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a
|
||||
// secret is issued. 0 means it does not expire.
|
||||
...(result.value.clientSecret
|
||||
? { client_secret: result.value.clientSecret, client_secret_expires_at: 0 }
|
||||
: {}),
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_name: name,
|
||||
redirect_uris: data.redirect_uris,
|
||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
})
|
||||
}
|
||||
|
||||
revoke = async (req: Request, res: Response) => {
|
||||
const data = OAuthRevokeArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
// RFC 7009: revocation always answers 200, even for an unknown token.
|
||||
await this.manager.revokeToken(data.token)
|
||||
return res.status(200).end()
|
||||
}
|
||||
|
||||
fetchConnectedApps = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const result = await this.manager.fetchConnectedApps(userId)
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
revokeConnectedApp = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const grantId = Number(req.body?.grantId)
|
||||
if (!Number.isInteger(grantId) || grantId <= 0) {
|
||||
return res.status(400).send('grantId must be a positive integer')
|
||||
}
|
||||
|
||||
const result = await this.manager.revokeGrant({ grantId, userId })
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
authorizationServerMetadata = async (req: Request, res: Response) => {
|
||||
const issuer = PublicApiUrl.base(req)
|
||||
return res.json({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/module/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/module/oauth/token`,
|
||||
revocation_endpoint: `${issuer}/module/oauth/revoke`,
|
||||
...(isDcrEnabled() ? { registration_endpoint: `${issuer}/module/oauth/register` } : {}),
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
|
||||
})
|
||||
}
|
||||
|
||||
protectedResourceMetadata = async (req: Request, res: Response) => {
|
||||
const issuer = PublicApiUrl.base(req)
|
||||
return res.json({
|
||||
resource: issuer,
|
||||
authorization_servers: [issuer],
|
||||
bearer_methods_supported: ['header'],
|
||||
})
|
||||
}
|
||||
|
||||
private readBasicAuth(req: Request): { clientId: string; clientSecret: string } | null {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.toLowerCase().startsWith('basic ')) return null
|
||||
|
||||
const decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')
|
||||
const separator = decoded.indexOf(':')
|
||||
if (separator < 0) return null
|
||||
|
||||
return {
|
||||
clientId: decodeURIComponent(decoded.slice(0, separator)),
|
||||
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
private sendTokenSuccess(args: SendTokenSuccessArgs) {
|
||||
args.res.setHeader('Cache-Control', 'no-store')
|
||||
args.res.setHeader('Pragma', 'no-cache')
|
||||
return args.res.json(args.body)
|
||||
}
|
||||
|
||||
private sendTokenError(args: SendTokenErrorArgs) {
|
||||
args.res.setHeader('Cache-Control', 'no-store')
|
||||
return args.res.status(args.status).json({
|
||||
error: args.error,
|
||||
error_description: args.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { OAuthClientsSchemaTypeForSelect, OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { OAuthRepository } from './OAuthRepository'
|
||||
import {
|
||||
OAUTH_ACCESS_TOKEN_PREFIX,
|
||||
OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
OAUTH_CODE_TTL_SECONDS,
|
||||
OAUTH_REFRESH_TOKEN_TTL_SECONDS,
|
||||
type AuthenticateClientArgs,
|
||||
type ConnectedApp,
|
||||
type CreateAuthCodeArgs,
|
||||
type ExchangeCodeArgs,
|
||||
type IssueTokenPairArgs,
|
||||
type OAuthResult,
|
||||
type OAuthTokenResponse,
|
||||
type RefreshTokensArgs,
|
||||
type RegisterClientArgs,
|
||||
type RevokeOwnGrantArgs,
|
||||
type ValidateRedirectUriArgs,
|
||||
} from './types'
|
||||
import {
|
||||
matchesRegisteredRedirectUri,
|
||||
randomToken,
|
||||
resourceMatches,
|
||||
safeCompareHex,
|
||||
sha256Hex,
|
||||
verifyPkce,
|
||||
} from './oauth.utils'
|
||||
|
||||
export class OAuthManager {
|
||||
private static instance: OAuthManager | null = null
|
||||
|
||||
public readonly repository: OAuthRepository
|
||||
|
||||
constructor() {
|
||||
this.repository = new OAuthRepository()
|
||||
}
|
||||
|
||||
static getInstance(): OAuthManager {
|
||||
if (!OAuthManager.instance) OAuthManager.instance = new OAuthManager()
|
||||
return OAuthManager.instance
|
||||
}
|
||||
|
||||
async findClient(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
return this.repository.findClientByClientId(clientId)
|
||||
}
|
||||
|
||||
async validateRedirectUri(args: ValidateRedirectUriArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
|
||||
const client = await this.repository.findClientByClientId(args.clientId)
|
||||
if (!client) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
|
||||
}
|
||||
if (!matchesRegisteredRedirectUri({ candidate: args.redirectUri, registered: client.redirectUris })) {
|
||||
return { ok: false, error: 'invalid_request', description: 'redirect_uri does not match a registered URI' }
|
||||
}
|
||||
return { ok: true, value: client }
|
||||
}
|
||||
|
||||
async issueAuthCode(args: CreateAuthCodeArgs): Promise<string | null> {
|
||||
const code = randomToken()
|
||||
const created = await this.repository.createAuthCode({
|
||||
...args,
|
||||
codeHash: sha256Hex(code),
|
||||
expiresAt: new Date(Date.now() + OAUTH_CODE_TTL_SECONDS * 1000),
|
||||
})
|
||||
if (!created) return null
|
||||
|
||||
this.repository.deleteExpiredAuthCodes().catch(() => {})
|
||||
return code
|
||||
}
|
||||
|
||||
async exchangeCode(args: ExchangeCodeArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
const record = await this.repository.findAuthCodeByHash(sha256Hex(args.code))
|
||||
if (!record) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code is not valid' }
|
||||
}
|
||||
|
||||
// A code presented twice means it leaked. Kill the grant it already produced.
|
||||
if (record.usedAt) {
|
||||
if (record.grantId) await this.repository.revokeGrant({ grantId: record.grantId })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
|
||||
}
|
||||
if (record.expiresAt < new Date()) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has expired' }
|
||||
}
|
||||
if (record.clientId !== args.clientId) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code was issued to another client' }
|
||||
}
|
||||
if (record.redirectUri !== args.redirectUri) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'redirect_uri does not match the authorization request' }
|
||||
}
|
||||
if (!verifyPkce({ codeVerifier: args.codeVerifier, codeChallenge: record.codeChallenge })) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'code_verifier does not match code_challenge' }
|
||||
}
|
||||
if (!resourceMatches({ granted: record.resource, requested: args.resource })) {
|
||||
return { ok: false, error: 'invalid_target', description: 'resource does not match the authorization request' }
|
||||
}
|
||||
|
||||
const client = await this.repository.findClientByClientId(record.clientId)
|
||||
|
||||
const grant = await this.repository.createGrant({
|
||||
userId: record.userId,
|
||||
clientId: record.clientId,
|
||||
allowedPermissions: record.allowedPermissions,
|
||||
allowedGoalIds: record.allowedGoalIds,
|
||||
resource: record.resource,
|
||||
})
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not create the grant' }
|
||||
}
|
||||
|
||||
const consumed = await this.repository.consumeAuthCode({ id: record.id, grantId: grant.id })
|
||||
if (!consumed) {
|
||||
await this.repository.revokeGrant({ grantId: grant.id })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
|
||||
}
|
||||
|
||||
return this.issueTokenPair({ grant, clientName: client?.name ?? record.clientId })
|
||||
}
|
||||
|
||||
async refreshTokens(args: RefreshTokensArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
const presentedHash = sha256Hex(args.refreshToken)
|
||||
const grant = await this.repository.findGrantByRefreshHash(presentedHash)
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token is not valid' }
|
||||
}
|
||||
if (grant.revokedAt) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
|
||||
}
|
||||
|
||||
// The previous token in the rotation chain showing up means it leaked.
|
||||
if (grant.refreshTokenPrevHash && safeCompareHex(grant.refreshTokenPrevHash, presentedHash)) {
|
||||
await this.repository.revokeGrant({ grantId: grant.id })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token was reused; the authorization has been revoked' }
|
||||
}
|
||||
if (grant.refreshExpiresAt && grant.refreshExpiresAt < new Date()) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token has expired' }
|
||||
}
|
||||
if (grant.clientId !== args.clientId) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token was issued to another client' }
|
||||
}
|
||||
if (!resourceMatches({ granted: grant.resource, requested: args.resource })) {
|
||||
return { ok: false, error: 'invalid_target', description: 'resource does not match the granted audience' }
|
||||
}
|
||||
|
||||
const client = await this.repository.findClientByClientId(grant.clientId)
|
||||
return this.issueTokenPair({ grant, clientName: client?.name ?? grant.clientId, prevRefreshHash: presentedHash })
|
||||
}
|
||||
|
||||
async registerClient(args: RegisterClientArgs): Promise<OAuthResult<{ clientId: string; clientSecret: string | null }>> {
|
||||
const clientId = randomBytes(16).toString('hex')
|
||||
const clientSecret = args.isPublic ? null : randomToken()
|
||||
|
||||
const client = await this.repository.createClient({
|
||||
clientId,
|
||||
clientSecretHash: clientSecret ? sha256Hex(clientSecret) : null,
|
||||
name: args.name.slice(0, 200),
|
||||
redirectUris: args.redirectUris,
|
||||
createdVia: 'dcr',
|
||||
})
|
||||
if (!client) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not register the client' }
|
||||
}
|
||||
return { ok: true, value: { clientId, clientSecret } }
|
||||
}
|
||||
|
||||
async authenticateClient(args: AuthenticateClientArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
|
||||
const client = await this.repository.findClientByClientId(args.clientId)
|
||||
if (!client) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
|
||||
}
|
||||
if (client.clientSecretHash) {
|
||||
if (!args.clientSecret || !safeCompareHex(client.clientSecretHash, sha256Hex(args.clientSecret))) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Client authentication failed' }
|
||||
}
|
||||
}
|
||||
return { ok: true, value: client }
|
||||
}
|
||||
|
||||
async fetchConnectedApps(userId: number): Promise<ConnectedApp[]> {
|
||||
const grants = await this.repository.fetchGrantsByUserId(userId)
|
||||
if (!grants.length) return []
|
||||
|
||||
const clients = await this.repository.fetchClientsByClientIds(grants.map((grant) => grant.clientId))
|
||||
const nameByClientId = new Map(clients.map((client) => [client.clientId, client.name]))
|
||||
|
||||
return grants.map((grant) => ({
|
||||
grantId: grant.id,
|
||||
clientId: grant.clientId,
|
||||
clientName: nameByClientId.get(grant.clientId) ?? grant.clientId,
|
||||
allowedPermissions: grant.allowedPermissions,
|
||||
allowedGoalIds: grant.allowedGoalIds,
|
||||
createdAt: grant.createdAt,
|
||||
lastUsedAt: grant.lastUsedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
async revokeGrant(args: RevokeOwnGrantArgs): Promise<boolean> {
|
||||
return this.repository.revokeGrant(args)
|
||||
}
|
||||
|
||||
/** RFC 7009: accepts either an access token or a refresh token. */
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
const hash = sha256Hex(token)
|
||||
|
||||
const grantId = await this.repository.findGrantIdByAccessTokenHash(hash)
|
||||
if (grantId) {
|
||||
await this.repository.revokeGrant({ grantId })
|
||||
return
|
||||
}
|
||||
|
||||
const grant = await this.repository.findGrantByRefreshHash(hash)
|
||||
if (grant) await this.repository.revokeGrant({ grantId: grant.id })
|
||||
}
|
||||
|
||||
private async issueTokenPair(args: IssueTokenPairArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
// Copied straight from the grant: these are the RBAC keys the user
|
||||
// ticked. An empty list means "do not narrow anything", the same thing
|
||||
// it means for a manually issued tvk_ token.
|
||||
const allowedPermissions = args.grant.allowedPermissions
|
||||
const accessToken = OAUTH_ACCESS_TOKEN_PREFIX + randomToken()
|
||||
const refreshToken = randomToken()
|
||||
const now = Date.now()
|
||||
|
||||
const rotated = await this.repository.rotateRefreshToken({
|
||||
grantId: args.grant.id,
|
||||
refreshTokenHash: sha256Hex(refreshToken),
|
||||
prevHash: args.prevRefreshHash ?? null,
|
||||
refreshExpiresAt: new Date(now + OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000),
|
||||
presentedHash: args.prevRefreshHash ?? null,
|
||||
})
|
||||
if (!rotated) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
|
||||
}
|
||||
|
||||
const created = await this.repository.createAccessToken({
|
||||
userId: args.grant.userId,
|
||||
name: args.clientName.slice(0, 100),
|
||||
tokenHash: sha256Hex(accessToken),
|
||||
allowedPermissions,
|
||||
allowedGoalIds: args.grant.allowedGoalIds,
|
||||
grantId: args.grant.id,
|
||||
expiresAt: new Date(now + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000),
|
||||
})
|
||||
if (!created) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not issue the access token' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { and, eq, isNull, lt, or } from 'drizzle-orm'
|
||||
import {
|
||||
ApiTokensSchema,
|
||||
OAuthAuthCodesSchema,
|
||||
OAuthClientsSchema,
|
||||
OAuthGrantsSchema,
|
||||
type OAuthAuthCodesSchemaTypeForSelect,
|
||||
type OAuthClientsSchemaTypeForSelect,
|
||||
type OAuthGrantsSchemaTypeForInsert,
|
||||
type OAuthGrantsSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type {
|
||||
ConsumeAuthCodeArgs,
|
||||
CreateAccessTokenArgs,
|
||||
CreateAuthCodeArgs,
|
||||
CreateOAuthClientArgs,
|
||||
RevokeGrantArgs,
|
||||
RotateRefreshTokenArgs,
|
||||
} from './types'
|
||||
|
||||
export class OAuthRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async createClient(data: CreateOAuthClientArgs): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthClientsSchema).values(data).returning(),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async findClientByClientId(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthClientsSchema).where(eq(OAuthClientsSchema.clientId, clientId)),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async createAuthCode(data: CreateAuthCodeArgs & { codeHash: string; expiresAt: Date }): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthAuthCodesSchema).values({
|
||||
codeHash: data.codeHash,
|
||||
clientId: data.clientId,
|
||||
userId: data.userId,
|
||||
redirectUri: data.redirectUri,
|
||||
codeChallenge: data.codeChallenge,
|
||||
codeChallengeMethod: data.codeChallengeMethod,
|
||||
allowedPermissions: data.allowedPermissions,
|
||||
allowedGoalIds: data.allowedGoalIds,
|
||||
resource: data.resource,
|
||||
expiresAt: data.expiresAt,
|
||||
}).returning(),
|
||||
)
|
||||
return !!result?.length
|
||||
}
|
||||
|
||||
async findAuthCodeByHash(codeHash: string): Promise<OAuthAuthCodesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthAuthCodesSchema).where(eq(OAuthAuthCodesSchema.codeHash, codeHash)),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-use redemption. The UPDATE only matches while used_at is still NULL,
|
||||
* so two concurrent exchanges of the same code cannot both win.
|
||||
*/
|
||||
async consumeAuthCode(args: ConsumeAuthCodeArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthAuthCodesSchema)
|
||||
.set({ usedAt: new Date(), grantId: args.grantId })
|
||||
.where(and(eq(OAuthAuthCodesSchema.id, args.id), isNull(OAuthAuthCodesSchema.usedAt))),
|
||||
)
|
||||
return !!result?.rowCount
|
||||
}
|
||||
|
||||
async deleteExpiredAuthCodes(): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(OAuthAuthCodesSchema).where(lt(OAuthAuthCodesSchema.expiresAt, new Date())),
|
||||
)
|
||||
}
|
||||
|
||||
async createGrant(data: OAuthGrantsSchemaTypeForInsert): Promise<OAuthGrantsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthGrantsSchema).values(data).returning(),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
/** Matches the current refresh token or the previous one, so replay is detectable. */
|
||||
async findGrantByRefreshHash(refreshTokenHash: string): Promise<OAuthGrantsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
|
||||
or(
|
||||
eq(OAuthGrantsSchema.refreshTokenHash, refreshTokenHash),
|
||||
eq(OAuthGrantsSchema.refreshTokenPrevHash, refreshTokenHash),
|
||||
),
|
||||
),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async rotateRefreshToken(args: RotateRefreshTokenArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthGrantsSchema)
|
||||
.set({
|
||||
refreshTokenHash: args.refreshTokenHash,
|
||||
refreshTokenPrevHash: args.prevHash,
|
||||
refreshExpiresAt: args.refreshExpiresAt,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(OAuthGrantsSchema.id, args.grantId),
|
||||
isNull(OAuthGrantsSchema.revokedAt),
|
||||
// The presented token must still be the current one. Without this,
|
||||
// two concurrent refreshes both succeed and the second overwrites
|
||||
// the first, silently orphaning the refresh token it just handed out.
|
||||
args.presentedHash
|
||||
? eq(OAuthGrantsSchema.refreshTokenHash, args.presentedHash)
|
||||
: isNull(OAuthGrantsSchema.refreshTokenHash),
|
||||
)),
|
||||
)
|
||||
return !!result?.rowCount
|
||||
}
|
||||
|
||||
async fetchGrantsByUserId(userId: number): Promise<OAuthGrantsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
|
||||
and(eq(OAuthGrantsSchema.userId, userId), isNull(OAuthGrantsSchema.revokedAt)),
|
||||
),
|
||||
)
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
async fetchClientsByClientIds(clientIds: string[]): Promise<OAuthClientsSchemaTypeForSelect[]> {
|
||||
if (!clientIds.length) return []
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthClientsSchema),
|
||||
)
|
||||
return (result ?? []).filter((client) => clientIds.includes(client.clientId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoking a grant also deletes its live access tokens — that is the whole
|
||||
* point of storing them as opaque rows instead of self-contained JWTs.
|
||||
*/
|
||||
async revokeGrant(args: RevokeGrantArgs): Promise<boolean> {
|
||||
const where = args.userId === undefined
|
||||
? eq(OAuthGrantsSchema.id, args.grantId)
|
||||
: and(eq(OAuthGrantsSchema.id, args.grantId), eq(OAuthGrantsSchema.userId, args.userId))
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthGrantsSchema)
|
||||
.set({ revokedAt: new Date(), refreshTokenHash: null, refreshTokenPrevHash: null })
|
||||
.where(where),
|
||||
)
|
||||
if (!result?.rowCount) return false
|
||||
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(ApiTokensSchema).where(eq(ApiTokensSchema.grantId, args.grantId)),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async createAccessToken(data: CreateAccessTokenArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning(),
|
||||
)
|
||||
return !!result?.length
|
||||
}
|
||||
|
||||
async findGrantIdByAccessTokenHash(tokenHash: string): Promise<number | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ grantId: ApiTokensSchema.grantId })
|
||||
.from(ApiTokensSchema)
|
||||
.where(eq(ApiTokensSchema.tokenHash, tokenHash)),
|
||||
)
|
||||
return result?.[0]?.grantId ?? null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { OAuthController } from './OAuthController'
|
||||
|
||||
export default class OAuthRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: OAuthController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new OAuthController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/authorize', this.controller.authorize)
|
||||
this.router.post('/token', this.controller.token)
|
||||
this.router.post('/revoke', this.controller.revoke)
|
||||
this.router.post('/register', this.controller.register)
|
||||
|
||||
// Consent is the one place a real human decides. It must be a browser
|
||||
// session, never an API token acting on the user's behalf.
|
||||
this.router.post('/consent', [IsLoggedIn, RejectApiTokenAuth], this.controller.consent)
|
||||
this.router.post('/consent/deny', [IsLoggedIn, RejectApiTokenAuth], this.controller.denyConsent)
|
||||
|
||||
this.router.get('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchConnectedApps)
|
||||
this.router.delete('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.revokeConnectedApp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { OAuthController } from './OAuthController'
|
||||
|
||||
/**
|
||||
* RFC 8414 / RFC 9728 discovery. These must sit at the origin root, not under
|
||||
* /module, because that is where clients look before they have any credentials.
|
||||
*/
|
||||
export default class OAuthWellKnownRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: OAuthController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new OAuthController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/oauth-authorization-server', this.controller.authorizationServerMetadata)
|
||||
this.router.get('/oauth-protected-resource', this.controller.protectedResourceMetadata)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios';
|
||||
import type http from 'http';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../../App';
|
||||
|
||||
const port = 1812;
|
||||
const url = `http://localhost:${port}`;
|
||||
|
||||
let server: http.Server;
|
||||
const api = axios.create({ baseURL: url, validateStatus: () => true });
|
||||
|
||||
/**
|
||||
* OAUTH_DYNAMIC_REGISTRATION=false is the lever a self-hosted operator pulls to
|
||||
* keep the client registry closed. It has to do two things: refuse registration,
|
||||
* and stop advertising the endpoint — a client that reads the metadata should
|
||||
* never attempt a registration this instance will reject.
|
||||
*/
|
||||
describe('OAuth with dynamic client registration disabled', () => {
|
||||
vi.mock('emailjs', () => ({
|
||||
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OAUTH_DYNAMIC_REGISTRATION = 'false';
|
||||
server = new App(port).listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
delete process.env.OAUTH_DYNAMIC_REGISTRATION;
|
||||
});
|
||||
|
||||
it('refuses to register a client', async () => {
|
||||
const response = await api.post('/module/oauth/register', {
|
||||
client_name: 'Should be refused',
|
||||
redirect_uris: ['https://client.test/cb'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.data.error).toBe('access_denied');
|
||||
});
|
||||
|
||||
it('stops advertising the registration endpoint in the metadata', async () => {
|
||||
const response = await api.get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.registration_endpoint).toBeUndefined();
|
||||
// The rest of the flow stays available for manually seeded clients.
|
||||
expect(response.data.authorization_endpoint).toContain('/module/oauth/authorize');
|
||||
expect(response.data.token_endpoint).toContain('/module/oauth/token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
const repositoryMock = {
|
||||
findClientByClientId: vi.fn(),
|
||||
findAuthCodeByHash: vi.fn(),
|
||||
consumeAuthCode: vi.fn(),
|
||||
createGrant: vi.fn(),
|
||||
createAccessToken: vi.fn(),
|
||||
rotateRefreshToken: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
findGrantByRefreshHash: vi.fn(),
|
||||
findGrantIdByAccessTokenHash: vi.fn(),
|
||||
createAuthCode: vi.fn(),
|
||||
deleteExpiredAuthCodes: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../OAuthRepository', () => ({
|
||||
OAuthRepository: vi.fn(() => repositoryMock),
|
||||
}))
|
||||
|
||||
const { OAuthManager } = await import('../OAuthManager')
|
||||
|
||||
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
const VERIFIER = randomBytes(40).toString('base64url')
|
||||
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url')
|
||||
|
||||
const validCode = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
codeHash: sha256('the-code'),
|
||||
clientId: 'client-a',
|
||||
userId: 7,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
codeChallenge: CHALLENGE,
|
||||
codeChallengeMethod: 'S256',
|
||||
allowedPermissions: ['goal_can_watch_content'],
|
||||
allowedGoalIds: [],
|
||||
resource: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
usedAt: null,
|
||||
grantId: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const validGrant = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 42,
|
||||
userId: 7,
|
||||
clientId: 'client-a',
|
||||
allowedPermissions: ['goal_can_watch_content'],
|
||||
allowedGoalIds: [],
|
||||
resource: null,
|
||||
refreshTokenHash: sha256('current-refresh'),
|
||||
refreshTokenPrevHash: null,
|
||||
refreshExpiresAt: new Date(Date.now() + 86_400_000),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const exchangeArgs = (overrides: Record<string, unknown> = {}) => ({
|
||||
code: 'the-code',
|
||||
codeVerifier: VERIFIER,
|
||||
clientId: 'client-a',
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
resource: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OAuthManager.exchangeCode', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
|
||||
repositoryMock.createGrant.mockResolvedValue(validGrant())
|
||||
repositoryMock.consumeAuthCode.mockResolvedValue(true)
|
||||
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
|
||||
repositoryMock.createAccessToken.mockResolvedValue(true)
|
||||
repositoryMock.revokeGrant.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('issues a tvo_ access token and a refresh token on a valid exchange', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.access_token.startsWith('tvo_')).toBe(true)
|
||||
expect(result.value.refresh_token).toBeTruthy()
|
||||
expect(result.value.token_type).toBe('Bearer')
|
||||
})
|
||||
|
||||
it('stores only the hash of the access token, never the token itself', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.tokenHash).toBe(sha256(result.value.access_token))
|
||||
expect(JSON.stringify(stored)).not.toContain(result.value.access_token)
|
||||
})
|
||||
|
||||
it('rejects a wrong code_verifier', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ codeVerifier: randomBytes(40).toString('base64url') }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.createGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a redirect_uri that differs from the authorization request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ redirectUri: 'https://app.example.com/other' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('rejects a code presented by a different client', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ clientId: 'client-b' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('rejects an expired code', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ expiresAt: new Date(Date.now() - 1000) }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('revokes the original grant when a used code is replayed', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ usedAt: new Date(), grantId: 42 }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('rolls back the grant when the code was consumed by a concurrent request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
repositoryMock.consumeAuthCode.mockResolvedValue(false)
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('rejects a resource that does not match the authorization request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ resource: 'https://mcp.example.com' }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ resource: 'https://other.example.com' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_target' })
|
||||
})
|
||||
|
||||
it('rejects an unknown code', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(null)
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthManager.refreshTokens', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
|
||||
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
|
||||
repositoryMock.createAccessToken.mockResolvedValue(true)
|
||||
repositoryMock.revokeGrant.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('rotates the refresh token and keeps the presented one as the previous hash', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.refresh_token).not.toBe('current-refresh')
|
||||
|
||||
const rotation = repositoryMock.rotateRefreshToken.mock.calls[0][0]
|
||||
expect(rotation.prevHash).toBe(sha256('current-refresh'))
|
||||
expect(rotation.refreshTokenHash).toBe(sha256(result.value.refresh_token))
|
||||
})
|
||||
|
||||
it('revokes the whole grant when a superseded refresh token is replayed', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
refreshTokenHash: sha256('rotated-refresh'),
|
||||
refreshTokenPrevHash: sha256('leaked-refresh'),
|
||||
}))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'leaked-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('refuses a revoked grant', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ revokedAt: new Date() }))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('refuses an expired refresh token', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
refreshExpiresAt: new Date(Date.now() - 1000),
|
||||
}))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('refuses a refresh token presented by another client', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-b',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('carries the consented permission keys onto the reissued token', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
allowedPermissions: ['timetracking_can_view', 'task_can_edit_status'],
|
||||
}))
|
||||
|
||||
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
|
||||
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.allowedPermissions).toEqual(['timetracking_can_view', 'task_can_edit_status'])
|
||||
})
|
||||
|
||||
it('keeps an unrestricted grant unrestricted, which is what "all permissions" means', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ allowedPermissions: [] }))
|
||||
|
||||
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
|
||||
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.allowedPermissions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthManager.authenticateClient', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
})
|
||||
|
||||
it('accepts a public client with no secret', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'pub', clientSecretHash: null })
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'pub' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a confidential client presenting the wrong secret', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({
|
||||
clientId: 'conf',
|
||||
clientSecretHash: sha256('right'),
|
||||
})
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'conf', clientSecret: 'wrong' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
|
||||
it('rejects a confidential client presenting no secret at all', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({
|
||||
clientId: 'conf',
|
||||
clientSecretHash: sha256('right'),
|
||||
})
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'conf' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
|
||||
it('rejects an unknown client', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue(null)
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'nope' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import {
|
||||
buildRedirectUrl,
|
||||
isAcceptableRedirectUri,
|
||||
matchesRegisteredRedirectUri,
|
||||
resourceMatches,
|
||||
verifyPkce,
|
||||
} from '../oauth.utils'
|
||||
|
||||
const challengeFor = (verifier: string) =>
|
||||
createHash('sha256').update(verifier).digest('base64url')
|
||||
|
||||
describe('verifyPkce', () => {
|
||||
const verifier = randomBytes(40).toString('base64url')
|
||||
|
||||
it('accepts the verifier that produced the challenge', () => {
|
||||
expect(verifyPkce({ codeVerifier: verifier, codeChallenge: challengeFor(verifier) })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different verifier', () => {
|
||||
const other = randomBytes(40).toString('base64url')
|
||||
expect(verifyPkce({ codeVerifier: other, codeChallenge: challengeFor(verifier) })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a verifier shorter than the RFC 7636 minimum', () => {
|
||||
const short = 'abc'
|
||||
expect(verifyPkce({ codeVerifier: short, codeChallenge: challengeFor(short) })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a verifier longer than the RFC 7636 maximum', () => {
|
||||
const long = 'a'.repeat(129)
|
||||
expect(verifyPkce({ codeVerifier: long, codeChallenge: challengeFor(long) })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesRegisteredRedirectUri', () => {
|
||||
it('accepts an exact match', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com/callback',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different path on the same host', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com/evil',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an attacker host that merely prefixes the registered one', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com.evil.test/callback',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('allows any loopback port for the same path (RFC 8252)', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'http://127.0.0.1:55123/callback',
|
||||
registered: ['http://127.0.0.1:8080/callback'],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extend the loopback port carve-out to other hosts', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://example.com:9999/callback',
|
||||
registered: ['https://example.com:443/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a loopback candidate whose path differs', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'http://127.0.0.1:55123/other',
|
||||
registered: ['http://127.0.0.1:8080/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unparseable candidate', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'not a url',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAcceptableRedirectUri', () => {
|
||||
it('accepts https', () => {
|
||||
expect(isAcceptableRedirectUri('https://example.com/cb')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts loopback http', () => {
|
||||
expect(isAcceptableRedirectUri('http://127.0.0.1:1234/cb')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a URI carrying a fragment', () => {
|
||||
expect(isAcceptableRedirectUri('https://example.com/cb#token')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a private-use scheme, which is how native apps come back (RFC 8252)', () => {
|
||||
expect(isAcceptableRedirectUri('com.example.app://oauth/callback')).toBe(true)
|
||||
expect(isAcceptableRedirectUri('myapp://callback')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects schemes where redirecting would execute something', () => {
|
||||
expect(isAcceptableRedirectUri('javascript:alert(1)')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('data:text/html,<script>alert(1)</script>')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('vbscript:msgbox(1)')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('file:///etc/passwd')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('blob:https://example.com/x')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('about:blank')).toBe(false)
|
||||
})
|
||||
|
||||
it('still refuses plaintext http off loopback', () => {
|
||||
expect(isAcceptableRedirectUri('http://evil.example.com/cb')).toBe(false)
|
||||
})
|
||||
|
||||
it('still refuses a fragment on any scheme', () => {
|
||||
expect(isAcceptableRedirectUri('com.example.app://cb#token')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a string that is not a URL at all', () => {
|
||||
expect(isAcceptableRedirectUri('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRedirectUrl', () => {
|
||||
it('appends parameters and skips undefined ones', () => {
|
||||
const url = buildRedirectUrl({
|
||||
redirectUri: 'https://example.com/cb?existing=1',
|
||||
params: { code: 'abc', state: undefined },
|
||||
})
|
||||
expect(url).toBe('https://example.com/cb?existing=1&code=abc')
|
||||
})
|
||||
|
||||
it('encodes parameter values', () => {
|
||||
const url = buildRedirectUrl({
|
||||
redirectUri: 'https://example.com/cb',
|
||||
params: { state: 'a b&c' },
|
||||
})
|
||||
expect(url).toContain('state=a+b%26c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resourceMatches', () => {
|
||||
it('ignores a trailing slash', () => {
|
||||
expect(resourceMatches({ granted: 'https://mcp.example.com/', requested: 'https://mcp.example.com' })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a token replayed against another resource', () => {
|
||||
expect(resourceMatches({ granted: 'https://mcp.example.com', requested: 'https://other.example.com' })).toBe(false)
|
||||
})
|
||||
|
||||
it('is permissive when the grant carries no audience', () => {
|
||||
expect(resourceMatches({ granted: null, requested: 'https://mcp.example.com' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import type {
|
||||
BuildRedirectUrlArgs,
|
||||
MatchRedirectUriArgs,
|
||||
ResourceMatchArgs,
|
||||
VerifyPkceArgs,
|
||||
} from './types'
|
||||
|
||||
export function isDcrEnabled(): boolean {
|
||||
const raw = process.env.OAUTH_DYNAMIC_REGISTRATION
|
||||
if (raw === undefined || raw.trim() === '') return true
|
||||
return raw.trim().toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
export function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export function randomToken(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function safeCompareHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
|
||||
}
|
||||
|
||||
export function verifyPkce(args: VerifyPkceArgs): boolean {
|
||||
if (args.codeVerifier.length < 43 || args.codeVerifier.length > 128) return false
|
||||
const digest = createHash('sha256').update(args.codeVerifier).digest('base64url')
|
||||
return safeCompareHex(digest, args.codeChallenge)
|
||||
}
|
||||
|
||||
function isLoopbackUrl(url: URL): boolean {
|
||||
return url.hostname === '127.0.0.1'
|
||||
|| url.hostname === '::1'
|
||||
|| url.hostname === '[::1]'
|
||||
|| url.hostname === 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact match, with one carve-out: RFC 8252 lets a native client bind an
|
||||
* arbitrary loopback port, so the port is ignored for 127.0.0.1 / ::1 only.
|
||||
* Everything else must match the registered string exactly — this is the guard
|
||||
* against turning /authorize into an open redirect.
|
||||
*/
|
||||
export function matchesRegisteredRedirectUri(args: MatchRedirectUriArgs): boolean {
|
||||
if (args.registered.includes(args.candidate)) return true
|
||||
|
||||
let candidateUrl: URL
|
||||
try {
|
||||
candidateUrl = new URL(args.candidate)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!isLoopbackUrl(candidateUrl)) return false
|
||||
|
||||
return args.registered.some((registered) => {
|
||||
try {
|
||||
const registeredUrl = new URL(registered)
|
||||
return (
|
||||
isLoopbackUrl(registeredUrl)
|
||||
&& registeredUrl.protocol === candidateUrl.protocol
|
||||
&& registeredUrl.hostname === candidateUrl.hostname
|
||||
&& registeredUrl.pathname === candidateUrl.pathname
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemes where "redirecting" means executing something rather than handing
|
||||
* control to an application. Everything else is allowed: RFC 8252 §7.1 has
|
||||
* native apps return through a private-use scheme (com.example.app://), and
|
||||
* refusing those locks every mobile client out of the flow. Interception of a
|
||||
* private-use scheme by another app on the device is what PKCE — mandatory here,
|
||||
* S256 only — exists to make useless.
|
||||
*/
|
||||
const DANGEROUS_REDIRECT_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:']
|
||||
|
||||
export function isAcceptableRedirectUri(raw: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.hash) return false
|
||||
if (DANGEROUS_REDIRECT_SCHEMES.includes(parsed.protocol)) return false
|
||||
if (parsed.protocol === 'https:') return true
|
||||
// http stays loopback-only. There is deliberately no NODE_ENV escape hatch:
|
||||
// an install running without NODE_ENV=production would otherwise let any
|
||||
// client register a plaintext redirect to a host it does not control.
|
||||
if (parsed.protocol === 'http:') return isLoopbackUrl(parsed)
|
||||
return true
|
||||
}
|
||||
|
||||
export function buildRedirectUrl(args: BuildRedirectUrlArgs): string {
|
||||
const url = new URL(args.redirectUri)
|
||||
for (const [key, value] of Object.entries(args.params)) {
|
||||
if (value !== undefined) url.searchParams.set(key, value)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding: a token minted for one MCP resource must not be
|
||||
* replayable against another. Compared on origin + path, ignoring trailing slash.
|
||||
*/
|
||||
export function resourceMatches(args: ResourceMatchArgs): boolean {
|
||||
if (!args.granted || !args.requested) return true
|
||||
const normalize = (value: string) => value.replace(/\/+$/, '').toLowerCase()
|
||||
return normalize(args.granted) === normalize(args.requested)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Response } from 'express'
|
||||
import type { OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const OAUTH_ACCESS_TOKEN_PREFIX = 'tvo_'
|
||||
export const OAUTH_CODE_TTL_SECONDS = 60
|
||||
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60
|
||||
export const OAUTH_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
export const OAuthAuthorizeArkType = type({
|
||||
response_type: "'code'",
|
||||
client_id: 'string > 0',
|
||||
redirect_uri: 'string > 0',
|
||||
code_challenge: 'string > 0',
|
||||
code_challenge_method: "'S256'",
|
||||
'state?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthAuthorizeArgs = typeof OAuthAuthorizeArkType.infer
|
||||
|
||||
export const OAuthConsentArkType = type({
|
||||
client_id: 'string > 0',
|
||||
redirect_uri: 'string > 0',
|
||||
code_challenge: 'string > 0',
|
||||
code_challenge_method: "'S256'",
|
||||
'allowedPermissions?': 'string[]',
|
||||
'allowedGoalIds?': 'number[]',
|
||||
'state?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthConsentArgs = typeof OAuthConsentArkType.infer
|
||||
|
||||
export const OAuthTokenArkType = type({
|
||||
grant_type: "'authorization_code'|'refresh_token'",
|
||||
'client_id?': 'string',
|
||||
'client_secret?': 'string',
|
||||
'code?': 'string',
|
||||
'code_verifier?': 'string',
|
||||
'redirect_uri?': 'string',
|
||||
'refresh_token?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthTokenArgs = typeof OAuthTokenArkType.infer
|
||||
|
||||
export const OAuthRegisterArkType = type({
|
||||
'client_name?': 'string',
|
||||
redirect_uris: 'string[] > 0',
|
||||
'token_endpoint_auth_method?': 'string',
|
||||
'grant_types?': 'string[]',
|
||||
'response_types?': 'string[]',
|
||||
})
|
||||
|
||||
export type OAuthRegisterArgs = typeof OAuthRegisterArkType.infer
|
||||
|
||||
export const OAuthRevokeArkType = type({
|
||||
token: 'string > 0',
|
||||
'token_type_hint?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthRevokeArgs = typeof OAuthRevokeArkType.infer
|
||||
|
||||
export type CreateAuthCodeArgs = {
|
||||
clientId: string
|
||||
userId: number
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
codeChallengeMethod: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type ExchangeCodeArgs = {
|
||||
code: string
|
||||
codeVerifier: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type RefreshTokensArgs = {
|
||||
refreshToken: string
|
||||
clientId: string
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type RegisterClientArgs = {
|
||||
name: string
|
||||
redirectUris: string[]
|
||||
isPublic: boolean
|
||||
}
|
||||
|
||||
export type OAuthTokenResponse = {
|
||||
access_token: string
|
||||
token_type: 'Bearer'
|
||||
expires_in: number
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export type OAuthFailure = { ok: false; error: string; description: string }
|
||||
export type OAuthSuccess<T> = { ok: true; value: T }
|
||||
export type OAuthResult<T> = OAuthSuccess<T> | OAuthFailure
|
||||
|
||||
export type ConnectedApp = {
|
||||
grantId: number
|
||||
clientId: string
|
||||
clientName: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
createdAt: Date
|
||||
lastUsedAt: Date | null
|
||||
}
|
||||
|
||||
export type CreateOAuthClientArgs = {
|
||||
clientId: string
|
||||
clientSecretHash: string | null
|
||||
name: string
|
||||
redirectUris: string[]
|
||||
createdVia: string
|
||||
}
|
||||
|
||||
export type ConsumeAuthCodeArgs = {
|
||||
id: number
|
||||
grantId: number
|
||||
}
|
||||
|
||||
export type RotateRefreshTokenArgs = {
|
||||
grantId: number
|
||||
refreshTokenHash: string
|
||||
prevHash: string | null
|
||||
refreshExpiresAt: Date
|
||||
/** Hash the caller presented; null on the first issue, when the grant has none yet. */
|
||||
presentedHash: string | null
|
||||
}
|
||||
|
||||
export type RevokeGrantArgs = {
|
||||
grantId: number
|
||||
/** Omitted for server-side revocation; set when the owner revokes from the UI. */
|
||||
userId?: number
|
||||
}
|
||||
|
||||
export type RevokeOwnGrantArgs = {
|
||||
grantId: number
|
||||
userId: number
|
||||
}
|
||||
|
||||
export type CreateAccessTokenArgs = {
|
||||
userId: number
|
||||
name: string
|
||||
tokenHash: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
grantId: number
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
export type ValidateRedirectUriArgs = {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export type AuthenticateClientArgs = {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
export type IssueTokenPairArgs = {
|
||||
grant: OAuthGrantsSchemaTypeForSelect
|
||||
clientName: string
|
||||
prevRefreshHash?: string
|
||||
}
|
||||
|
||||
export type VerifyPkceArgs = {
|
||||
codeVerifier: string
|
||||
codeChallenge: string
|
||||
}
|
||||
|
||||
export type MatchRedirectUriArgs = {
|
||||
candidate: string
|
||||
registered: string[]
|
||||
}
|
||||
|
||||
export type BuildRedirectUrlArgs = {
|
||||
redirectUri: string
|
||||
params: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export type ResourceMatchArgs = {
|
||||
granted: string | null
|
||||
requested: string | null
|
||||
}
|
||||
|
||||
export type SendTokenSuccessArgs = {
|
||||
res: Response
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SendTokenErrorArgs = {
|
||||
res: Response
|
||||
status: number
|
||||
error: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { OrganizationController } from './OrganizationController'
|
||||
import { IsOrgAdmin } from './middlewares/IsOrgAdmin'
|
||||
import { IsOrgMember } from './middlewares/IsOrgMember'
|
||||
import { IsOrgOwner } from './middlewares/IsOrgOwner'
|
||||
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
|
||||
import { GoalPermissions } from '../../types/auth.types'
|
||||
|
||||
export default class OrganizationRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
@@ -21,16 +23,20 @@ export default class OrganizationRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn], this.controller.create)
|
||||
const canManage = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)
|
||||
const canManageMembers = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE_MEMBERS)
|
||||
const canView = RequireTokenPermission(GoalPermissions.ORG_CAN_VIEW)
|
||||
|
||||
this.router.post('', [IsLoggedIn, canManage], this.controller.create)
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch)
|
||||
|
||||
this.router.post('/members', [IsLoggedIn, IsOrgAdmin], this.controller.addMember)
|
||||
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin], this.controller.updateMemberRole)
|
||||
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin], this.controller.removeMember)
|
||||
this.router.post('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.addMember)
|
||||
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.updateMemberRole)
|
||||
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.removeMember)
|
||||
|
||||
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById)
|
||||
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin], this.controller.update)
|
||||
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner], this.controller.delete)
|
||||
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers)
|
||||
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember, canView], this.controller.getById)
|
||||
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.update)
|
||||
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner, canManage], this.controller.delete)
|
||||
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin, canView], this.controller.fetchMembers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import type http from 'http';
|
||||
import { join } from 'path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../../App';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
|
||||
const port = 1811;
|
||||
const url = `http://localhost:${port}`;
|
||||
const MIGRATION_DIR = join(__dirname, '../../../migrations/taskview/sql/1.65.0');
|
||||
|
||||
const LOGIN = 'test@mail.dest';
|
||||
const PASSWORD = 'user1!#Q';
|
||||
|
||||
let server: http.Server;
|
||||
let jwt = '';
|
||||
const createdTokenIds: number[] = [];
|
||||
const createdOrgIds: number[] = [];
|
||||
let ownedGoalId = 0;
|
||||
|
||||
const api = axios.create({ baseURL: url, validateStatus: () => true });
|
||||
|
||||
const asUser = () => ({ headers: { Authorization: `Bearer ${jwt}` } });
|
||||
|
||||
async function issueToken(allowedPermissions: string[]) {
|
||||
const response = await api.post(
|
||||
'/module/api-tokens',
|
||||
{ name: 'scope-probe', allowedPermissions, allowedGoalIds: [] },
|
||||
asUser(),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
createdTokenIds.push(response.data.response.item.id);
|
||||
return { headers: { Authorization: `Bearer ${response.data.response.token}` } };
|
||||
}
|
||||
|
||||
async function createOrg(auth: { headers: Record<string, string> }, name: string) {
|
||||
const response = await api.post('/module/organizations', { name }, auth);
|
||||
const id = response.data?.response?.id;
|
||||
if (id) createdOrgIds.push(id);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe('API token scope on organization-level surfaces', () => {
|
||||
vi.mock('emailjs', () => ({
|
||||
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = Database.getInstance();
|
||||
const client = await db.getClient();
|
||||
for (const file of (await fs.readdir(MIGRATION_DIR)).sort()) {
|
||||
await client.query(await fs.readFile(join(MIGRATION_DIR, file), 'utf-8'));
|
||||
}
|
||||
client.release();
|
||||
|
||||
server = new App(port).listen();
|
||||
|
||||
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
jwt = login.data.access;
|
||||
|
||||
// A goal the caller actually owns: otherwise IsGoalOwnerByGoalId answers 403
|
||||
// on its own and the webhook tests below would pass without the token check.
|
||||
const goal = await api.post('/module/goals', { name: `scope-goal-${Date.now()}` }, asUser());
|
||||
expect(goal.status).toBe(200);
|
||||
ownedGoalId = goal.data.response.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownedGoalId) {
|
||||
await api.delete('/module/goals', { ...asUser(), data: { goalId: ownedGoalId } });
|
||||
}
|
||||
for (const id of createdOrgIds) {
|
||||
await api.delete(`/module/organizations/${id}`, asUser());
|
||||
}
|
||||
for (const id of createdTokenIds) {
|
||||
await api.delete('/module/api-tokens', { ...asUser(), data: { id } });
|
||||
}
|
||||
server?.close();
|
||||
});
|
||||
|
||||
it('seeds the organization permission group', async () => {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ name: string }>(
|
||||
'SELECT name FROM tv_auth.permissions WHERE permission_group = 6 ORDER BY name',
|
||||
);
|
||||
expect(result?.rows.map((row) => row.name)).toEqual([
|
||||
'org_can_manage', 'org_can_manage_members', 'org_can_view', 'sso_can_manage', 'webhooks_can_manage',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers the new permissions for selection, with localized descriptions', async () => {
|
||||
const response = await api.get('/module/api-tokens/permissions', asUser());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const rows = response.data.response as {
|
||||
name: string;
|
||||
permissionGroup: number;
|
||||
descriptionLocales: Record<string, string> | null;
|
||||
}[];
|
||||
|
||||
const orgView = rows.find((row) => row.name === 'org_can_view');
|
||||
expect(orgView?.permissionGroup).toBe(6);
|
||||
expect(orgView?.descriptionLocales?.ru).toBeTruthy();
|
||||
|
||||
// Group 1 is enforced nowhere, so it must not be offered as a restriction.
|
||||
expect(rows.some((row) => row.permissionGroup === 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks a narrowly scoped token from creating an organization', async () => {
|
||||
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-denied');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a token that was given org_can_manage', async () => {
|
||||
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-allowed');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps an unrestricted token working, so existing integrations do not break', async () => {
|
||||
const token = await issueToken([]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-unrestricted');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('does not restrict a browser session', async () => {
|
||||
const response = await createOrg(asUser(), 'scope-probe-session');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('separates managing the organization from managing its members', async () => {
|
||||
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
const org = await createOrg(token, 'scope-probe-members');
|
||||
expect(org.status).toBe(200);
|
||||
|
||||
const response = await api.post(
|
||||
'/module/organizations/members',
|
||||
{ organizationId: org.data.response.id, email: LOGIN, role: 'admin' },
|
||||
token,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a token holding org_can_manage_members add one', async () => {
|
||||
const owner = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
const org = await createOrg(owner, 'scope-probe-members-ok');
|
||||
expect(org.status).toBe(200);
|
||||
|
||||
const member = await issueToken([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
|
||||
const response = await api.post(
|
||||
'/module/organizations/members',
|
||||
{ organizationId: org.data.response.id, email: `member-${Date.now()}@test.dest`, role: 'member' },
|
||||
member,
|
||||
);
|
||||
|
||||
// A precise status, not merely "not 403" — that would also pass on a 500.
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('blocks a narrowly scoped token from reaching webhooks of a goal it owns', async () => {
|
||||
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
|
||||
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets webhooks_can_manage through on that same goal, proving the 403 came from the token', async () => {
|
||||
const token = await issueToken([GoalPermissions.WEBHOOKS_CAN_MANAGE]);
|
||||
|
||||
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
|
||||
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
|
||||
import { RequireLoginMethod } from '../auth/middlewares/require-login-method'
|
||||
import { SsoController } from './SsoController'
|
||||
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
|
||||
import { GoalPermissions } from '../../types/auth.types'
|
||||
|
||||
export default class SsoRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
@@ -26,15 +28,17 @@ export default class SsoRoutes implements Routable {
|
||||
this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
|
||||
|
||||
const canManageSso = RequireTokenPermission(GoalPermissions.SSO_CAN_MANAGE)
|
||||
|
||||
this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls)
|
||||
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
|
||||
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
|
||||
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig)
|
||||
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig)
|
||||
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin], this.controller.startDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin], this.controller.checkDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken)
|
||||
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim)
|
||||
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.parseMetadata)
|
||||
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.listConfigs)
|
||||
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.createConfig)
|
||||
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.updateConfig)
|
||||
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.deleteConfig)
|
||||
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.startDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.checkDomainVerification)
|
||||
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.generateScimToken)
|
||||
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.toggleScim)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { WebhooksController } from './WebhooksController'
|
||||
import { IsGoalOwnerByGoalId, IsGoalOwnerByWebhookId } from './middlewares/IsGoalOwner'
|
||||
import { RequireTokenPermission } from '../../middlewares/require-token-permission';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
|
||||
export default class WebhooksRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
@@ -19,13 +21,15 @@ export default class WebhooksRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.fetch)
|
||||
this.router.post('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.create)
|
||||
this.router.patch('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.update)
|
||||
this.router.delete('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.delete)
|
||||
this.router.post('/rotate-secret', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.rotateSecret)
|
||||
this.router.post('/test', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.testDelivery)
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.fetchDeliveries)
|
||||
this.router.post('/retry', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.retryDelivery)
|
||||
const canManageWebhooks = RequireTokenPermission(GoalPermissions.WEBHOOKS_CAN_MANAGE)
|
||||
|
||||
this.router.get('', [IsLoggedIn, IsGoalOwnerByGoalId, canManageWebhooks], this.controller.fetch)
|
||||
this.router.post('', [IsLoggedIn, IsGoalOwnerByGoalId, canManageWebhooks], this.controller.create)
|
||||
this.router.patch('', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.update)
|
||||
this.router.delete('', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.delete)
|
||||
this.router.post('/rotate-secret', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.rotateSecret)
|
||||
this.router.post('/test', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.testDelivery)
|
||||
this.router.get('/deliveries/:id', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.fetchDeliveries)
|
||||
this.router.post('/retry', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.retryDelivery)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,12 @@ export const GoalPermissions = {
|
||||
TIMETRACKING_CAN_LOG: 'timetracking_can_log',
|
||||
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
|
||||
|
||||
ORG_CAN_VIEW: 'org_can_view',
|
||||
ORG_CAN_MANAGE: 'org_can_manage',
|
||||
ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members',
|
||||
SSO_CAN_MANAGE: 'sso_can_manage',
|
||||
WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage',
|
||||
|
||||
SPRINT_CAN_VIEW: 'sprint_can_view',
|
||||
SPRINT_CAN_MANAGE: 'sprint_can_manage',
|
||||
SPRINT_CAN_ASSIGN_TASKS: 'sprint_can_assign_tasks',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type OAuthConsentSelection = {
|
||||
allowedGoalIds: number[]
|
||||
allowedPermissions: string[]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: MCP Server
|
||||
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
|
||||
description: Connect AI assistants like Claude Code, claude.ai and ChatGPT to TaskView via the Model Context Protocol (MCP). Authenticate with a scoped API token or over OAuth.
|
||||
navigation:
|
||||
icon: i-lucide-bot
|
||||
---
|
||||
@@ -15,7 +15,7 @@ Shared (HTTP): AI client ──HTTPS──▶ taskview-mcp container ──
|
||||
```
|
||||
|
||||
- **Local (stdio)** — each user runs the server on their machine via `npx`; the token lives in the client config.
|
||||
- **Shared (HTTP)** — one server container runs next to your TaskView instance; every user connects to its URL and authenticates with their own API token per request.
|
||||
- **Shared (HTTP)** — one server container runs next to your TaskView instance; every user connects to its URL and authenticates with their own API token per request, or over [OAuth](#oauth-cloud-clients) for clients that cannot send a token header.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -56,7 +56,8 @@ The `gimanhead/taskview-ce-mcp` image serves MCP over HTTP (Streamable HTTP tran
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Where this MCP server forwards requests; the docker-network address
|
||||
# of your API service works best
|
||||
# of your API service works best. For OAuth it must be the public API
|
||||
# URL instead — see below.
|
||||
TASKVIEW_URL: "http://taskview-api-server:1401"
|
||||
ports:
|
||||
- "3100:3100"
|
||||
@@ -88,10 +89,43 @@ or in `.mcp.json` (Claude Code, Cursor, VS Code):
|
||||
|
||||
Put the port behind your reverse proxy with HTTPS for anything beyond local use.
|
||||
|
||||
::callout{icon="i-lucide-info" color="neutral"}
|
||||
The claude.ai and Claude Desktop **custom connectors** UI supports only OAuth-based servers and has no field for a token header — it cannot connect to this endpoint yet. Claude Desktop users should use the local stdio configuration above instead.
|
||||
::
|
||||
## OAuth (cloud clients)
|
||||
|
||||
Cloud assistants have no field for a pasted token: claude.ai and Claude Desktop **custom connectors**, ChatGPT connectors and similar clients authorize over OAuth 2.1 instead. The shared HTTP server supports this with nothing extra to run. The MCP server presents itself as the protected resource and names your TaskView API as the authorization server; the API handles client registration, the consent screen and tokens.
|
||||
|
||||
What happens when a client connects:
|
||||
|
||||
1. The client calls `/mcp` without a token and gets `401` with a `WWW-Authenticate` header pointing at the resource metadata.
|
||||
2. It reads `/.well-known/oauth-protected-resource` on the MCP server, then `/.well-known/oauth-authorization-server` on the API.
|
||||
3. It registers itself (dynamic client registration, RFC 7591) and opens the TaskView consent screen in your browser.
|
||||
4. You sign in and tick the permissions and projects the app may use. Ticking nothing grants your full access; your RBAC role is the ceiling either way.
|
||||
5. The client receives a `tvo_` access token (valid 1 hour) and a refresh token (valid 30 days), and sends the access token to `/mcp` like any other bearer token.
|
||||
|
||||
To connect, paste the MCP URL (`https://mcp.your-domain.com/mcp`) into the client's custom connector form. When it asks you to authorize, the TaskView consent screen opens. Claude Code can use OAuth too: add the server without the `--header` flag and run `/mcp` to authenticate.
|
||||
|
||||
### Configuration for OAuth
|
||||
|
||||
- `MCP_PUBLIC_URL` on the MCP server — the public URL of the server exactly as clients reach it. This is the OAuth resource identifier. When unset it is derived from the request's `Host` and `X-Forwarded-Proto` headers.
|
||||
- `TASKVIEW_URL` on the MCP server — advertised to clients as the authorization server, so for OAuth it must be the **public** API URL (`https://api.your-domain.com`), not the docker-network address from the example above.
|
||||
- `API_PUBLIC_URL` and `APP_URL` on the API — the first names the authorization server in its metadata, the second is where the consent screen lives. See [environment variables](/docs/configuration/environment-variables#application).
|
||||
- `OAUTH_DYNAMIC_REGISTRATION=false` on the API — stops new clients from registering themselves, which closes OAuth for new connections on a locked-down instance. Existing connected apps keep working.
|
||||
|
||||
**Mounted under a path.** When the MCP server sits behind a reverse proxy at `https://api.your-domain.com/mcp` rather than on its own host, put the full path into `MCP_PUBLIC_URL`. The resource metadata then also lives at `/.well-known/oauth-protected-resource/mcp` (RFC 9728), which is where a client that computes the address from the spec will look. The server publishes it at both that address and the root one, but the proxy has to route the path-inserted address to the MCP server too:
|
||||
|
||||
```nginx
|
||||
location = /.well-known/oauth-protected-resource/mcp {
|
||||
proxy_pass http://127.0.0.1:3100/.well-known/oauth-protected-resource/mcp;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
### Managing connected apps
|
||||
|
||||
Every app you authorized is listed in your account settings under **Connected apps**, with the permissions and projects you granted and when it was last used. Revoking one cuts off its access immediately. OAuth tokens do not appear in the API tokens list and cannot be deleted from there.
|
||||
|
||||
Manually issued `tvk_` tokens keep working exactly as before. OAuth is an additional way in, not a replacement: use `tvk_` for stdio, CLIs and CI, where there is no browser to complete an authorization flow.
|
||||
|
||||
## Permissions
|
||||
|
||||
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
|
||||
The assistant can only do what its credential allows. API token permissions are scoped to selected projects; an OAuth grant carries the permissions and projects you ticked on the consent screen. Both are intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
|
||||
|
||||
@@ -51,6 +51,7 @@ Unlike everything else on this page, this variable is set on the **web app conta
|
||||
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
|
||||
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
|
||||
| `ALLOW_PUBLIC_REGISTRATION` | No | `true` | Set to `false` to close the instance: strangers can no longer create accounts — the registration endpoint returns 403, and magic-link / social sign-in stop auto-creating users. Emails invited to an organization or project can still sign in and get their account created on first login. |
|
||||
| `OAUTH_DYNAMIC_REGISTRATION` | No | `true` | Whether AI clients may register themselves as OAuth clients (RFC 7591) when connecting to the [MCP server](/docs/integrations/mcp#oauth-cloud-clients). Set to `false` on a locked-down instance: the registration endpoint returns 403 and no new OAuth connection can be started; apps already connected keep working. |
|
||||
| `SSO_TRUSTED_DOMAINS` | No | empty | Comma-separated email domains that skip DNS/HTTP ownership checks for SSO (air-gapped / closed-network installs). Example: `company.com,corp.local`. On a public instance leave this unset so every org must prove it owns the domain. |
|
||||
|
||||
::callout{icon="i-lucide-shield" color="warning"}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { TvApi } from '@/tv';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest';
|
||||
import { initApi } from './init-api';
|
||||
import { ymd } from './test-helpers';
|
||||
|
||||
/**
|
||||
* The main screen endpoint. It answers "what do I have today" in one request and
|
||||
* splits the day server-side in the caller's timezone — the reason a client must
|
||||
* not fetch every project and compare deadlines itself.
|
||||
*/
|
||||
describe('Start screen state', () => {
|
||||
let $api: TvApi;
|
||||
let goalId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi();
|
||||
$api = $tvApi;
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `Agenda test-${Date.now()}` });
|
||||
goalId = goal!.id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {});
|
||||
});
|
||||
|
||||
it('returns every bucket the main screen shows', async () => {
|
||||
const state = await $api.start.fetchAllState({ tz: 'UTC' });
|
||||
|
||||
expect(state).toBeDefined();
|
||||
expect(Array.isArray(state!.tasksToday)).toBe(true);
|
||||
expect(Array.isArray(state!.tasksUpcoming)).toBe(true);
|
||||
expect(Array.isArray(state!.tasksLastCompleted)).toBe(true);
|
||||
expect(Array.isArray(state!.tasks)).toBe(true);
|
||||
});
|
||||
|
||||
it('puts a task due today into tasksToday', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Due today-${Date.now()}`,
|
||||
});
|
||||
await $api.tasks.updateTask({ id: task!.id, endDate: ymd(0) });
|
||||
|
||||
const state = await $api.start.fetchAllState({ tz: 'UTC' });
|
||||
|
||||
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(true);
|
||||
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('puts a task due later into tasksUpcoming', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `Due later-${Date.now()}`,
|
||||
});
|
||||
await $api.tasks.updateTask({ id: task!.id, endDate: ymd(7) });
|
||||
|
||||
const state = await $api.start.fetchAllState({ tz: 'UTC' });
|
||||
|
||||
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(true);
|
||||
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a task with no deadline out of both dated buckets', async () => {
|
||||
const task = await $api.tasks.createTask({
|
||||
goalId,
|
||||
description: `No deadline-${Date.now()}`,
|
||||
});
|
||||
|
||||
const state = await $api.start.fetchAllState({ tz: 'UTC' });
|
||||
|
||||
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(false);
|
||||
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a timezone — the split is meaningless without one', async () => {
|
||||
const status = await $api.start
|
||||
.fetchAllState({ tz: '' })
|
||||
.then(() => 200)
|
||||
.catch((err) => err.status ?? err.response?.status);
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { TvApi } from '@/tv';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
} from 'vitest';
|
||||
import { initApi, API_URL } from './init-api';
|
||||
|
||||
/**
|
||||
* Organization, SSO and webhook endpoints are guarded by an organization role or
|
||||
* by project ownership, not by the project RBAC, so before migration 1.65.0 they
|
||||
* ignored the scope of an API token entirely: a token issued with a single
|
||||
* permission could still create organizations and webhooks. These tests pin the
|
||||
* fixed behaviour, including the deliberate escape hatch — a token with no
|
||||
* permissions selected stays unrestricted.
|
||||
*/
|
||||
describe('API token scope on organization-level surfaces', () => {
|
||||
let $api: TvApi;
|
||||
const tokenIds: number[] = [];
|
||||
const orgIds: number[] = [];
|
||||
let goalId: number;
|
||||
|
||||
const clientFor = (token: string) => new TvApi(axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}));
|
||||
|
||||
const scopedClient = async (allowedPermissions: string[]) => {
|
||||
const created = await $api.apiTokens.create({
|
||||
name: `scope-${Date.now()}-${Math.random()}`,
|
||||
allowedPermissions,
|
||||
});
|
||||
tokenIds.push(created!.item.id);
|
||||
return clientFor(created!.token);
|
||||
};
|
||||
|
||||
const statusOf = async (call: Promise<unknown>) =>
|
||||
call.then(() => 200).catch((err) => err.status ?? err.response?.status);
|
||||
|
||||
/**
|
||||
* A permitted call must answer 200. Asserting merely "not 403" would also pass
|
||||
* on a 500, which is how a broken endpoint gets mistaken for a working guard.
|
||||
*/
|
||||
const expectAllowed = (status: number) => expect(status).toBe(200);
|
||||
|
||||
beforeAll(async () => {
|
||||
const { $tvApi } = await initApi();
|
||||
$api = $tvApi;
|
||||
|
||||
const goal = await $api.goals.createGoal({ name: `scope-goal-${Date.now()}` });
|
||||
goalId = goal!.id!;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of orgIds) {
|
||||
await $api.organizations.delete(id).catch(() => {});
|
||||
}
|
||||
for (const id of tokenIds) {
|
||||
await $api.apiTokens.delete(id).catch(() => {});
|
||||
}
|
||||
await $api.goals.deleteGoal(goalId).catch(() => {});
|
||||
});
|
||||
|
||||
describe('permission catalogue', () => {
|
||||
it('offers the organization group for selection', async () => {
|
||||
const permissions = await $api.apiTokens.fetchPermissions();
|
||||
const names = permissions!.map((p) => p.name);
|
||||
|
||||
expect(names).toContain('org_can_view');
|
||||
expect(names).toContain('org_can_manage');
|
||||
expect(names).toContain('org_can_manage_members');
|
||||
expect(names).toContain('sso_can_manage');
|
||||
expect(names).toContain('webhooks_can_manage');
|
||||
});
|
||||
|
||||
it('carries localized descriptions for them', async () => {
|
||||
const permissions = await $api.apiTokens.fetchPermissions();
|
||||
const orgManage = permissions!.find((p) => p.name === 'org_can_manage');
|
||||
|
||||
expect(orgManage!.permissionGroup).toBe(6);
|
||||
expect(orgManage!.descriptionLocales?.en).toBeTruthy();
|
||||
expect(orgManage!.descriptionLocales?.ru).toBeTruthy();
|
||||
});
|
||||
|
||||
it('never offers group 1, which is enforced nowhere', async () => {
|
||||
const permissions = await $api.apiTokens.fetchPermissions();
|
||||
|
||||
expect(permissions!.some((p) => p.permissionGroup === 1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('creating organizations', () => {
|
||||
it('denies a token scoped to an unrelated permission', async () => {
|
||||
const client = await scopedClient(['timetracking_can_view']);
|
||||
|
||||
const status = await statusOf(client.organizations.create({ name: `denied-${Date.now()}` }));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a token holding org_can_manage', async () => {
|
||||
const client = await scopedClient(['org_can_manage']);
|
||||
|
||||
const org = await client.organizations.create({ name: `allowed-${Date.now()}` });
|
||||
|
||||
expect(org).toBeDefined();
|
||||
orgIds.push(org!.id);
|
||||
});
|
||||
|
||||
it('allows a token with no permissions selected — unrestricted by design', async () => {
|
||||
const client = await scopedClient([]);
|
||||
|
||||
const org = await client.organizations.create({ name: `unrestricted-${Date.now()}` });
|
||||
|
||||
expect(org).toBeDefined();
|
||||
orgIds.push(org!.id);
|
||||
});
|
||||
|
||||
it('does not restrict a normal browser session', async () => {
|
||||
const org = await $api.organizations.create({ name: `session-${Date.now()}` });
|
||||
|
||||
expect(org).toBeDefined();
|
||||
orgIds.push(org!.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('managing members', () => {
|
||||
it('separates org_can_manage from org_can_manage_members', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `members-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.organizations.addMember({
|
||||
organizationId: org!.id,
|
||||
email: `member-${Date.now()}@test.dest`,
|
||||
role: 'member',
|
||||
}));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a token holding org_can_manage_members through the guard', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `members-ok-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const member = await scopedClient(['org_can_manage_members']);
|
||||
const status = await statusOf(member.organizations.addMember({
|
||||
organizationId: org!.id,
|
||||
email: `member-${Date.now()}@test.dest`,
|
||||
role: 'member',
|
||||
}));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
|
||||
it('denies listing members without org_can_view', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `list-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.organizations.fetchMembers(org!.id));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows listing members with org_can_view', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `list-ok-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const viewer = await scopedClient(['org_can_view']);
|
||||
const status = await statusOf(viewer.organizations.fetchMembers(org!.id));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing and deleting an organization', () => {
|
||||
it('denies renaming without org_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `rename-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const outsider = await scopedClient(['org_can_view']);
|
||||
const status = await statusOf(outsider.organizations.update(org!.id, { name: 'renamed' }));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows renaming with org_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `rename-ok-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.organizations.update(org!.id, { name: `renamed-${Date.now()}` }));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
|
||||
it('denies deleting without org_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `delete-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const outsider = await scopedClient(['org_can_view']);
|
||||
const status = await statusOf(outsider.organizations.delete(org!.id));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing and removing members', () => {
|
||||
it('denies changing a role without org_can_manage_members', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `role-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.organizations.updateMemberRole({
|
||||
organizationId: org!.id,
|
||||
email: `role-${Date.now()}@test.dest`,
|
||||
role: 'admin',
|
||||
}));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies removing a member without org_can_manage_members', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `remove-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.organizations.removeMember({
|
||||
organizationId: org!.id,
|
||||
email: `remove-${Date.now()}@test.dest`,
|
||||
}));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets org_can_manage_members past the guard on both', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `member-verbs-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const manager = await scopedClient(['org_can_manage_members']);
|
||||
const email = `member-${Date.now()}@test.dest`;
|
||||
await manager.organizations.addMember({ organizationId: org!.id, email, role: 'member' }).catch(() => {});
|
||||
|
||||
const roleStatus = await statusOf(manager.organizations.updateMemberRole({
|
||||
organizationId: org!.id, email, role: 'admin',
|
||||
}));
|
||||
const removeStatus = await statusOf(manager.organizations.removeMember({
|
||||
organizationId: org!.id, email,
|
||||
}));
|
||||
|
||||
expectAllowed(roleStatus);
|
||||
expectAllowed(removeStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO administration', () => {
|
||||
it('denies listing configs without sso_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `sso-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.sso.listConfigs(org!.id));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows listing configs with sso_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `sso-ok-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const admin = await scopedClient(['sso_can_manage']);
|
||||
const status = await statusOf(admin.sso.listConfigs(org!.id));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
|
||||
it('denies every other admin verb on a real config without sso_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `sso-verbs-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
// A real config is required: IsSsoConfigAdmin runs first and would answer
|
||||
// 404 for a made-up id, hiding whether the token check exists at all.
|
||||
const admin = await scopedClient(['sso_can_manage']);
|
||||
const config = await admin.sso.createConfig({
|
||||
organizationId: org!.id,
|
||||
protocol: 'oidc',
|
||||
displayName: 'Scope probe',
|
||||
emailDomainRestriction: `sso-${Date.now()}.test`,
|
||||
});
|
||||
expect(config).toBeDefined();
|
||||
const configId = config!.id;
|
||||
|
||||
const results = await Promise.all([
|
||||
statusOf(owner.sso.updateConfig(configId, { displayName: 'renamed' })),
|
||||
statusOf(owner.sso.startDomainVerification(configId)),
|
||||
statusOf(owner.sso.checkDomainVerification(configId)),
|
||||
statusOf(owner.sso.generateScimToken(configId)),
|
||||
statusOf(owner.sso.toggleScim(configId, true)),
|
||||
statusOf(owner.sso.deleteConfig(configId)),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([403, 403, 403, 403, 403, 403]);
|
||||
|
||||
await admin.sso.deleteConfig(configId).catch(() => {});
|
||||
});
|
||||
|
||||
it('lets sso_can_manage change and delete a config', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `sso-admin-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const admin = await scopedClient(['sso_can_manage']);
|
||||
const config = await admin.sso.createConfig({
|
||||
organizationId: org!.id,
|
||||
protocol: 'oidc',
|
||||
displayName: 'Editable',
|
||||
emailDomainRestriction: `sso-ok-${Date.now()}.test`,
|
||||
});
|
||||
|
||||
const updateStatus = await statusOf(admin.sso.updateConfig(config!.id, { displayName: 'Renamed' }));
|
||||
const deleteStatus = await statusOf(admin.sso.deleteConfig(config!.id));
|
||||
|
||||
expectAllowed(updateStatus);
|
||||
expectAllowed(deleteStatus);
|
||||
});
|
||||
|
||||
it('denies creating a config without sso_can_manage', async () => {
|
||||
const owner = await scopedClient(['org_can_manage']);
|
||||
const org = await owner.organizations.create({ name: `sso-create-${Date.now()}` });
|
||||
orgIds.push(org!.id);
|
||||
|
||||
const status = await statusOf(owner.sso.createConfig({
|
||||
organizationId: org!.id,
|
||||
protocol: 'oidc',
|
||||
displayName: 'Should not be created',
|
||||
emailDomainRestriction: `sso-${Date.now()}.test`,
|
||||
}));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhooks', () => {
|
||||
it('denies a token scoped to an unrelated permission', async () => {
|
||||
const client = await scopedClient(['timetracking_can_view']);
|
||||
|
||||
const status = await statusOf(client.webhooks.fetch(goalId));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a token holding webhooks_can_manage', async () => {
|
||||
const client = await scopedClient(['webhooks_can_manage']);
|
||||
|
||||
const status = await statusOf(client.webhooks.fetch(goalId));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
|
||||
it('allows an unrestricted token', async () => {
|
||||
const client = await scopedClient([]);
|
||||
|
||||
const status = await statusOf(client.webhooks.fetch(goalId));
|
||||
|
||||
expectAllowed(status);
|
||||
});
|
||||
|
||||
it('denies creating a webhook without webhooks_can_manage', async () => {
|
||||
const client = await scopedClient(['timetracking_can_view']);
|
||||
|
||||
const status = await statusOf(client.webhooks.create({
|
||||
goalId,
|
||||
url: 'https://exfiltration.test/hook',
|
||||
events: ['task.created'],
|
||||
}));
|
||||
|
||||
expect(status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows the full webhook lifecycle with webhooks_can_manage', async () => {
|
||||
const client = await scopedClient(['webhooks_can_manage']);
|
||||
|
||||
const created = await client.webhooks.create({
|
||||
goalId,
|
||||
url: `https://receiver.test/${Date.now()}`,
|
||||
events: ['task.created'],
|
||||
});
|
||||
expect(created).toBeDefined();
|
||||
|
||||
const updateStatus = await statusOf(client.webhooks.update({
|
||||
id: created!.webhook.id,
|
||||
events: ['task.created', 'task.updated'],
|
||||
}));
|
||||
const deleteStatus = await statusOf(client.webhooks.delete({ id: created!.webhook.id }));
|
||||
|
||||
expectAllowed(updateStatus);
|
||||
expectAllowed(deleteStatus);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,4 +26,6 @@ export type ApiTokenPermission = {
|
||||
name: string;
|
||||
description: string;
|
||||
permissionGroup: number;
|
||||
/** Per-locale description; keys match the app locales ('en', 'ru', 'de', 'es', 'pt-BR'). */
|
||||
descriptionLocales?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from '@/api/base.types';
|
||||
import type {
|
||||
OAuthConnectedApp,
|
||||
OAuthConsentRequest,
|
||||
OAuthConsentResponse,
|
||||
} from './oauth.types';
|
||||
|
||||
export default class TvOAuth extends TvApiBase {
|
||||
protected moduleUrl = '/module/oauth';
|
||||
|
||||
public async approveConsent(data: OAuthConsentRequest) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<OAuthConsentResponse>>(`${this.moduleUrl}/consent`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async denyConsent(data: OAuthConsentRequest) {
|
||||
return this.request(
|
||||
this.$axios.post<AppResponse<OAuthConsentResponse>>(`${this.moduleUrl}/consent/deny`, data)
|
||||
);
|
||||
}
|
||||
|
||||
public async fetchConnectedApps() {
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<OAuthConnectedApp[]>>(`${this.moduleUrl}/connected-apps`)
|
||||
);
|
||||
}
|
||||
|
||||
public async revokeConnectedApp(grantId: number) {
|
||||
return this.request(
|
||||
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/connected-apps`, { data: { grantId } })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type OAuthConsentRequest = {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method: 'S256';
|
||||
/** Empty means "do not narrow" — same semantics as a tvk_ token with no permissions picked. */
|
||||
allowedPermissions?: string[];
|
||||
allowedGoalIds?: number[];
|
||||
state?: string;
|
||||
resource?: string;
|
||||
};
|
||||
|
||||
export type OAuthConsentResponse = {
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export type OAuthConnectedApp = {
|
||||
grantId: number;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
allowedPermissions: string[];
|
||||
allowedGoalIds: number[];
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import TvApiBase from './base';
|
||||
import type { AppResponse } from '@/api/base.types';
|
||||
import type { StartScreenState, StartStateArgs } from './start.types';
|
||||
|
||||
/**
|
||||
* The main screen module. Its routes sit under /module/about for historical
|
||||
* reasons, while the backend module itself is called `start`.
|
||||
*/
|
||||
export default class TvStartApi extends TvApiBase {
|
||||
protected moduleUrl = '/module/about';
|
||||
|
||||
/**
|
||||
* One request for what the main screen shows: today, upcoming and recently
|
||||
* completed tasks across every project the caller can see. The split happens
|
||||
* on the server in `tz`, so it matches the app — a client that fetches every
|
||||
* project and compares deadlines itself gets a different answer for anyone
|
||||
* outside UTC.
|
||||
*/
|
||||
public async fetchAllState(args: StartStateArgs) {
|
||||
const params = new URLSearchParams({ tz: args.tz });
|
||||
if (args.organizationId) {
|
||||
params.set('organizationId', String(args.organizationId));
|
||||
}
|
||||
|
||||
return this.request(
|
||||
this.$axios.get<AppResponse<StartScreenState>>(`${this.moduleUrl}/fetchallstate?${params.toString()}`)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Task } from './tasks.api.types';
|
||||
|
||||
export type StartScreenUsers = {
|
||||
id: number;
|
||||
name: string;
|
||||
users: { id: number; email: string }[];
|
||||
}[];
|
||||
|
||||
export type StartScreenAssignees = {
|
||||
taskId: Task['id'];
|
||||
collabUserId: number;
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
/** Everything the main screen shows, split server-side in the caller's timezone. */
|
||||
export type StartScreenState = {
|
||||
tasks: Task[];
|
||||
tasksToday: Task[];
|
||||
tasksUpcoming: Task[];
|
||||
tasksLastCompleted: Task[];
|
||||
users: StartScreenUsers;
|
||||
assignees: StartScreenAssignees;
|
||||
listToGoal: Record<number, number>;
|
||||
};
|
||||
|
||||
export type StartStateArgs = {
|
||||
/** IANA timezone, e.g. "Europe/Belgrade". The API rejects the request without it. */
|
||||
tz: string;
|
||||
organizationId?: number;
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export * from '@/api/notifications.api.types';
|
||||
export * from '@/api/webhooks.types';
|
||||
export * from '@/api/messaging.types';
|
||||
export * from '@/api/api-tokens.types';
|
||||
export * from '@/api/oauth.types';
|
||||
export * from '@/api/start.types';
|
||||
export * from '@/api/sessions.types';
|
||||
export * from '@/api/organizations.types';
|
||||
export * from '@/api/sso.types';
|
||||
|
||||
@@ -11,6 +11,8 @@ import TvNotificationsApi from "./api/notifications";
|
||||
import TvWebhooks from "./api/webhooks";
|
||||
import TvMessagingApi from "./api/messaging";
|
||||
import TvApiTokens from "./api/api-tokens";
|
||||
import TvOAuth from "./api/oauth";
|
||||
import TvStartApi from "./api/start";
|
||||
import TvSessions from "./api/sessions";
|
||||
import TvOrganizationsApi from "./api/organizations";
|
||||
import TvSsoApi from "./api/sso";
|
||||
@@ -47,6 +49,8 @@ export class TvApi {
|
||||
public messaging: TvMessagingApi;
|
||||
|
||||
public apiTokens: TvApiTokens;
|
||||
public oauth: TvOAuth;
|
||||
public start: TvStartApi;
|
||||
|
||||
public sessions: TvSessions;
|
||||
|
||||
@@ -90,6 +94,8 @@ export class TvApi {
|
||||
this.messaging = new TvMessagingApi(this.$axios);
|
||||
|
||||
this.apiTokens = new TvApiTokens(this.$axios);
|
||||
this.oauth = new TvOAuth(this.$axios);
|
||||
this.start = new TvStartApi(this.$axios);
|
||||
|
||||
this.sessions = new TvSessions(this.$axios);
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ export * from './schemas/notification-preferences.schema';
|
||||
export * from './schemas/webhooks.schema';
|
||||
export * from './schemas/messaging.schema';
|
||||
export * from './schemas/api-tokens.schema';
|
||||
export * from './schemas/oauth-clients.schema';
|
||||
export * from './schemas/oauth-auth-codes.schema';
|
||||
export * from './schemas/oauth-grants.schema';
|
||||
export * from './schemas/user-tokens.schema';
|
||||
export * from './schemas/organizations.schema';
|
||||
export * from './schemas/sso.schema';
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ApiTokensSchema = pgSchema('tv_auth').table('api_tokens', {
|
||||
tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(),
|
||||
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
|
||||
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
|
||||
grantId: integer('grant_id'),
|
||||
lastUsedAt: timestamp('last_used_at'),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const OAuthAuthCodesSchema = pgSchema('tv_auth').table('oauth_auth_codes', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
codeHash: varchar('code_hash', { length: 64 }).notNull().unique(),
|
||||
clientId: varchar('client_id', { length: 64 }).notNull(),
|
||||
userId: integer('user_id').notNull(),
|
||||
redirectUri: varchar('redirect_uri').notNull(),
|
||||
codeChallenge: varchar('code_challenge', { length: 128 }).notNull(),
|
||||
codeChallengeMethod: varchar('code_challenge_method', { length: 8 }).notNull().default('S256'),
|
||||
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
|
||||
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
|
||||
resource: varchar(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
usedAt: timestamp('used_at'),
|
||||
grantId: integer('grant_id'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type OAuthAuthCodesSchemaTypeForSelect = typeof OAuthAuthCodesSchema.$inferSelect;
|
||||
export type OAuthAuthCodesSchemaTypeForInsert = typeof OAuthAuthCodesSchema.$inferInsert;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const OAuthClientsSchema = pgSchema('tv_auth').table('oauth_clients', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
clientId: varchar('client_id', { length: 64 }).notNull().unique(),
|
||||
clientSecretHash: varchar('client_secret_hash', { length: 64 }),
|
||||
name: varchar({ length: 200 }).notNull(),
|
||||
redirectUris: varchar('redirect_uris').array().notNull().default([]),
|
||||
createdVia: varchar('created_via', { length: 16 }).notNull().default('manual'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type OAuthClientsSchemaTypeForSelect = typeof OAuthClientsSchema.$inferSelect;
|
||||
export type OAuthClientsSchemaTypeForInsert = typeof OAuthClientsSchema.$inferInsert;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const OAuthGrantsSchema = pgSchema('tv_auth').table('oauth_grants', {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
userId: integer('user_id').notNull(),
|
||||
clientId: varchar('client_id', { length: 64 }).notNull(),
|
||||
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
|
||||
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
|
||||
resource: varchar(),
|
||||
refreshTokenHash: varchar('refresh_token_hash', { length: 64 }),
|
||||
refreshTokenPrevHash: varchar('refresh_token_prev_hash', { length: 64 }),
|
||||
refreshExpiresAt: timestamp('refresh_expires_at'),
|
||||
lastUsedAt: timestamp('last_used_at'),
|
||||
revokedAt: timestamp('revoked_at'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type OAuthGrantsSchemaTypeForSelect = typeof OAuthGrantsSchema.$inferSelect;
|
||||
export type OAuthGrantsSchemaTypeForInsert = typeof OAuthGrantsSchema.$inferInsert;
|
||||
@@ -17,7 +17,7 @@ export const PermissionsSchema = pgSchema('tv_auth').table('permissions', {
|
||||
name: varchar().notNull(),
|
||||
description: varchar(),
|
||||
permissionGroup: integer('permission_group'),
|
||||
descriptionLocales: jsonb('description_locales'),
|
||||
descriptionLocales: jsonb('description_locales').$type<Record<string, string> | null>(),
|
||||
});
|
||||
|
||||
export type PermissionsSchemaTypeForSelect = typeof PermissionsSchema.$inferSelect;
|
||||
|
||||
@@ -73,6 +73,8 @@ Then use `"command": "taskview-mcp"` (no `args` needed).
|
||||
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`) |
|
||||
| `TASKVIEW_TOKEN` | yes (stdio mode) | API token with `tvk_` prefix. Not used in HTTP mode — each caller sends their own token |
|
||||
| `MCP_HTTP_PORT` | no (HTTP mode) | Port for the HTTP server, default `3100` |
|
||||
| `MCP_PUBLIC_URL` | no (HTTP mode) | Public URL of this server as clients reach it — the OAuth resource identifier. Include the path when the server is mounted under one (`https://api.example.com/mcp`); derived from the request host when unset |
|
||||
| `MCP_ALLOWED_ORIGINS` | no (HTTP mode) | Comma-separated browser origins allowed to call `/mcp`. Unset means no browser origin is allowed; non-browser clients send no `Origin` and are unaffected |
|
||||
|
||||
## HTTP server (remote / self-hosted)
|
||||
|
||||
@@ -112,6 +114,45 @@ Self-hosted instances run their own copy next to their API (see `Dockerfile`
|
||||
in this package) — point `TASKVIEW_URL` at your API server and put the MCP
|
||||
port behind your reverse proxy with HTTPS.
|
||||
|
||||
### OAuth (ChatGPT and other cloud clients)
|
||||
|
||||
Clients that will not accept a pasted API token — ChatGPT connectors among
|
||||
them — authorize over OAuth 2.1 instead. Nothing extra runs here: this server
|
||||
advertises itself as the protected resource and points at your TaskView API,
|
||||
which is the authorization server.
|
||||
|
||||
- `GET /.well-known/oauth-protected-resource` returns the resource metadata
|
||||
(RFC 9728), naming `TASKVIEW_URL` as the authorization server.
|
||||
- A request with no token answers `401` with
|
||||
`WWW-Authenticate: Bearer resource_metadata="…"`, which is what starts
|
||||
discovery in the client.
|
||||
- The client then registers, sends the user to the TaskView consent screen,
|
||||
and comes back with a `tvo_` access token that it sends here like any other
|
||||
bearer token.
|
||||
|
||||
Set `MCP_PUBLIC_URL` to the externally reachable URL of this server so the
|
||||
advertised resource identifier matches what clients actually request.
|
||||
|
||||
**Mounted under a path.** When the server sits behind a reverse proxy at
|
||||
`https://api.example.com/mcp` rather than on its own host, put the full path in
|
||||
`MCP_PUBLIC_URL`. RFC 9728 then places the metadata at
|
||||
`/.well-known/oauth-protected-resource/mcp`, which is where a client that
|
||||
computes the address from the spec — instead of trusting the `WWW-Authenticate`
|
||||
header — will look. The server publishes it at both that address and the root
|
||||
one, but the proxy has to route the path-inserted address here too:
|
||||
|
||||
```nginx
|
||||
location = /.well-known/oauth-protected-resource/mcp {
|
||||
proxy_pass http://127.0.0.1:3100/.well-known/oauth-protected-resource/mcp;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
Manually issued `tvk_` tokens keep working exactly as before — OAuth is an
|
||||
additional way in, not a replacement. Use `tvk_` for stdio, CLIs and CI, where
|
||||
there is no browser to complete an authorization flow.
|
||||
|
||||
## Available tools
|
||||
|
||||
61 tools covering the full TaskView surface.
|
||||
@@ -122,6 +163,8 @@ port behind your reverse proxy with HTTPS.
|
||||
|
||||
**Tasks** — `list_tasks`, `get_task`, `create_task`, `update_task`, `delete_task`, `toggle_task_assignees`, `get_task_history`, `restore_task_from_history`
|
||||
|
||||
**Agenda** — `get_agenda` (today, upcoming and recently completed across all projects in one call)
|
||||
|
||||
**Tags** — `list_tags`, `create_tag`, `update_tag`, `delete_tag`, `toggle_task_tag`
|
||||
|
||||
**Kanban** — `list_kanban_columns`, `create_kanban_column`, `update_kanban_column`, `delete_kanban_column`
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerGoalsTools } from '../tools/goals.js'
|
||||
import { registerTasksTools } from '../tools/tasks.js'
|
||||
import { registerListsTools } from '../tools/lists.js'
|
||||
import { registerTagsTools } from '../tools/tags.js'
|
||||
import { registerKanbanTools } from '../tools/kanban.js'
|
||||
import { registerCollaborationTools } from '../tools/collaboration.js'
|
||||
import { registerGraphTools } from '../tools/graph.js'
|
||||
import { registerNotificationsTools } from '../tools/notifications.js'
|
||||
import { registerStartTools } from '../tools/start.js'
|
||||
import { registerOrganizationsTools } from '../tools/organizations.js'
|
||||
import { registerTimeTrackingTools } from '../tools/time-tracking.js'
|
||||
import { mockServer, mockApi, findTool } from './setup.js'
|
||||
|
||||
const REQUIRED_HINTS = ['readOnlyHint', 'destructiveHint', 'openWorldHint'] as const
|
||||
|
||||
function registerEverything() {
|
||||
const { server, tools } = mockServer()
|
||||
const api = mockApi()
|
||||
registerGoalsTools(server, api)
|
||||
registerTasksTools(server, api)
|
||||
registerListsTools(server, api)
|
||||
registerTagsTools(server, api)
|
||||
registerKanbanTools(server, api)
|
||||
registerCollaborationTools(server, api)
|
||||
registerGraphTools(server, api)
|
||||
registerNotificationsTools(server, api)
|
||||
registerStartTools(server, api)
|
||||
registerOrganizationsTools(server, api)
|
||||
registerTimeTrackingTools(server, api)
|
||||
return tools
|
||||
}
|
||||
|
||||
describe('tool annotations', () => {
|
||||
const tools = registerEverything()
|
||||
|
||||
it('gives every tool a human-readable title', () => {
|
||||
// The Claude Connectors Directory flags any tool without a title and
|
||||
// will not accept the submission until every one has it.
|
||||
for (const tool of tools) {
|
||||
expect(typeof tool.config.title, tool.name).toBe('string')
|
||||
expect(tool.config.title?.trim().length, tool.name).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps every tool name within the 64-character directory limit', () => {
|
||||
for (const tool of tools) {
|
||||
expect(tool.name.length, tool.name).toBeLessThanOrEqual(64)
|
||||
}
|
||||
})
|
||||
|
||||
it('sets every safety hint explicitly on every tool', () => {
|
||||
// ChatGPT refuses to list an app whose tools leave any hint unset, and the
|
||||
// protocol defaults differ between clients — so nothing may rely on them.
|
||||
for (const tool of tools) {
|
||||
for (const hint of REQUIRED_HINTS) {
|
||||
expect(typeof tool.config.annotations?.[hint], `${tool.name}.${hint}`).toBe('boolean')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('marks every list and get tool as read-only', () => {
|
||||
const readers = tools.filter((t) => /^(list|get)_/.test(t.name))
|
||||
expect(readers.length).toBeGreaterThan(0)
|
||||
for (const tool of readers) {
|
||||
expect(tool.config.annotations?.readOnlyHint, tool.name).toBe(true)
|
||||
expect(tool.config.annotations?.destructiveHint, tool.name).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('never marks a writing tool as read-only', () => {
|
||||
const writers = tools.filter((t) => !/^(list|get)_/.test(t.name))
|
||||
for (const tool of writers) {
|
||||
expect(tool.config.annotations?.readOnlyHint, tool.name).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks deletion and removal as destructive', () => {
|
||||
const removers = tools.filter((t) => /^(delete|remove)_/.test(t.name))
|
||||
expect(removers.length).toBeGreaterThan(0)
|
||||
for (const tool of removers) {
|
||||
expect(tool.config.annotations?.destructiveHint, tool.name).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('marks restoring a task from history as destructive, since it overwrites the current state', () => {
|
||||
expect(findTool(tools, 'restore_task_from_history').config.annotations?.destructiveHint).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps every tool inside the user\'s own instance except the invitation email', () => {
|
||||
for (const tool of tools) {
|
||||
const expected = tool.name === 'invite_collaborator'
|
||||
expect(tool.config.annotations?.openWorldHint, tool.name).toBe(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* The HTTP transport is the entry point of OAuth discovery: a client knows only
|
||||
* the /mcp URL, gets a 401, and follows WWW-Authenticate to the resource
|
||||
* metadata. These run against the built dist/http.js as a real process, because
|
||||
* that is what ships and what a client actually talks to.
|
||||
*/
|
||||
const PORT = 3199
|
||||
const PUBLIC_URL = `http://127.0.0.1:${PORT}`
|
||||
const TASKVIEW_URL = process.env.TASKVIEW_URL || 'http://127.0.0.1:11401'
|
||||
|
||||
let server: ChildProcess
|
||||
|
||||
async function waitForServer(attempts = 40) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_URL}/health`)
|
||||
if (response.ok) return
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error('MCP HTTP server did not start')
|
||||
}
|
||||
|
||||
describe('MCP HTTP transport — OAuth discovery', () => {
|
||||
beforeAll(async () => {
|
||||
server = spawn('node', [join(__dirname, '../../../dist/http.js')], {
|
||||
env: {
|
||||
...process.env,
|
||||
TASKVIEW_URL,
|
||||
MCP_PUBLIC_URL: PUBLIC_URL,
|
||||
MCP_HTTP_PORT: String(PORT),
|
||||
},
|
||||
stdio: 'ignore',
|
||||
})
|
||||
await waitForServer()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
server?.kill()
|
||||
})
|
||||
|
||||
it('answers an unauthenticated call with 401 and points at the resource metadata', async () => {
|
||||
const response = await fetch(`${PUBLIC_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('www-authenticate')).toBe(
|
||||
`Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('publishes protected resource metadata naming the TaskView API as the authorization server', async () => {
|
||||
const response = await fetch(`${PUBLIC_URL}/.well-known/oauth-protected-resource`)
|
||||
const body = await response.json() as Record<string, any>
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.resource).toBe(PUBLIC_URL)
|
||||
expect(body.authorization_servers).toEqual([TASKVIEW_URL])
|
||||
expect(body.bearer_methods_supported).toEqual(['header'])
|
||||
})
|
||||
|
||||
it('uses MCP_PUBLIC_URL rather than the Host header, so a proxy cannot leak an internal address', async () => {
|
||||
const response = await fetch(`${PUBLIC_URL}/.well-known/oauth-protected-resource`, {
|
||||
headers: { Host: 'internal-name:9999' },
|
||||
})
|
||||
const body = await response.json() as Record<string, any>
|
||||
|
||||
expect(body.resource).toBe(PUBLIC_URL)
|
||||
})
|
||||
|
||||
it('keeps /health open', async () => {
|
||||
const response = await fetch(`${PUBLIC_URL}/health`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(((await response.json()) as { status: string }).status).toBe('ok')
|
||||
})
|
||||
|
||||
it('does not allow an unlisted browser origin', async () => {
|
||||
const response = await fetch(`${PUBLIC_URL}/mcp`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://evil.example.com' },
|
||||
})
|
||||
|
||||
expect(response.headers.get('access-control-allow-origin')).toBeNull()
|
||||
})
|
||||
|
||||
describe('mounted under a path', () => {
|
||||
const PATH_PORT = PORT + 2
|
||||
const PATH_PUBLIC_URL = `http://127.0.0.1:${PATH_PORT}/mcp`
|
||||
let pathServer: ChildProcess
|
||||
|
||||
beforeAll(async () => {
|
||||
pathServer = spawn('node', [join(__dirname, '../../../dist/http.js')], {
|
||||
env: {
|
||||
...process.env,
|
||||
TASKVIEW_URL,
|
||||
MCP_PUBLIC_URL: PATH_PUBLIC_URL,
|
||||
MCP_HTTP_PORT: String(PATH_PORT),
|
||||
},
|
||||
stdio: 'ignore',
|
||||
})
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
if ((await fetch(`http://127.0.0.1:${PATH_PORT}/health`)).ok) return
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error('path-mounted MCP server did not start')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
pathServer?.kill()
|
||||
})
|
||||
|
||||
it('points the 401 at the path-inserted metadata address', async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${PATH_PORT}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get('www-authenticate')).toBe(
|
||||
`Bearer resource_metadata="http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource/mcp"`,
|
||||
)
|
||||
})
|
||||
|
||||
it('serves the document where RFC 9728 says it lives', async () => {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource/mcp`,
|
||||
)
|
||||
const body = await response.json() as Record<string, any>
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
// The identifier now matches the URL the client actually connected to.
|
||||
expect(body.resource).toBe(PATH_PUBLIC_URL)
|
||||
})
|
||||
|
||||
it('keeps serving the root address for clients that only follow the header', async () => {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource`,
|
||||
)
|
||||
const body = await response.json() as Record<string, any>
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.resource).toBe(PATH_PUBLIC_URL)
|
||||
})
|
||||
})
|
||||
|
||||
it('allows an origin listed in MCP_ALLOWED_ORIGINS', async () => {
|
||||
const allowed = spawn('node', [join(__dirname, '../../../dist/http.js')], {
|
||||
env: {
|
||||
...process.env,
|
||||
TASKVIEW_URL,
|
||||
MCP_PUBLIC_URL: `http://127.0.0.1:${PORT + 1}`,
|
||||
MCP_HTTP_PORT: String(PORT + 1),
|
||||
MCP_ALLOWED_ORIGINS: 'https://good.example.com',
|
||||
},
|
||||
stdio: 'ignore',
|
||||
})
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
try {
|
||||
if ((await fetch(`http://127.0.0.1:${PORT + 1}/health`)).ok) break
|
||||
} catch {
|
||||
// not listening yet
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${PORT + 1}/mcp`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://good.example.com' },
|
||||
})
|
||||
|
||||
expect(response.headers.get('access-control-allow-origin')).toBe('https://good.example.com')
|
||||
} finally {
|
||||
allowed.kill()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,7 @@ export function mockApi(overrides: Record<string, Record<string, unknown>> = {})
|
||||
},
|
||||
graph: { fetchAllEdges: noop, addEdge: noop, deleteEdge: noop },
|
||||
notifications: { fetch: noop, markRead: noop, markAllRead: noop },
|
||||
start: { fetchAllState: noop },
|
||||
timeTracking: {
|
||||
start: noop, stop: noop, getActive: noop, createManual: noop, update: noop, delete: noop,
|
||||
fetchEntries: noop, summaryByTask: noop, summaryByGoal: noop,
|
||||
@@ -52,7 +53,7 @@ export function apiThrow(message: string) {
|
||||
|
||||
type ToolEntry = {
|
||||
name: string
|
||||
config: { description?: string; inputSchema?: Record<string, unknown> }
|
||||
config: { title?: string; description?: string; inputSchema?: Record<string, unknown>; annotations?: Record<string, unknown> }
|
||||
cb: (args: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; isError?: boolean }>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { registerStartTools } from '../tools/start.js'
|
||||
import { mockServer, mockApi, apiReturn, apiThrow, findTool, ts } from './setup.js'
|
||||
|
||||
describe('start tools', () => {
|
||||
it('registers the agenda tool', () => {
|
||||
const { server, tools } = mockServer()
|
||||
registerStartTools(server, mockApi())
|
||||
|
||||
expect(tools.map((t) => t.name)).toEqual(['get_agenda'])
|
||||
})
|
||||
|
||||
it('returns today, upcoming and recently completed in one call', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
const data = {
|
||||
tasks: [],
|
||||
tasksToday: [{ id: 1, description: `Today ${ts()}` }],
|
||||
tasksUpcoming: [{ id: 2, description: `Later ${ts()}` }],
|
||||
tasksLastCompleted: [{ id: 3, description: `Done ${ts()}` }],
|
||||
users: [],
|
||||
assignees: [],
|
||||
listToGoal: {},
|
||||
}
|
||||
registerStartTools(server, mockApi({ start: { fetchAllState: apiReturn(data) } }))
|
||||
|
||||
const result = await findTool(tools, 'get_agenda').cb({})
|
||||
expect(result.content[0].text).toContain(data.tasksToday[0].description)
|
||||
expect(result.content[0].text).toContain(data.tasksUpcoming[0].description)
|
||||
expect(result.content[0].text).toContain(data.tasksLastCompleted[0].description)
|
||||
})
|
||||
|
||||
it('passes the timezone through, since the server splits the day by it', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: unknown
|
||||
const fetchAllState = (args: unknown) => {
|
||||
captured = args
|
||||
return Promise.resolve({ response: {}, rid: `rid-${ts()}` })
|
||||
}
|
||||
registerStartTools(server, mockApi({ start: { fetchAllState } }))
|
||||
|
||||
await findTool(tools, 'get_agenda').cb({ timezone: 'Europe/Belgrade', organizationId: 7 })
|
||||
|
||||
expect(captured).toEqual({ tz: 'Europe/Belgrade', organizationId: 7 })
|
||||
})
|
||||
|
||||
it('falls back to UTC when the caller does not know the timezone', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
let captured: any
|
||||
const fetchAllState = (args: unknown) => {
|
||||
captured = args
|
||||
return Promise.resolve({ response: {}, rid: `rid-${ts()}` })
|
||||
}
|
||||
registerStartTools(server, mockApi({ start: { fetchAllState } }))
|
||||
|
||||
await findTool(tools, 'get_agenda').cb({})
|
||||
|
||||
// The API rejects a request without tz, so the tool must always send one.
|
||||
expect(captured.tz).toBe('UTC')
|
||||
})
|
||||
|
||||
it('reports an API failure instead of throwing', async () => {
|
||||
const { server, tools } = mockServer()
|
||||
registerStartTools(server, mockApi({ start: { fetchAllState: apiThrow('boom') } }))
|
||||
|
||||
const result = await findTool(tools, 'get_agenda').cb({})
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -4,10 +4,22 @@ import axios from 'axios'
|
||||
import { TvApi } from 'taskview-api'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
import { createMcpServer } from './server.js'
|
||||
import type { HandleMcpRequestArgs } from './http.types.js'
|
||||
import type { CorsHeadersArgs, HandleMcpRequestArgs, UnauthorizedArgs } from './http.types.js'
|
||||
|
||||
const TASKVIEW_URL = process.env.TASKVIEW_URL
|
||||
const PORT = Number(process.env.MCP_HTTP_PORT || 3100)
|
||||
// Public URL of THIS server, as clients reach it — the OAuth resource
|
||||
// identifier. It may carry a path when the server is mounted under one
|
||||
// (https://api.example.com/mcp), which changes where the metadata lives.
|
||||
const MCP_PUBLIC_URL = process.env.MCP_PUBLIC_URL?.replace(/\/+$/, '')
|
||||
const METADATA_PREFIX = '/.well-known/oauth-protected-resource'
|
||||
// Browser origins allowed to call /mcp. Unset means no browser origin is
|
||||
// allowed — non-browser clients (ChatGPT, Claude, CLIs) send no Origin and are
|
||||
// unaffected. Guards against DNS rebinding, which the MCP spec calls out.
|
||||
const ALLOWED_ORIGINS = (process.env.MCP_ALLOWED_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!TASKVIEW_URL) {
|
||||
console.error('Required environment variable: TASKVIEW_URL')
|
||||
@@ -15,11 +27,73 @@ if (!TASKVIEW_URL) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
|
||||
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
||||
function corsHeaders({ origin }: CorsHeadersArgs): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
|
||||
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
||||
Vary: 'Origin',
|
||||
}
|
||||
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||
headers['Access-Control-Allow-Origin'] = origin
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function resourceUrl(req: IncomingMessage): string {
|
||||
if (MCP_PUBLIC_URL) return MCP_PUBLIC_URL
|
||||
const host = req.headers.host ?? `localhost:${PORT}`
|
||||
const proto = (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0]?.trim()
|
||||
return `${proto || 'http'}://${host}/mcp`
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 §3.1: for a resource identified by a URL with a path, the metadata
|
||||
* lives at the well-known prefix followed by that path — not at the origin root.
|
||||
* A client that computes the address from the spec instead of trusting the
|
||||
* WWW-Authenticate header looks exactly there, so this is what makes a server
|
||||
* mounted under /mcp discoverable.
|
||||
*/
|
||||
function metadataPathsFor(resource: string): string[] {
|
||||
let path = ''
|
||||
try {
|
||||
path = new URL(resource).pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
path = ''
|
||||
}
|
||||
// The root form stays served for clients that only follow the 401 header.
|
||||
return path ? [`${METADATA_PREFIX}${path}`, METADATA_PREFIX] : [METADATA_PREFIX]
|
||||
}
|
||||
|
||||
function metadataUrlFor(resource: string): string {
|
||||
const [preferred] = metadataPathsFor(resource)
|
||||
try {
|
||||
return `${new URL(resource).origin}${preferred}`
|
||||
} catch {
|
||||
return `${resource}${METADATA_PREFIX}`
|
||||
}
|
||||
}
|
||||
|
||||
function protectedResourceMetadata(req: IncomingMessage) {
|
||||
return {
|
||||
resource: resourceUrl(req),
|
||||
authorization_servers: [TASKVIEW_URL],
|
||||
bearer_methods_supported: ['header'],
|
||||
}
|
||||
}
|
||||
|
||||
function sendUnauthorized({ req, res, message }: UnauthorizedArgs) {
|
||||
const metadataUrl = metadataUrlFor(resourceUrl(req))
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json',
|
||||
'WWW-Authenticate': `Bearer resource_metadata="${metadataUrl}"`,
|
||||
}).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message },
|
||||
id: null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function extractBearerToken(req: IncomingMessage): string | null {
|
||||
@@ -53,7 +127,9 @@ async function handleMcpRequest({ req, res, token }: HandleMcpRequestArgs) {
|
||||
}
|
||||
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
for (const [name, value] of Object.entries(CORS_HEADERS)) res.setHeader(name, value)
|
||||
for (const [name, value] of Object.entries(corsHeaders({ origin: req.headers.origin }))) {
|
||||
res.setHeader(name, value)
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204).end()
|
||||
@@ -67,6 +143,13 @@ const httpServer = createServer(async (req, res) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (metadataPathsFor(resourceUrl(req)).includes(path)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' }).end(
|
||||
JSON.stringify(protectedResourceMetadata(req)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (path !== '/mcp') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ message: 'Not found. MCP endpoint is /mcp' }))
|
||||
return
|
||||
@@ -74,13 +157,11 @@ const httpServer = createServer(async (req, res) => {
|
||||
|
||||
const token = extractBearerToken(req)
|
||||
if (!token) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Unauthorized: provide your TaskView API token as "Authorization: Bearer tvk_..."' },
|
||||
id: null,
|
||||
}),
|
||||
)
|
||||
sendUnauthorized({
|
||||
req,
|
||||
res,
|
||||
message: 'Unauthorized: authorize with OAuth, or send a TaskView API token as "Authorization: Bearer tvk_..."',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -5,3 +5,13 @@ export type HandleMcpRequestArgs = {
|
||||
res: ServerResponse
|
||||
token: string
|
||||
}
|
||||
|
||||
export type CorsHeadersArgs = {
|
||||
origin: string | undefined
|
||||
}
|
||||
|
||||
export type UnauthorizedArgs = {
|
||||
req: IncomingMessage
|
||||
res: ServerResponse
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerKanbanTools } from './tools/kanban.js'
|
||||
import { registerCollaborationTools } from './tools/collaboration.js'
|
||||
import { registerGraphTools } from './tools/graph.js'
|
||||
import { registerNotificationsTools } from './tools/notifications.js'
|
||||
import { registerStartTools } from './tools/start.js'
|
||||
import { registerOrganizationsTools } from './tools/organizations.js'
|
||||
import { registerTimeTrackingTools } from './tools/time-tracking.js'
|
||||
|
||||
@@ -23,7 +24,9 @@ DATA MODEL (top to bottom):
|
||||
|
||||
WORKFLOW: all IDs are numeric and must be resolved first — never guess them. Map a name to its id with the matching list_* tool (project → list_goals, list → list_lists, members → list_collaborators_for_goal, kanban columns → list_kanban_columns, tags → list_tags), then pass that id to the create/update/delete tools.
|
||||
|
||||
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.`
|
||||
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.
|
||||
|
||||
AGENDA: for "what do I have today", "what is coming up" or "what did I finish recently", call get_agenda. It returns today, upcoming, recently completed and undated tasks across every project in a single request, with the day boundary computed on the server. Do NOT answer those questions by listing projects and walking their tasks — that is many times slower and gets the day wrong for anyone outside UTC. Pass the user's IANA timezone when you know it.`
|
||||
|
||||
export function createMcpServer(api: TvApi) {
|
||||
const server = new McpServer(
|
||||
@@ -42,6 +45,7 @@ export function createMcpServer(api: TvApi) {
|
||||
registerCollaborationTools(server, api)
|
||||
registerGraphTools(server, api)
|
||||
registerNotificationsTools(server, api)
|
||||
registerStartTools(server, api)
|
||||
registerOrganizationsTools(server, api)
|
||||
registerTimeTrackingTools(server, api)
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_collaborators',
|
||||
{
|
||||
title: 'List collaborators',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all collaborators across all projects',
|
||||
},
|
||||
async () => {
|
||||
@@ -20,6 +22,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_collaborators_for_goal',
|
||||
{
|
||||
title: 'List project collaborators',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List collaborators for a specific project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -36,6 +40,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'invite_collaborator',
|
||||
{
|
||||
title: 'Invite collaborator',
|
||||
annotations: toolAnnotations.writeExternal,
|
||||
description: 'Invite a user to a project by email',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -54,6 +60,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'remove_collaborator',
|
||||
{
|
||||
title: 'Remove collaborator',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Remove a user from a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -71,6 +79,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'toggle_collaborator_roles',
|
||||
{
|
||||
title: 'Toggle collaborator roles',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update roles for a collaborator in a project',
|
||||
inputSchema: {
|
||||
userId: z.coerce.number().describe('User ID'),
|
||||
@@ -89,6 +99,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_roles',
|
||||
{
|
||||
title: 'List roles',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all roles for a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -105,6 +117,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_role',
|
||||
{
|
||||
title: 'Create role',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new role for a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -123,6 +137,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_role',
|
||||
{
|
||||
title: 'Delete role',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a role from a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -140,6 +156,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_role_permissions_for_goal',
|
||||
{
|
||||
title: 'List role permissions for project',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get role-to-permission mappings for a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -156,6 +174,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_permissions',
|
||||
{
|
||||
title: 'List permissions',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all available permissions',
|
||||
},
|
||||
async () => {
|
||||
@@ -169,6 +189,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'toggle_role_permission',
|
||||
{
|
||||
title: 'Toggle role permission',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Add or remove a permission from a role',
|
||||
inputSchema: {
|
||||
roleId: z.coerce.number().describe('Role ID'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_goals',
|
||||
{
|
||||
title: 'List projects',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all projects (goals) accessible to the current user. Optionally filter by organization.',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().optional().describe('Filter by organization ID'),
|
||||
@@ -23,6 +25,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_goal',
|
||||
{
|
||||
title: 'Create project',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new project (goal). If organizationId is provided, creates within that organization.',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Project name'),
|
||||
@@ -43,6 +47,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_goal',
|
||||
{
|
||||
title: 'Update project',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update a project (goal) — name, description, color',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -64,6 +70,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_goal',
|
||||
{
|
||||
title: 'Delete project',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a project (goal)',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID to delete'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerGraphTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_task_dependencies',
|
||||
{
|
||||
title: 'List task dependencies',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all task dependency edges in a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -23,6 +25,8 @@ export function registerGraphTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'add_task_dependency',
|
||||
{
|
||||
title: 'Add task dependency',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a dependency between two tasks',
|
||||
inputSchema: {
|
||||
source: z.coerce.number().describe('Source task ID'),
|
||||
@@ -40,6 +44,8 @@ export function registerGraphTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_task_dependency',
|
||||
{
|
||||
title: 'Delete task dependency',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a task dependency edge',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Dependency edge ID'),
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
/**
|
||||
* Every tool declares all three hints explicitly. ChatGPT refuses to list an
|
||||
* app whose tools leave any of them unset, and other clients use them to decide
|
||||
* what needs confirmation. openWorldHint is false throughout: a tool only ever
|
||||
* touches the user's own TaskView instance — the one exception reaches out to
|
||||
* an email address that may belong to someone outside it.
|
||||
*/
|
||||
export const toolAnnotations = {
|
||||
readOnly: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
|
||||
write: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
||||
destructive: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
||||
writeExternal: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
||||
} as const satisfies Record<string, ToolAnnotations>
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerKanbanTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_kanban_columns',
|
||||
{
|
||||
title: 'List kanban columns',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get all kanban columns for a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -23,6 +25,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_kanban_column',
|
||||
{
|
||||
title: 'Create kanban column',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new kanban column in a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -40,6 +44,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_kanban_column',
|
||||
{
|
||||
title: 'Update kanban column',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update a kanban column',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Column ID'),
|
||||
@@ -58,6 +64,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_kanban_column',
|
||||
{
|
||||
title: 'Delete kanban column',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a kanban column',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Column ID to delete'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerListsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_lists',
|
||||
{
|
||||
title: 'List lists in project',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get all task lists within a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -23,6 +25,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_list',
|
||||
{
|
||||
title: 'Create list',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new task list within a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -41,6 +45,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_list',
|
||||
{
|
||||
title: 'Update list',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update a task list',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('List ID'),
|
||||
@@ -60,6 +66,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_list',
|
||||
{
|
||||
title: 'Delete list',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a task list',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('List ID to delete'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerNotificationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_notifications',
|
||||
{
|
||||
title: 'List notifications',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get user notifications',
|
||||
inputSchema: {
|
||||
cursor: z.coerce.number().optional().describe('Pagination cursor'),
|
||||
@@ -23,6 +25,8 @@ export function registerNotificationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'mark_notification_read',
|
||||
{
|
||||
title: 'Mark notification as read',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Mark a notification as read',
|
||||
inputSchema: {
|
||||
notificationId: z.coerce.number().describe('Notification ID'),
|
||||
@@ -39,6 +43,8 @@ export function registerNotificationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'mark_all_notifications_read',
|
||||
{
|
||||
title: 'Mark all notifications as read',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Mark all notifications as read',
|
||||
},
|
||||
async () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_organizations',
|
||||
{
|
||||
title: 'List organizations',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all organizations the current user belongs to',
|
||||
},
|
||||
async () => {
|
||||
@@ -20,6 +22,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_organization',
|
||||
{
|
||||
title: 'Get organization',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get organization details by ID',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
@@ -36,6 +40,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_organization',
|
||||
{
|
||||
title: 'Create organization',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new organization. The creator becomes the owner.',
|
||||
inputSchema: {
|
||||
name: z.string().describe('Organization name'),
|
||||
@@ -55,6 +61,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_organization',
|
||||
{
|
||||
title: 'Update organization',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update organization name, slug, or logo. Requires admin or owner role.',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
@@ -75,6 +83,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_organization',
|
||||
{
|
||||
title: 'Delete organization',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete an organization. Only the owner can delete. Personal workspaces cannot be deleted.',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID to delete'),
|
||||
@@ -91,6 +101,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_organization_members',
|
||||
{
|
||||
title: 'List organization members',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all members of an organization',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
@@ -107,6 +119,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'add_organization_member',
|
||||
{
|
||||
title: 'Add organization member',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Add a member to an organization by email. Requires admin or owner role. NOTE: organization membership alone does NOT grant access to any project — the user will not see a project until they are also added to it via invite_collaborator. After adding the member, invite them to the relevant project(s).',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
@@ -126,6 +140,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_organization_member_role',
|
||||
{
|
||||
title: 'Change organization member role',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Change a member role in an organization. Cannot change the owner role.',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
@@ -145,6 +161,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'remove_organization_member',
|
||||
{
|
||||
title: 'Remove organization member',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Remove a member from an organization. Cannot remove the owner.',
|
||||
inputSchema: {
|
||||
organizationId: z.coerce.number().describe('Organization ID'),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerStartTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_agenda',
|
||||
{
|
||||
title: 'Get agenda',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description:
|
||||
'Get the user\'s agenda across every project in one call: tasks due today, '
|
||||
+ 'upcoming tasks, recently completed ones, and tasks with no deadline. '
|
||||
+ 'Use this for questions like "what do I have today", "what is coming up" or '
|
||||
+ '"what did I finish recently" instead of listing projects and their tasks one '
|
||||
+ 'by one — this is the same data the app\'s main screen shows, and the split '
|
||||
+ 'into today/upcoming is computed on the server in the given timezone.',
|
||||
inputSchema: {
|
||||
timezone: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'IANA timezone deciding where "today" ends, e.g. "Europe/Belgrade". '
|
||||
+ 'Defaults to UTC; pass the user\'s zone when it is known, otherwise the '
|
||||
+ 'day boundary may be off by a few hours.',
|
||||
),
|
||||
organizationId: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Limit to one organization; omit for every organization the user belongs to'),
|
||||
},
|
||||
},
|
||||
async ({ timezone, organizationId }) => {
|
||||
try {
|
||||
const result = await api.start.fetchAllState({
|
||||
tz: timezone || 'UTC',
|
||||
organizationId,
|
||||
})
|
||||
return ok(result)
|
||||
} catch (e) { return err(e) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerTagsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_tags',
|
||||
{
|
||||
title: 'List tags',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List all tags accessible to the current user',
|
||||
},
|
||||
async () => {
|
||||
@@ -20,6 +22,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_tag',
|
||||
{
|
||||
title: 'Create tag',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new tag',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -38,6 +42,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_tag',
|
||||
{
|
||||
title: 'Update tag',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update a tag',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Tag ID'),
|
||||
@@ -57,6 +63,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_tag',
|
||||
{
|
||||
title: 'Delete tag',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a tag',
|
||||
inputSchema: {
|
||||
tagId: z.coerce.number().describe('Tag ID to delete'),
|
||||
@@ -73,6 +81,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'toggle_task_tag',
|
||||
{
|
||||
title: 'Toggle tag on task',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Add or remove a tag from a task',
|
||||
inputSchema: {
|
||||
tagId: z.coerce.number().describe('Tag ID'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { type TvApi, ALL_TASKS_LIST_ID } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_tasks',
|
||||
{
|
||||
title: 'List tasks',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List tasks for a specific project (goal). Returns paginated results.',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -39,6 +41,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_task',
|
||||
{
|
||||
title: 'Get task',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get a single task by its ID with full details',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID'),
|
||||
@@ -56,6 +60,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'create_task',
|
||||
{
|
||||
title: 'Create task',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Create a new task in a project',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().describe('Project (goal) ID'),
|
||||
@@ -81,6 +87,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_task',
|
||||
{
|
||||
title: 'Update task',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update an existing task (description, status, priority, dates, cost, etc.)',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Task ID to update'),
|
||||
@@ -111,6 +119,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_task',
|
||||
{
|
||||
title: 'Delete task',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a task',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID to delete'),
|
||||
@@ -127,6 +137,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'toggle_task_assignees',
|
||||
{
|
||||
title: 'Toggle task assignees',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Assign or unassign users to/from a task',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID'),
|
||||
@@ -144,6 +156,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_task_history',
|
||||
{
|
||||
title: 'Get task history',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get the change history of a task',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID'),
|
||||
@@ -160,6 +174,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'restore_task_from_history',
|
||||
{
|
||||
title: 'Restore task from history',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Restore a task to a previous state from its history',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID'),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { TvApi } from 'taskview-api'
|
||||
import { z } from 'zod'
|
||||
import { ok, err } from './helpers.js'
|
||||
import { ok, err, toolAnnotations } from './helpers.js'
|
||||
|
||||
export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'start_timer',
|
||||
{
|
||||
title: 'Start timer',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Start a timer for the given task. Returns {entry, autoStoppedEntry} — autoStoppedEntry is the previous active timer that was stopped (null if there was no active timer).',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID to track time for'),
|
||||
@@ -27,6 +29,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'stop_timer',
|
||||
{
|
||||
title: 'Stop timer',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Stop the active timer (or specific entry by id) and return the closed entry.',
|
||||
inputSchema: {
|
||||
entryId: z.coerce.number().optional().describe('Specific entry ID; if omitted, stops the user\'s active timer'),
|
||||
@@ -43,6 +47,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_active_timer',
|
||||
{
|
||||
title: 'Get active timer',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get the user\'s currently running timer (if any).',
|
||||
},
|
||||
async () => {
|
||||
@@ -56,6 +62,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'log_time',
|
||||
{
|
||||
title: 'Log time',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Log a manual time entry retroactively (no time-period restrictions).',
|
||||
inputSchema: {
|
||||
taskId: z.coerce.number().describe('Task ID'),
|
||||
@@ -82,6 +90,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'list_time_entries',
|
||||
{
|
||||
title: 'List time entries',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'List time entries with optional filters (goalId, taskId, userId, date range).',
|
||||
inputSchema: {
|
||||
goalId: z.coerce.number().optional().describe('Filter by project'),
|
||||
@@ -104,6 +114,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_time_summary',
|
||||
{
|
||||
title: 'Get time summary',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description: 'Get aggregated time summary for a task or a project (goal).',
|
||||
inputSchema: {
|
||||
scope: z.enum(['task', 'goal']).describe('Aggregation scope'),
|
||||
@@ -124,6 +136,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'update_time_entry',
|
||||
{
|
||||
title: 'Update time entry',
|
||||
annotations: toolAnnotations.write,
|
||||
description: 'Update fields of an existing time entry (cannot edit started/ended of a running timer).',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Time entry ID'),
|
||||
@@ -144,6 +158,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_time_report',
|
||||
{
|
||||
title: 'Get time report',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description:
|
||||
'Aggregated time report. Choose scope: by-day (daily totals), by-user (per member), by-task (per task), summary (totals + billable). Filter by org, optional projects, optional user, date range.',
|
||||
inputSchema: {
|
||||
@@ -181,6 +197,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'get_time_contributors',
|
||||
{
|
||||
title: 'Get time contributors',
|
||||
annotations: toolAnnotations.readOnly,
|
||||
description:
|
||||
'List users who logged time in the given projects and date range, with total seconds and entry counts. Useful for "who worked on this project last month".',
|
||||
inputSchema: {
|
||||
@@ -208,6 +226,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
|
||||
server.registerTool(
|
||||
'delete_time_entry',
|
||||
{
|
||||
title: 'Delete time entry',
|
||||
annotations: toolAnnotations.destructive,
|
||||
description: 'Delete a time entry (writes a record to history.time_entries before deletion).',
|
||||
inputSchema: {
|
||||
id: z.coerce.number().describe('Time entry ID'),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { i18n, restoreSavedLocale } from './plugins/i18n'
|
||||
import { storeResetPlugin } from './plugins/pinia'
|
||||
import App from './App.vue'
|
||||
import authenticated from './middleware/authenticated'
|
||||
import authenticatedWithReturn from './middleware/authenticated-with-return'
|
||||
import syncSelectedProject from './middleware/syncSelectedProject'
|
||||
import api from './plugins/axios'
|
||||
import LoginPage from './pages/login.vue'
|
||||
@@ -37,6 +38,15 @@ function buildRoutes(extensions: TvWebExtension[]): RouteRecordRaw[] {
|
||||
name: 'reset-password',
|
||||
component: () => import('./pages/reset-password.vue'),
|
||||
},
|
||||
{
|
||||
// Outside /:orgSlug on purpose — the OAuth flow lands here before any
|
||||
// organization is chosen. The guard sends an unauthenticated user to
|
||||
// login and back, so consent is always given by a real browser session.
|
||||
path: '/oauth/consent',
|
||||
name: 'oauth-consent',
|
||||
component: () => import('./pages/oauth-consent.vue'),
|
||||
beforeEnter: [authenticatedWithReturn],
|
||||
},
|
||||
...extensionRoutes,
|
||||
{
|
||||
path: '/:orgSlug',
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
<ApiTokensPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard
|
||||
variant="soft"
|
||||
class="w-full rounded-3xl"
|
||||
>
|
||||
<ConnectedAppsPanel />
|
||||
</UPageCard>
|
||||
|
||||
<UPageCard
|
||||
v-if="isDefaultUser"
|
||||
variant="soft"
|
||||
@@ -68,6 +75,7 @@ import PasswordSettings from './parts/PasswordSettings.vue'
|
||||
import DefaultUserCredentials from './parts/DefaultUserCredentials.vue'
|
||||
import SessionsPanel from '@/components/features/sessions/SessionsPanel.vue'
|
||||
import ApiTokensPanel from '@/components/features/api-tokens/ApiTokensPanel.vue'
|
||||
import ConnectedAppsPanel from '@/components/features/oauth/ConnectedAppsPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -49,26 +49,7 @@
|
||||
class="underline underline-offset-2 hover:text-default"
|
||||
>{{ t('apiTokens.permissionsDocs') }}</a>
|
||||
</p>
|
||||
<div class="max-h-64 overflow-y-auto border border-default rounded-lg p-3">
|
||||
<div
|
||||
v-for="(group, groupId) in groupedPermissions"
|
||||
:key="groupId"
|
||||
class="mb-3 last:mb-0"
|
||||
>
|
||||
<p class="text-xs font-semibold text-muted mb-1 uppercase">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<UCheckbox
|
||||
v-for="perm in group.items"
|
||||
:key="perm.id"
|
||||
:model-value="selectedPermissions.includes(perm.name)"
|
||||
:label="perm.description || perm.name"
|
||||
@update:model-value="togglePermission(perm.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TvPermissionPicker v-model="selectedPermissions" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -129,12 +110,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useApiTokensStore } from '@/stores/api-tokens.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
import TvPermissionPicker from '@/components/features/base/TvPermissionPicker.vue'
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
@@ -170,34 +152,6 @@ const goalOptions = computed(() =>
|
||||
.map((g) => ({ label: g.name, value: g.id })),
|
||||
)
|
||||
|
||||
const permissionGroupNames: Record<number, string> = {
|
||||
1: 'Application level',
|
||||
2: 'Project',
|
||||
3: 'Lists',
|
||||
4: 'Tasks',
|
||||
}
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups: Record<number, { name: string; items: typeof store.permissions }> = {}
|
||||
for (const perm of store.permissions) {
|
||||
const gid = perm.permissionGroup
|
||||
if (!groups[gid]) {
|
||||
groups[gid] = { name: permissionGroupNames[gid] || `Group ${gid}`, items: [] }
|
||||
}
|
||||
groups[gid].items.push(perm)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function togglePermission(permName: string) {
|
||||
const idx = selectedPermissions.value.indexOf(permName)
|
||||
if (idx === -1) {
|
||||
selectedPermissions.value.push(permName)
|
||||
} else {
|
||||
selectedPermissions.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getExpiresAt(): string | null {
|
||||
if (expiration.value === 'none') return null
|
||||
const days = parseInt(expiration.value)
|
||||
@@ -231,9 +185,4 @@ async function handleCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.permissions.length === 0) {
|
||||
store.fetchPermissions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,10 +5,23 @@ import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import { useUiPreferencesStore } from '@/stores/uiPreferences.store'
|
||||
import { useProjectRoute } from '@/composables/useProjectRoute'
|
||||
|
||||
/** Only internal absolute paths — never an absolute URL, which would be an open redirect. */
|
||||
const safeReturnPath = (raw: unknown): string | null => {
|
||||
if (typeof raw !== 'string') return null
|
||||
if (!raw.startsWith('/') || raw.startsWith('//')) return null
|
||||
return raw
|
||||
}
|
||||
|
||||
export const redirectToUser = async (router: Router) => {
|
||||
const userStore = useUserStore()
|
||||
if (!userStore.accessToken) return
|
||||
|
||||
const returnPath = safeReturnPath(router.currentRoute.value.query.redirect)
|
||||
if (returnPath) {
|
||||
await router.replace(returnPath)
|
||||
return
|
||||
}
|
||||
|
||||
const orgStore = useOrganizationStore()
|
||||
if (!orgStore.organizations.length) {
|
||||
await orgStore.fetchOrganizations()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="max-h-64 overflow-y-auto border border-default rounded-lg p-3">
|
||||
<div
|
||||
v-for="(group, groupId) in groupedPermissions"
|
||||
:key="groupId"
|
||||
class="mb-3 last:mb-0"
|
||||
>
|
||||
<p class="text-xs font-semibold text-muted mb-1 uppercase">
|
||||
{{ group.name }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<UCheckbox
|
||||
v-for="perm in group.items"
|
||||
:key="perm.id"
|
||||
:model-value="selected.includes(perm.name)"
|
||||
:label="describe(perm)"
|
||||
@update:model-value="toggle(perm.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useApiTokensStore } from '@/stores/api-tokens.store'
|
||||
import type { ApiTokenPermission } from 'taskview-api'
|
||||
|
||||
const selected = defineModel<string[]>({ default: () => [] })
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const store = useApiTokensStore()
|
||||
|
||||
// The table carries a per-locale description; fall back to the English column,
|
||||
// then to the raw key, so a permission added without translations still reads.
|
||||
function describe(permission: ApiTokenPermission): string {
|
||||
return permission.descriptionLocales?.[locale.value]
|
||||
|| permission.description
|
||||
|| permission.name
|
||||
}
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups: Record<number, { name: string; items: ApiTokenPermission[] }> = {}
|
||||
for (const perm of store.permissions) {
|
||||
const gid = perm.permissionGroup
|
||||
if (!groups[gid]) {
|
||||
groups[gid] = { name: t(`permissionGroups.${gid}`), items: [] }
|
||||
}
|
||||
groups[gid].items.push(perm)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function toggle(permName: string) {
|
||||
const index = selected.value.indexOf(permName)
|
||||
if (index === -1) {
|
||||
selected.value = [...selected.value, permName]
|
||||
} else {
|
||||
selected.value = selected.value.filter((name) => name !== permName)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.permissions.length === 0) {
|
||||
store.fetchPermissions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold">
|
||||
{{ t('oauth.connectedApps.title') }}
|
||||
</h2>
|
||||
<p class="text-sm text-muted">
|
||||
{{ t('oauth.connectedApps.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.loading"
|
||||
class="flex items-center justify-center h-32"
|
||||
>
|
||||
<p>{{ t('common.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.connectedApps.length === 0"
|
||||
class="flex flex-col items-center justify-center h-32 text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plug"
|
||||
class="size-10 mb-3"
|
||||
/>
|
||||
<p>{{ t('oauth.connectedApps.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<ConnectedAppItem
|
||||
v-for="app in store.connectedApps"
|
||||
:key="app.grantId"
|
||||
:app="app"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOAuthStore } from '@/stores/oauth.store'
|
||||
import ConnectedAppItem from './parts/ConnectedAppItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useOAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchConnectedApps()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<UCard class="w-full max-w-lg">
|
||||
<template #header>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon
|
||||
name="i-lucide-plug-zap"
|
||||
class="size-6 shrink-0 text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">
|
||||
{{ t('oauth.consent.title', { client: clientName }) }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
{{ t('oauth.consent.subtitle', { client: clientName }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-elevated p-3">
|
||||
<p class="text-xs text-muted">
|
||||
{{ t('oauth.consent.destinationLabel') }}
|
||||
</p>
|
||||
<p class="font-medium break-all mt-0.5">
|
||||
{{ redirectHost }}
|
||||
</p>
|
||||
<p class="text-xs text-muted break-all mt-0.5">
|
||||
{{ redirectUri }}
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-2">
|
||||
{{ isLocalDestination
|
||||
? t('oauth.consent.destinationLocalHint')
|
||||
: t('oauth.consent.destinationHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<UAlert
|
||||
icon="i-lucide-shield-check"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:description="t('oauth.consent.rbacNote')"
|
||||
/>
|
||||
|
||||
<UFormField :label="t('oauth.consent.projectsLabel')">
|
||||
<USelectMenu
|
||||
v-model="selectedGoalIds"
|
||||
:items="goalOptions"
|
||||
multiple
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
class="w-full"
|
||||
:placeholder="t('oauth.consent.allProjects')"
|
||||
/>
|
||||
<UAlert
|
||||
v-if="!selectedGoalIds.length"
|
||||
icon="i-lucide-triangle-alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
class="mt-2"
|
||||
:ui="{ description: 'text-xs' }"
|
||||
:description="t('oauth.consent.projectsWarning')"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted mt-2"
|
||||
>
|
||||
{{ t('oauth.consent.projectsHint') }}
|
||||
</p>
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="t('oauth.consent.permissionsLabel')">
|
||||
<TvPermissionPicker v-model="selectedPermissions" />
|
||||
<UAlert
|
||||
v-if="!selectedPermissions.length"
|
||||
icon="i-lucide-triangle-alert"
|
||||
color="warning"
|
||||
variant="soft"
|
||||
class="mt-2"
|
||||
:ui="{ description: 'text-xs' }"
|
||||
:description="t('oauth.consent.permissionsWarning')"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted mt-2"
|
||||
>
|
||||
{{ t('oauth.consent.permissionsHint') }}
|
||||
</p>
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('oauth.consent.deny')"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:disabled="store.submitting"
|
||||
@click="emit('deny')"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('oauth.consent.approve')"
|
||||
color="primary"
|
||||
:loading="store.submitting"
|
||||
@click="onApprove"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useOAuthStore } from '@/stores/oauth.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import TvPermissionPicker from '@/components/features/base/TvPermissionPicker.vue'
|
||||
import type { OAuthConsentSelection } from '@/types/oauth.types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useOAuthStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
|
||||
const props = defineProps<{
|
||||
clientName: string
|
||||
redirectUri: string
|
||||
}>()
|
||||
|
||||
const LOOPBACK_HOSTS = ['127.0.0.1', '::1', '[::1]', 'localhost']
|
||||
|
||||
const redirectUrl = computed(() => {
|
||||
try {
|
||||
return new URL(props.redirectUri)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// The host is what a person can actually check against the app they think they
|
||||
// are connecting; the full URI stays visible underneath it.
|
||||
const redirectHost = computed(() => redirectUrl.value?.host ?? props.redirectUri)
|
||||
|
||||
const isLocalDestination = computed(() =>
|
||||
!!redirectUrl.value && LOOPBACK_HOSTS.includes(redirectUrl.value.hostname),
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: [selection: OAuthConsentSelection]
|
||||
deny: []
|
||||
}>()
|
||||
|
||||
// Nothing ticked means "everything you can do", the same as a tvk_ token issued
|
||||
// with no permissions picked. The warning under the picker spells that out.
|
||||
const selectedPermissions = ref<string[]>([])
|
||||
const selectedGoalIds = ref<number[]>([])
|
||||
|
||||
const goalOptions = computed(() =>
|
||||
goalsStore.goals
|
||||
.filter((goal) => goal.archive === 0)
|
||||
.map((goal) => ({ label: goal.name, value: goal.id })),
|
||||
)
|
||||
|
||||
function onApprove() {
|
||||
emit('approve', {
|
||||
allowedGoalIds: selectedGoalIds.value,
|
||||
allowedPermissions: selectedPermissions.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<UCard
|
||||
variant="soft"
|
||||
:ui="{ body: 'w-full flex items-center justify-between p-4', root: 'rounded-2xl' }"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<UIcon
|
||||
name="i-lucide-plug-zap"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">
|
||||
{{ app.clientName }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted mt-1">
|
||||
<span v-if="app.createdAt">
|
||||
{{ t('oauth.connectedApps.connected') }}: {{ formatDate(app.createdAt) }}
|
||||
</span>
|
||||
<span>
|
||||
{{ t('oauth.connectedApps.lastUsed') }}:
|
||||
{{ app.lastUsedAt ? formatDate(app.lastUsedAt) : t('oauth.connectedApps.never') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 mt-1.5">
|
||||
<UBadge
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ app.allowedPermissions.length
|
||||
? t('oauth.connectedApps.permissionsCount', { count: app.allowedPermissions.length })
|
||||
: t('oauth.connectedApps.allPermissions') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
variant="subtle"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
>
|
||||
{{ app.allowedGoalIds.length
|
||||
? t('oauth.connectedApps.projectsCount', { count: app.allowedGoalIds.length })
|
||||
: t('oauth.connectedApps.allProjects') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="xl"
|
||||
@click="showRevokeConfirm = true"
|
||||
/>
|
||||
</UCard>
|
||||
|
||||
<UModal
|
||||
v-model:open="showRevokeConfirm"
|
||||
:fullscreen="isMobile"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold">
|
||||
{{ t('oauth.connectedApps.revoke') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-sm">
|
||||
{{ t('oauth.connectedApps.revokeConfirm', { client: app.clientName }) }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="w-full flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
variant="ghost"
|
||||
@click="showRevokeConfirm = false"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('oauth.connectedApps.revoke')"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:loading="revoking"
|
||||
@click="handleRevoke"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OAuthConnectedApp } from 'taskview-api'
|
||||
import { useOAuthStore } from '@/stores/oauth.store'
|
||||
import { useTaskView } from '@/composables/useTaskView'
|
||||
|
||||
const props = defineProps<{
|
||||
app: OAuthConnectedApp
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isMobile } = useTaskView()
|
||||
const store = useOAuthStore()
|
||||
|
||||
const showRevokeConfirm = ref(false)
|
||||
const revoking = ref(false)
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
revoking.value = true
|
||||
await store.revokeConnectedApp(props.app.grantId)
|
||||
showRevokeConfirm.value = false
|
||||
revoking.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -656,6 +656,9 @@ export default {
|
||||
page: {
|
||||
title: 'Analytik',
|
||||
subtitle: 'Metriken zu Produktivität und Projektgesundheit',
|
||||
destinationLabel: 'Daten werden gesendet an',
|
||||
destinationHint: 'Prüfen Sie, ob diese Adresse zu der App gehört, die Sie verbinden. Den Namen oben gibt die App selbst an, er kann beliebig sein.',
|
||||
destinationLocalHint: 'Diese Adresse liegt auf Ihrem eigenen Rechner, die App läuft also lokal.',
|
||||
emptyState: 'Noch keine Daten. Erstellen Sie Aufgaben, um Analytik zu sehen.',
|
||||
emptyStateForPeriod: 'Keine Daten für den ausgewählten Zeitraum. Versuchen Sie einen längeren Zeitraum.',
|
||||
partialFailure: 'Einige Abschnitte konnten nicht geladen werden. Versuchen Sie es erneut.',
|
||||
@@ -914,6 +917,46 @@ export default {
|
||||
tokenFailed: 'Token konnte nicht generiert werden',
|
||||
toggleFailed: 'SCIM konnte nicht umgeschaltet werden',
|
||||
},
|
||||
permissionGroups: {
|
||||
1: 'Anwendungsebene',
|
||||
2: 'Projekt',
|
||||
3: 'Listen',
|
||||
4: 'Aufgaben',
|
||||
5: 'Sprints',
|
||||
6: 'Organisation',
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
title: '{client} verbinden',
|
||||
subtitle: '{client} fragt Zugriff auf Ihr TaskView-Konto an.',
|
||||
rbacNote: 'Der Zugriff geht nie über Ihre eigenen Berechtigungen hinaus — was Sie in TaskView nicht dürfen, darf die App auch nicht.',
|
||||
projectsLabel: 'Auf Projekte beschränken',
|
||||
allProjects: 'Alle Projekte',
|
||||
projectsHint: 'Leer lassen, um alle Projekte zuzulassen, auf die Sie Zugriff haben.',
|
||||
permissionsLabel: 'Berechtigungen einschränken',
|
||||
permissionsHint: 'Leer lassen, um alles zu erlauben, was Sie selbst dürfen.',
|
||||
projectsWarning: 'Kein Projekt ausgewählt — die App erhält Zugriff auf alle Projekte, auf die Sie Zugriff haben.',
|
||||
permissionsWarning: 'Keine Berechtigung ausgewählt — die App darf alles tun, was Sie selbst dürfen.',
|
||||
approve: 'Zugriff erlauben',
|
||||
deny: 'Ablehnen',
|
||||
invalidTitle: 'Ungültige Autorisierungsanfrage',
|
||||
invalidDescription: 'In diesem Link fehlen erforderliche Parameter. Starten Sie die Verbindung in der App erneut.',
|
||||
},
|
||||
connectedApps: {
|
||||
title: 'Verbundene Apps',
|
||||
description: 'Apps, die in Ihrem Namen handeln dürfen. Ein Widerruf beendet den Zugriff sofort.',
|
||||
empty: 'Keine verbundenen Apps',
|
||||
connected: 'Verbunden',
|
||||
lastUsed: 'Zuletzt verwendet',
|
||||
never: 'nie',
|
||||
projectsCount: '{count} Projekte',
|
||||
permissionsCount: '{count} Berechtigungen',
|
||||
allPermissions: 'Alle Berechtigungen',
|
||||
allProjects: 'Alle Projekte',
|
||||
revoke: 'Zugriff widerrufen',
|
||||
revokeConfirm: 'Zugriff für {client} widerrufen? Die App funktioniert sofort nicht mehr.',
|
||||
},
|
||||
},
|
||||
apiTokens: {
|
||||
title: 'API-Tokens',
|
||||
description: 'Erstellen Sie Tokens, um programmgesteuert auf die API zuzugreifen.',
|
||||
|
||||
@@ -670,6 +670,9 @@ export default {
|
||||
page: {
|
||||
title: 'Analytics',
|
||||
subtitle: 'Productivity and project health metrics',
|
||||
destinationLabel: 'Data will be sent to',
|
||||
destinationHint: 'Check that this address belongs to the app you are connecting. The name above is supplied by the app itself and can be anything.',
|
||||
destinationLocalHint: 'This address is on your own computer, so the app runs locally.',
|
||||
emptyState: 'No data yet. Create tasks to see analytics.',
|
||||
emptyStateForPeriod: 'No data for the selected period. Try a longer one.',
|
||||
partialFailure: 'Some sections failed to load. Try refreshing.',
|
||||
@@ -928,6 +931,46 @@ export default {
|
||||
tokenFailed: 'Failed to generate token',
|
||||
toggleFailed: 'Failed to toggle SCIM',
|
||||
},
|
||||
permissionGroups: {
|
||||
1: 'Application level',
|
||||
2: 'Project',
|
||||
3: 'Lists',
|
||||
4: 'Tasks',
|
||||
5: 'Sprints',
|
||||
6: 'Organization',
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
title: 'Connect {client}',
|
||||
subtitle: '{client} is asking for access to your TaskView account.',
|
||||
rbacNote: 'Access never exceeds your own permissions — if you cannot do something in TaskView, neither can this app.',
|
||||
projectsLabel: 'Limit to projects',
|
||||
allProjects: 'All projects',
|
||||
projectsHint: 'Leave empty to allow every project you have access to.',
|
||||
permissionsLabel: 'Limit permissions',
|
||||
permissionsHint: 'Leave empty to allow everything you can do yourself.',
|
||||
projectsWarning: 'No project selected — the app will be able to reach every project you have access to.',
|
||||
permissionsWarning: 'No permission selected — the app will be able to do everything you can do yourself.',
|
||||
approve: 'Allow access',
|
||||
deny: 'Deny',
|
||||
invalidTitle: 'Invalid authorization request',
|
||||
invalidDescription: 'This link is missing required parameters. Start the connection again from the app you were using.',
|
||||
},
|
||||
connectedApps: {
|
||||
title: 'Connected apps',
|
||||
description: 'Apps you allowed to act on your behalf. Revoking one cuts off its access immediately.',
|
||||
empty: 'No connected apps',
|
||||
connected: 'Connected',
|
||||
lastUsed: 'Last used',
|
||||
never: 'never',
|
||||
projectsCount: '{count} projects',
|
||||
permissionsCount: '{count} permissions',
|
||||
allPermissions: 'All permissions',
|
||||
allProjects: 'All projects',
|
||||
revoke: 'Revoke access',
|
||||
revokeConfirm: 'Revoke access for {client}? It will stop working immediately.',
|
||||
},
|
||||
},
|
||||
apiTokens: {
|
||||
title: 'API Tokens',
|
||||
description: 'Create tokens to access the API programmatically.',
|
||||
|
||||
@@ -656,6 +656,9 @@ export default {
|
||||
page: {
|
||||
title: 'Analíticas',
|
||||
subtitle: 'Métricas de productividad y estado del proyecto',
|
||||
destinationLabel: 'Los datos se enviarán a',
|
||||
destinationHint: 'Comprueba que esta dirección pertenece a la aplicación que estás conectando. El nombre de arriba lo indica la propia aplicación y puede ser cualquiera.',
|
||||
destinationLocalHint: 'Esta dirección está en tu propio equipo, así que la aplicación se ejecuta localmente.',
|
||||
emptyState: 'Aún no hay datos. Crea tareas para ver las analíticas.',
|
||||
emptyStateForPeriod: 'No hay datos para el período seleccionado. Prueba con uno más largo.',
|
||||
partialFailure: 'Algunas secciones no se pudieron cargar. Prueba a actualizar.',
|
||||
@@ -914,6 +917,46 @@ export default {
|
||||
tokenFailed: 'Error al generar el token',
|
||||
toggleFailed: 'Error al activar o desactivar SCIM',
|
||||
},
|
||||
permissionGroups: {
|
||||
1: 'Nivel de aplicación',
|
||||
2: 'Proyecto',
|
||||
3: 'Listas',
|
||||
4: 'Tareas',
|
||||
5: 'Sprints',
|
||||
6: 'Organización',
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
title: 'Conectar {client}',
|
||||
subtitle: '{client} solicita acceso a tu cuenta de TaskView.',
|
||||
rbacNote: 'El acceso nunca supera tus propios permisos: si no puedes hacer algo en TaskView, la aplicación tampoco.',
|
||||
projectsLabel: 'Limitar a proyectos',
|
||||
allProjects: 'Todos los proyectos',
|
||||
projectsHint: 'Déjalo vacío para permitir todos los proyectos a los que tienes acceso.',
|
||||
permissionsLabel: 'Limitar permisos',
|
||||
permissionsHint: 'Déjalo vacío para permitir todo lo que tú mismo puedes hacer.',
|
||||
projectsWarning: 'Ningún proyecto seleccionado: la aplicación podrá acceder a todos los proyectos a los que tienes acceso.',
|
||||
permissionsWarning: 'Ningún permiso seleccionado: la aplicación podrá hacer todo lo que tú mismo puedes hacer.',
|
||||
approve: 'Permitir acceso',
|
||||
deny: 'Denegar',
|
||||
invalidTitle: 'Solicitud de autorización no válida',
|
||||
invalidDescription: 'A este enlace le faltan parámetros obligatorios. Inicia la conexión de nuevo desde la aplicación.',
|
||||
},
|
||||
connectedApps: {
|
||||
title: 'Aplicaciones conectadas',
|
||||
description: 'Aplicaciones a las que permitiste actuar en tu nombre. Revocar corta su acceso de inmediato.',
|
||||
empty: 'No hay aplicaciones conectadas',
|
||||
connected: 'Conectada',
|
||||
lastUsed: 'Último uso',
|
||||
never: 'nunca',
|
||||
projectsCount: '{count} proyectos',
|
||||
permissionsCount: '{count} permisos',
|
||||
allPermissions: 'Todos los permisos',
|
||||
allProjects: 'Todos los proyectos',
|
||||
revoke: 'Revocar acceso',
|
||||
revokeConfirm: '¿Revocar el acceso de {client}? Dejará de funcionar de inmediato.',
|
||||
},
|
||||
},
|
||||
apiTokens: {
|
||||
title: 'Tokens de API',
|
||||
description: 'Crea tokens para acceder a la API de forma programática.',
|
||||
|
||||
@@ -669,6 +669,9 @@ export default {
|
||||
page: {
|
||||
title: 'Análises',
|
||||
subtitle: 'Métricas de produtividade e saúde dos projetos',
|
||||
destinationLabel: 'Os dados serão enviados para',
|
||||
destinationHint: 'Verifique se este endereço pertence ao aplicativo que você está conectando. O nome acima é informado pelo próprio aplicativo e pode ser qualquer um.',
|
||||
destinationLocalHint: 'Este endereço está no seu próprio computador, então o aplicativo roda localmente.',
|
||||
emptyState: 'Ainda não há dados. Crie tarefas para visualizar as análises.',
|
||||
emptyStateForPeriod: 'Não há dados para o período selecionado. Tente um período maior.',
|
||||
partialFailure: 'Algumas seções não foram carregadas. Tente atualizar a página.',
|
||||
@@ -925,6 +928,46 @@ export default {
|
||||
tokenFailed: 'Falha ao gerar o token',
|
||||
toggleFailed: 'Falha ao alternar o SCIM',
|
||||
},
|
||||
permissionGroups: {
|
||||
1: 'Nível do aplicativo',
|
||||
2: 'Projeto',
|
||||
3: 'Listas',
|
||||
4: 'Tarefas',
|
||||
5: 'Sprints',
|
||||
6: 'Organização',
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
title: 'Conectar {client}',
|
||||
subtitle: '{client} está solicitando acesso à sua conta TaskView.',
|
||||
rbacNote: 'O acesso nunca ultrapassa suas próprias permissões — se você não pode fazer algo no TaskView, o aplicativo também não pode.',
|
||||
projectsLabel: 'Limitar a projetos',
|
||||
allProjects: 'Todos os projetos',
|
||||
projectsHint: 'Deixe vazio para permitir todos os projetos a que você tem acesso.',
|
||||
permissionsLabel: 'Limitar permissões',
|
||||
permissionsHint: 'Deixe vazio para permitir tudo o que você mesmo pode fazer.',
|
||||
projectsWarning: 'Nenhum projeto selecionado — o aplicativo poderá acessar todos os projetos a que você tem acesso.',
|
||||
permissionsWarning: 'Nenhuma permissão selecionada — o aplicativo poderá fazer tudo o que você mesmo pode fazer.',
|
||||
approve: 'Permitir acesso',
|
||||
deny: 'Negar',
|
||||
invalidTitle: 'Solicitação de autorização inválida',
|
||||
invalidDescription: 'Faltam parâmetros obrigatórios neste link. Inicie a conexão novamente pelo aplicativo.',
|
||||
},
|
||||
connectedApps: {
|
||||
title: 'Aplicativos conectados',
|
||||
description: 'Aplicativos que você autorizou a agir em seu nome. Revogar encerra o acesso imediatamente.',
|
||||
empty: 'Nenhum aplicativo conectado',
|
||||
connected: 'Conectado',
|
||||
lastUsed: 'Último uso',
|
||||
never: 'nunca',
|
||||
projectsCount: '{count} projetos',
|
||||
permissionsCount: '{count} permissões',
|
||||
allPermissions: 'Todas as permissões',
|
||||
allProjects: 'Todos os projetos',
|
||||
revoke: 'Revogar acesso',
|
||||
revokeConfirm: 'Revogar o acesso de {client}? Ele parará de funcionar imediatamente.',
|
||||
},
|
||||
},
|
||||
apiTokens: {
|
||||
title: 'Tokens de API',
|
||||
description: 'Crie tokens para acessar a API programaticamente.',
|
||||
|
||||
@@ -643,6 +643,9 @@ export default {
|
||||
page: {
|
||||
title: 'Аналитика',
|
||||
subtitle: 'Показатели продуктивности и здоровья проектов',
|
||||
destinationLabel: 'Данные будут отправлены на',
|
||||
destinationHint: 'Убедитесь, что этот адрес принадлежит подключаемому приложению. Название выше приложение указывает о себе само, и оно может быть любым.',
|
||||
destinationLocalHint: 'Адрес указывает на ваш компьютер — значит приложение работает локально.',
|
||||
emptyState: 'Нет данных. Создайте задачи, чтобы увидеть аналитику.',
|
||||
emptyStateForPeriod: 'За выбранный период данных нет. Попробуйте увеличить период.',
|
||||
partialFailure: 'Некоторые секции не удалось загрузить. Попробуйте обновить.',
|
||||
@@ -907,6 +910,46 @@ export default {
|
||||
markAllRead: 'Прочитать все',
|
||||
loadMore: 'Загрузить ещё',
|
||||
},
|
||||
permissionGroups: {
|
||||
1: 'Уровень приложения',
|
||||
2: 'Проект',
|
||||
3: 'Списки',
|
||||
4: 'Задачи',
|
||||
5: 'Спринты',
|
||||
6: 'Организация',
|
||||
},
|
||||
oauth: {
|
||||
consent: {
|
||||
title: 'Подключить {client}',
|
||||
subtitle: '{client} запрашивает доступ к вашему аккаунту TaskView.',
|
||||
rbacNote: 'Доступ никогда не превысит ваши собственные права — если вы чего-то не можете в TaskView, не сможет и приложение.',
|
||||
projectsLabel: 'Ограничить проектами',
|
||||
allProjects: 'Все проекты',
|
||||
projectsHint: 'Оставьте пустым, чтобы разрешить все доступные вам проекты.',
|
||||
permissionsLabel: 'Ограничить разрешения',
|
||||
permissionsHint: 'Оставьте пустым, чтобы разрешить всё, что можете вы сами.',
|
||||
projectsWarning: 'Проект не выбран — приложение получит доступ ко всем вашим проектам.',
|
||||
permissionsWarning: 'Разрешения не выбраны — приложение сможет делать всё, что можете вы сами.',
|
||||
approve: 'Разрешить доступ',
|
||||
deny: 'Отклонить',
|
||||
invalidTitle: 'Некорректный запрос авторизации',
|
||||
invalidDescription: 'В ссылке не хватает обязательных параметров. Начните подключение заново из приложения.',
|
||||
},
|
||||
connectedApps: {
|
||||
title: 'Подключённые приложения',
|
||||
description: 'Приложения, которым вы разрешили действовать от вашего имени. Отзыв мгновенно прекращает их доступ.',
|
||||
empty: 'Нет подключённых приложений',
|
||||
connected: 'Подключено',
|
||||
lastUsed: 'Последнее использование',
|
||||
never: 'никогда',
|
||||
projectsCount: 'проектов: {count}',
|
||||
permissionsCount: 'разрешений: {count}',
|
||||
allPermissions: 'Все разрешения',
|
||||
allProjects: 'Все проекты',
|
||||
revoke: 'Отозвать доступ',
|
||||
revokeConfirm: 'Отозвать доступ для {client}? Приложение сразу перестанет работать.',
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
title: 'Активные сессии',
|
||||
description: 'Управляйте активными сессиями. Вы можете завершить любую сессию кроме текущей.',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
import { $ls } from '@/plugins/axios'
|
||||
|
||||
/**
|
||||
* Like `authenticated`, but remembers where the user was going. The OAuth
|
||||
* consent screen is usually the first TaskView page a connector opens, so an
|
||||
* unauthenticated user must come back to it — with its query intact — instead
|
||||
* of landing on their project list with the authorization request lost.
|
||||
*/
|
||||
export default async function authenticatedWithReturn(to: RouteLocationNormalized) {
|
||||
await $ls.updateUserStoreByToken()
|
||||
if (!(await $ls.getToken())) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-elevated/40">
|
||||
<div
|
||||
v-if="paramError"
|
||||
class="w-full max-w-lg"
|
||||
>
|
||||
<UAlert
|
||||
icon="i-lucide-triangle-alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:title="t('oauth.consent.invalidTitle')"
|
||||
:description="t('oauth.consent.invalidDescription')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OAuthConsentCard
|
||||
v-else
|
||||
:client-name="clientName"
|
||||
:redirect-uri="request.redirect_uri"
|
||||
@approve="onApprove"
|
||||
@deny="onDeny"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { OAuthConsentRequest } from 'taskview-api'
|
||||
import { useOAuthStore } from '@/stores/oauth.store'
|
||||
import { useGoalsStore } from '@/stores/goals.store'
|
||||
import { useOrganizationStore } from '@/stores/organization.store'
|
||||
import OAuthConsentCard from '@/components/features/oauth/OAuthConsentCard.vue'
|
||||
import type { OAuthConsentSelection } from '@/types/oauth.types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const store = useOAuthStore()
|
||||
const goalsStore = useGoalsStore()
|
||||
const orgStore = useOrganizationStore()
|
||||
|
||||
const readParam = (key: string): string => {
|
||||
const value = route.query[key]
|
||||
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
|
||||
}
|
||||
|
||||
const clientName = computed(() => readParam('client_name') || readParam('client_id'))
|
||||
const request = computed<OAuthConsentRequest>(() => ({
|
||||
client_id: readParam('client_id'),
|
||||
redirect_uri: readParam('redirect_uri'),
|
||||
code_challenge: readParam('code_challenge'),
|
||||
code_challenge_method: 'S256',
|
||||
state: readParam('state') || undefined,
|
||||
resource: readParam('resource') || undefined,
|
||||
}))
|
||||
|
||||
const paramError = computed(() =>
|
||||
!request.value.client_id
|
||||
|| !request.value.redirect_uri
|
||||
|| !request.value.code_challenge
|
||||
|| readParam('code_challenge_method') !== 'S256',
|
||||
)
|
||||
|
||||
const leaving = ref(false)
|
||||
|
||||
const leaveTo = (url: string | null) => {
|
||||
if (!url) return
|
||||
leaving.value = true
|
||||
window.location.replace(url)
|
||||
}
|
||||
|
||||
const onApprove = async (selection: OAuthConsentSelection) => {
|
||||
leaveTo(await store.approveConsent({ ...request.value, ...selection }))
|
||||
}
|
||||
|
||||
const onDeny = async () => {
|
||||
leaveTo(await store.denyConsent(request.value))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (paramError.value) return
|
||||
if (!orgStore.organizations.length) await orgStore.fetchOrganizations()
|
||||
if (!orgStore.currentOrg) orgStore.restoreCurrentOrg()
|
||||
await goalsStore.fetchGoals()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { OAuthConnectedApp, OAuthConsentRequest } from 'taskview-api'
|
||||
import { $tvApi } from '@/plugins/axios'
|
||||
|
||||
interface OAuthStoreState {
|
||||
connectedApps: OAuthConnectedApp[]
|
||||
loading: boolean
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
export const useOAuthStore = defineStore('oauth', {
|
||||
state: (): OAuthStoreState => ({
|
||||
connectedApps: [],
|
||||
loading: false,
|
||||
submitting: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchConnectedApps(): Promise<void> {
|
||||
this.loading = true
|
||||
const result = await $tvApi.oauth.fetchConnectedApps()
|
||||
this.connectedApps = result || []
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async revokeConnectedApp(grantId: number): Promise<void> {
|
||||
const result = await $tvApi.oauth.revokeConnectedApp(grantId)
|
||||
if (!result) return
|
||||
const index = this.connectedApps.findIndex((app) => app.grantId === grantId)
|
||||
if (index !== -1) {
|
||||
this.connectedApps.splice(index, 1)
|
||||
}
|
||||
},
|
||||
|
||||
async approveConsent(data: OAuthConsentRequest): Promise<string | null> {
|
||||
this.submitting = true
|
||||
const result = await $tvApi.oauth.approveConsent(data)
|
||||
this.submitting = false
|
||||
return result?.redirectUrl ?? null
|
||||
},
|
||||
|
||||
async denyConsent(data: OAuthConsentRequest): Promise<string | null> {
|
||||
this.submitting = true
|
||||
const result = await $tvApi.oauth.denyConsent(data)
|
||||
this.submitting = false
|
||||
return result?.redirectUrl ?? null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export type OAuthConsentSelection = {
|
||||
allowedGoalIds: number[]
|
||||
allowedPermissions: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user