Compare commits

..

29 Commits

Author SHA1 Message Date
Nikolai Giman 27e1597c13 Merge pull request #111 from Gimanh/fix/issues
feat: OAuth
2026-09-06 18:07:29 +02:00
Nikolai Giman 3403e1a71d wip: OAuth and mcp 2026-09-06 18:06:35 +02:00
Nikolai Giman e9fb4f6f1e fix: org permissions 2026-08-31 00:54:19 +02:00
Nikolai Giman a173a870d7 feat: OAuth 2026-08-30 23:39:25 +02:00
Nikolai Giman 968c3d2eeb Merge pull request #105 from Gimanh/fix/issues
Fix/issues
2026-08-27 20:44:20 +02:00
Nikolai Giman 171f269ee2 version: 1.53.0 2026-08-27 20:42:36 +02:00
Nikolai Giman f79614ebc5 fix(security): enforce collaboration access permissions 2026-08-27 20:29:54 +02:00
Nikolai Giman a4c465469d wip: updated locales 2026-08-26 23:41:46 +02:00
Nikolai Giman f3e2663ebe Merge branch 'main' into fix/issues 2026-08-26 23:35:04 +02:00
Nikolai Giman 5ce2fe99f0 Merge pull request #108 from murilorosa/feat/pt-BR_locale
feat: add pt-BR locale
2026-08-26 23:27:46 +02:00
Nikolai Giman e11aba4d79 fix: time tracking edit 2026-08-26 23:20:16 +02:00
Murilo Biassio Rosa 0b6f95ec3d feat: add pt-BR locale 2026-08-25 22:21:10 -03:00
Nikolai Giman a596e023db fix: performance issue in graph and update layout building 2026-08-24 22:05:39 +02:00
Nikolai Giman c1685e42b8 feat: task dependencies in detailed panel 2026-08-24 14:21:46 +02:00
Nikolai Giman 9635120e66 fix: add security md 2026-08-23 17:56:17 +02:00
Nikolai Giman 19e0212edf fix: add task on the main screen when no project is created 2026-08-22 22:06:52 +02:00
Nikolai Giman 5dc5b387de fix: disable analytics 2026-08-22 16:43:48 +02:00
Nikolai Giman 8011da1259 fix: #106 2026-08-22 09:01:33 +02:00
Nikolai Giman a6329e998d fix: disabled analytics 2026-08-21 22:51:57 +02:00
Nikolai Giman b372854636 fix: custom server url normalization 2026-08-21 10:16:22 +02:00
Nikolai Giman a243f9ec56 fix: normalize server url 2026-08-21 10:06:54 +02:00
Nikolai Giman ebd25a94ea fix: sso email resolving and switcher for sso 2026-08-20 19:53:29 +02:00
Nikolai Giman 4b142619c0 fix: #6126 2026-08-20 16:13:23 +02:00
Nikolai Giman 6f576e7297 Merge pull request #104 from Gimanh/fix/issues
chore: version
2026-08-20 14:44:26 +02:00
Nikolai Giman 15050541fa chore: version 2026-08-20 14:40:23 +02:00
Nikolai Giman 3afdca99d9 Merge pull request #103 from Gimanh/fix/issues
fix: sso domain verification and #77
2026-08-20 14:37:05 +02:00
Nikolai Giman aaa876412d fix: #101 2026-08-20 13:51:32 +02:00
Nikolai Giman 915e8d9f9f fix: sso domain verification and #77 2026-08-18 18:57:17 +02:00
Nikolai Giman 55ded8bac9 Merge pull request #99 from Gimanh/fix/98
fix: #98
2026-08-04 20:45:47 +02:00
204 changed files with 10769 additions and 888 deletions
+1 -1
View File
@@ -428,7 +428,7 @@ For commercial licensing questions, hosted service permissions, or other use cas
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
When running TaskView in production:
+46
View File
@@ -0,0 +1,46 @@
# Security Policy
## Reporting a vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately using one of these channels:
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
Please include as much of the following as you can:
- A description of the issue and its impact
- Affected component (API, web app, MCP server, mobile app) and version
- Steps to reproduce, or a proof of concept
- Any suggested mitigation
## What to expect
- We will acknowledge your report within **5 business days**.
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
## Supported versions
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
## Scope
In scope:
- The TaskView API server, web app, MCP server, and mobile app in this repository
- The hosted service at `app.taskview.tech`
Out of scope:
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
- Findings that require a compromised admin account or physical access to the server
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
- Denial-of-service testing against the hosted service
## Safe harbor
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
+12
View File
@@ -19,6 +19,18 @@ ACCESS_LIFE_TIME=1d
REFRESH_LIFE_TIME=2d
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.51.0",
"version": "1.53.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
@@ -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);
});
});
+2 -1
View File
@@ -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();
};
};
+43
View File
@@ -737,5 +737,48 @@
"description": [
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
]
},
"58": {
"version": "1.63.0",
"name": "SSO domain verification",
"releaseDate": "20260813",
"scripts": [
"/1.63.0/0.sso-domain-verification.sql",
"/1.63.0/1.sso-domain-verified-unique.sql"
],
"description": [
"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,3 @@
ALTER TABLE tv_auth.sso_configs
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.sso_configs
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
ON tv_auth.sso_configs (email_domain_restriction)
WHERE domain_verified_at IS NOT NULL;
@@ -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;
+4
View File
@@ -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;
@@ -5,18 +5,12 @@ import { OverdueKpi } from './kpi/OverdueKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
@@ -59,6 +53,12 @@ import { sectionLocales } from './locales'
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
@@ -82,25 +82,25 @@ const builders: SectionBuilder[] = [
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
new BlockedByDependenciesSection(),
// new BlockedByDependenciesSection(), // disabled
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new OverdueByAgeSection(), // disabled
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
new ActiveProjectsSection(),
// new ActiveProjectsSection(), // disabled
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
// new IncomePerTagMonthSection(), // disabled
// new ExpensePerTagMonthSection(), // disabled
// new TopProjectsByAmountSection(), // disabled
]
export class SectionRegistry {
@@ -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)
+10
View File
@@ -367,6 +367,16 @@ export default class AuthController {
// Invalidate code immediately to prevent replay attacks
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
if (userData.block) {
if (!userData.confirm_email_code) {
return res.status(403).send({ message: 'account_blocked' });
}
const confirmed = await req.appUser.authManager.repository.markEmailConfirmed(userData.email);
if (!confirmed) {
return res.status(500).end();
}
}
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
+47 -1
View File
@@ -2,7 +2,7 @@ import { eq, sql } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserEmailArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
export default class AuthModel {
private readonly db: Database;
@@ -119,6 +119,21 @@ export default class AuthModel {
}
}
async markEmailConfirmed(email: string): Promise<boolean> {
if (!email) return false;
try {
const result = await this.db.dbDrizzle
.update(UsersSchema)
.set({ confirmEmailCode: null, block: 0 })
.where(eq(UsersSchema.email, email));
return (result.rowCount ?? 0) > 0;
} catch (error) {
$logger.error(error, `Error marking email confirmed for ${email}`);
return false;
}
}
async confirmEmail(login: string, code: string, block: number): Promise<boolean> {
const query = `UPDATE tv_auth.users
SET confirm_email_code = NULL, block = $1
@@ -189,6 +204,37 @@ export default class AuthModel {
}
}
async updateUserEmail(args: UpdateUserEmailArgs): Promise<UpdateUserCredentialsResult> {
try {
await this.db.dbDrizzle.transaction(async (tx) => {
await tx
.update(UsersSchema)
.set({ email: args.email })
.where(eq(UsersSchema.id, args.userId));
await tx
.update(OrganizationMembersSchema)
.set({ email: args.email })
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
await tx
.update(CollaborationUsersSchema)
.set({ email: args.email })
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
await tx
.update(SsoIdentitiesSchema)
.set({ email: args.email })
.where(eq(SsoIdentitiesSchema.userId, args.userId));
});
return 'ok';
} catch (error) {
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
if (pgCode === '23505') {
return 'conflict';
}
$logger.error(error, `Can not update email for user ${args.userId}`);
return 'error';
}
}
async updateUserPassword(password: string, userId: number): Promise<boolean> {
try {
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
@@ -512,4 +512,65 @@ describe('Login API', () => {
expect(te).toBe(0);
});
it('loginByCode confirms and admits a blocked-unconfirmed account', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const userModel = new AuthModel();
const before = await userModel.getUserByLogin(email, true);
expect(before).toBeTruthy();
expect((before as any).block).toBe(1);
expect((before as any).confirm_email_code).toBeTruthy();
const code = '654321';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
const response = await axios.post(`${url}/module/auth/login-by-code`, { email, code });
expect(response.status).toBe(200);
expect(response.data.access).toBeTruthy();
expect(response.data.refresh).toBeTruthy();
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(0);
expect((after as any).confirm_email_code).toBeNull();
});
it('loginByCode rejects a banned account (blocked, no confirm code)', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const db = Database.getInstance();
await db.query('update tv_auth.users set block = 1, confirm_email_code = null where email = $1', [email]);
const userModel = new AuthModel();
const code = '112233';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
let status = 0;
let message = '';
await axios.post(`${url}/module/auth/login-by-code`, { email, code }).catch((err) => {
status = err.response.status;
message = err.response.data.message;
});
expect(status).toBe(403);
expect(message).toBe('account_blocked');
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(1);
});
});
@@ -5,7 +5,8 @@ import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, res: Response, next: NextFunction) => {
const goalId = req.body.goalId ? req.body.goalId : req.params.goalId;
// the only route using this guard names the goal in the path
const goalId = req.params.goalId;
if (!goalId) {
return res.status(400).end();
@@ -5,7 +5,7 @@ import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
import { CollaborationController } from './CollaborationController';
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
// import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanToggleRolesCollaboration } from './middlewares/CanToggleRolesCollaboration';
export default class CollaborationRoutes implements Routable {
@@ -56,6 +56,10 @@ export default class CollaborationRoutes implements Routable {
/**
* Fetch users for goal for collaboration
*/
this.router.get('/:goalId', [IsLoggedIn], this.collaborationController.fetchUsersForGoalNew);
this.router.get(
'/:goalId',
[IsLoggedIn, CanFetchUsersCollaboration],
this.collaborationController.fetchUsersForGoalNew
);
}
}
@@ -21,7 +21,8 @@ export const CanFetchUsersCollaboration = async (req: Request, res: Response, ne
if (
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS) ||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)
) {
return next();
}
@@ -33,6 +33,15 @@ export class GraphController {
return res.tvJson(edges);
};
fetchTaskEdges = async (req: Request, res: Response) => {
const taskId = Number(req.params.taskId);
if (!Number.isFinite(taskId)) {
return res.status(400).send('Task ID is required');
}
const edges = await req.appUser.graphManager.fetchEdgesForTask(taskId);
return res.tvJson(edges);
};
deleteEdge = async (req: Request, res: Response) => {
if (!req.params.id) {
return res.status(400).send('Edge ID is required');
+4
View File
@@ -19,6 +19,10 @@ export class GraphManager {
return await this.repository.fetchAllEdges(goalId);
}
async fetchEdgesForTask(taskId: number) {
return await this.repository.fetchEdgesForTask(taskId);
}
async deleteEdge(id: number) {
return await this.repository.deleteEdge(id);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, or } from 'drizzle-orm';
import { GraphRelationsSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -32,6 +32,16 @@ export class GraphRepository {
return result ?? [];
}
public async fetchEdgesForTask(taskId: number): Promise<GraphReturnRelationsType[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(GraphRelationsSchema)
.where(or(eq(GraphRelationsSchema.fromTaskId, taskId), eq(GraphRelationsSchema.toTaskId, taskId)))
);
return result ?? [];
}
public async deleteEdge(id: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
+1
View File
@@ -21,6 +21,7 @@ export default class GraphRoutes implements Routable {
initRoutes() {
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
this.router.get('/task/:taskId', [IsLoggedIn, CanViewGraph], this.graphController.fetchTaskEdges);
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
}
@@ -3,28 +3,32 @@ import { GraphRepository } from '../GraphRepository';
import { TasksRepository } from '../../tasks/TasksRepository';
/**
* Resolves goalId from graph request.
* - GET /:goalId → params.goalId
* - POST (addEdge) → resolve via fromTaskId (body.source)
* - DELETE /:id → resolve via edge id
* Resolves the single goal a graph request belongs to.
*
* The source is chosen by what the route actually carries, not by probing every
* field in turn: a route parameter always wins, and only a request with no
* parameters at all (addEdge) is resolved from the body. Reading the body first
* would let a caller point the guard at a task they own while the handler acts
* on someone else's edge.
*
* A graph lives inside one project, so an edge whose endpoints sit in different
* goals is not a permission question — it is an impossible object. It resolves
* to null and the guards reject it before any permission is considered, the same
* invariant the tasks.check_task_graph_relation_goal trigger enforces in the DB.
*/
export async function resolveGoalId(req: Request): Promise<number | null> {
// Direct goalId in params (fetchAllEdges)
// fetchAllEdges: GET /:goalId
if (req.params.goalId) {
const id = Number(req.params.goalId);
return isNaN(id) ? null : id;
const goalId = Number(req.params.goalId);
return isNaN(goalId) ? null : goalId;
}
// addEdge: resolve goalId from task
if (req.body?.source) {
const taskId = Number(req.body.source);
if (isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
// fetchTaskEdges: GET /task/:taskId
if (req.params.taskId) {
return goalIdForTask(req.params.taskId);
}
// deleteEdge: resolve goalId from edge
// deleteEdge: DELETE /:id
if (req.params.id) {
const edgeId = Number(req.params.id);
if (isNaN(edgeId)) return null;
@@ -33,5 +37,25 @@ export async function resolveGoalId(req: Request): Promise<number | null> {
return edge?.goalId ?? null;
}
// addEdge: POST with { source, target } — both endpoints must be in one goal
if (req.body?.source) {
const sourceGoalId = await goalIdForTask(req.body.source);
if (sourceGoalId === null) return null;
const targetGoalId = await goalIdForTask(req.body.target);
if (targetGoalId !== sourceGoalId) return null;
return sourceGoalId;
}
return null;
}
async function goalIdForTask(rawTaskId: unknown): Promise<number | null> {
const taskId = Number(rawTaskId);
if (!taskId || isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
}
@@ -2,22 +2,26 @@ import type { Request } from 'express';
import { IntegrationsRepository } from '../IntegrationsRepository';
/**
* Resolves projectId from request.
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
* Resolves the project to authorize the request against.
*
* When the request names an integration, the project is derived from that
* integration and a projectId supplied by the caller is ignored: every handler
* that takes an integration id acts on the integration, so authorizing a
* caller-supplied project would guard a different object than the one touched.
*
* Only create and fetch carry no integration id — there the project itself is
* the object being acted on, so it is read from the request.
*/
export async function resolveProjectId(req: Request): Promise<number | null> {
// Direct projectId in body or query
const directId = req.body?.projectId ?? req.query?.projectId;
if (directId) {
const id = Number(directId);
return isNaN(id) ? null : id;
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (integrationId && !isNaN(integrationId)) {
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
}
// integrationId from body or query, or id from body
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (!integrationId || isNaN(integrationId)) return null;
const projectId = Number(req.body?.projectId || req.query?.projectId);
if (!projectId || isNaN(projectId)) return null;
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
return projectId;
}
+84 -11
View File
@@ -1,10 +1,11 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { GoalPermissions } from '../../types/auth.types';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { KanbanController } from './KanbanController';
import { CanManageKanban } from './middlewares/CanManageKanban';
import { CanViewKanban } from './middlewares/CanViewKanban';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { goalIdFromBody, goalIdFromParam, goalIdFromStatusBody } from './middlewares/goal-id-resolvers';
import { requireKanbanPermission } from './middlewares/require-kanban-permission';
export default class KanbanRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly kanbanController: KanbanController;
@@ -20,17 +21,89 @@ export default class KanbanRoutes implements Routable {
}
initRoutes() {
this.router.post('/fetch-statuses', [IsLoggedIn, CanViewKanban], this.kanbanController.fetchAllColumns);
this.router.post('/add-status', [IsLoggedIn, CanManageKanban], this.kanbanController.addStatus);
this.router.post('/delete-status', [IsLoggedIn, CanManageKanban], this.kanbanController.deleteStatus);
this.router.post('/update-status', [IsLoggedIn, CanManageKanban], this.kanbanController.updateStatus);
this.router.post(
'/fetch-statuses',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromBody,
}),
],
this.kanbanController.fetchAllColumns
);
// this.router.get('columns/:goalId', [IsLoggedIn], this.kanbanController.fetchAllColumns);
this.router.get('/tasks/:goalId/:columnId/:cursor', [IsLoggedIn, CanViewKanban, CanFetchTasks], this.kanbanController.fetchTasksForColumn);
this.router.post(
'/add-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.addStatus
);
this.router.post(
'/delete-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.deleteStatus
);
this.router.post(
'/update-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.updateStatus
);
this.router.get(
'/tasks/:goalId/:columnId/:cursor',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam,
}),
requireKanbanPermission({
anyOf: [GoalPermissions.COMPONENT_CAN_WATCH_CONTENT],
resolveGoalId: goalIdFromParam,
}),
],
this.kanbanController.fetchTasksForColumn
);
//we do not use this route in the client (no logic for this route on the client side)!!!
this.router.get('/tasks-order/:goalId/:columnId/:cursor', [IsLoggedIn, CanManageKanban], this.kanbanController.getTasksOrderForColumnAndCursor);
this.router.get(
'/tasks-order/:goalId/:columnId/:cursor',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam
})
],
this.kanbanController.getTasksOrderForColumnAndCursor
);
this.router.patch('/update-tasks-order-and-column', [IsLoggedIn, CanManageKanban], this.kanbanController.updateTasksOrderAndColumn);
this.router.patch(
'/update-tasks-order-and-column',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.updateTasksOrderAndColumn
);
}
}
@@ -1,33 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanFetchTasks = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
return next();
}
return res.status(403).end();
};
@@ -1,54 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanManageKanban = async (req: Request, res: Response, next: NextFunction) => {
let props = req.body.goalId ? req.body : req.params;
switch (req.url) {
case '/update-status':
const result = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result?.goal_id,
};
break;
case '/delete-status':
const result2 = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result2?.goal_id,
};
break;
default:
break;
}
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,33 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanViewKanban = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.KANBAN_CAN_VIEW)) {
return next();
}
return res.status(403).end();
};
@@ -0,0 +1,19 @@
import type { Request } from 'express';
export function goalIdFromParam(req: Request): number | null {
const goalId = Number(req.params.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export function goalIdFromBody(req: Request): number | null {
const goalId = Number(req.body?.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export async function goalIdFromStatusBody(req: Request): Promise<number | null> {
const statusId = Number(req.body?.id);
if (!statusId || isNaN(statusId)) return null;
const status = await req.appUser.kanbanManager.repository.fetchStatus(statusId);
return status?.goal_id ?? null;
}
@@ -0,0 +1,24 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { logError } from '../../../utils/api';
import type { RequireKanbanPermissionArgs } from '../types';
export function requireKanbanPermission({ anyOf, resolveGoalId }: RequireKanbanPermissionArgs) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = await resolveGoalId(req);
if (!goalId) return res.status(400).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not resolve kanban permissions');
return res.status(500).end();
}
if (anyOf.some((permission) => permissions.hasPermissions(permission))) return next();
return res.status(403).end();
};
}
+8 -4
View File
@@ -1,6 +1,8 @@
import { type } from 'arktype';
import type { Request } from 'express';
import { z } from 'zod';
import { StringToNumber } from '../../types/app.types';
import type { GoalPermissionType } from '../../types/auth.types';
// ============ Arktype schemas ============
@@ -102,11 +104,13 @@ export const KanbanArkTypeUpdateTasksOrder = type({
export type KanbanArgUpdateTasksOrder = typeof KanbanArkTypeUpdateTasksOrder.infer;
export const KanbanArkTypeCanManageKanban = type({
goalId: NumberFromString,
});
export type KanbanGoalIdResolver = (req: Request) => Promise<number | null> | number | null;
export type KanbanArgCanManageKanban = typeof KanbanArkTypeCanManageKanban.infer;
export type RequireKanbanPermissionArgs = {
/** the caller must hold at least ONE of these */
anyOf: GoalPermissionType[];
resolveGoalId: KanbanGoalIdResolver;
};
// ============ Deprecated Zod schemas ============
+299
View File
@@ -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,
})
}
}
+259
View File
@@ -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,
},
}
}
}
+185
View File
@@ -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
}
}
+35
View File
@@ -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)
})
})
+116
View File
@@ -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)
}
+207
View File
@@ -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);
});
});
+196 -42
View File
@@ -5,15 +5,24 @@ import type { Request, Response } from 'express'
import { $logger } from '../../modules/logget'
import { PublicApiUrl } from '../../modules/public-url'
import { logError } from '../../utils/api'
import { generateString, isEmail } from '../../utils/helpers'
import { generateLetters, generateString } from '../../utils/helpers'
import AuthModel from '../auth/AuthModel'
import { GoalsRepository } from '../goals/GoalsRepository'
import { OrganizationRepository } from '../organizations/OrganizationRepository'
import { createSsoProvider } from './providers/provider-factory'
import { SsoRepository } from './SsoRepository'
import { parseSamlMetadata } from './saml-metadata-parser'
import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils'
import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types'
import { generateLoginCode, isSsoDomainVerified, stripSecrets, validateMetadataUrl } from './sso.utils'
import {
SsoConfigArkTypeCreate,
SsoConfigArkTypeUpdate,
SsoDomainNotVerifiedError,
type ApplySsoIdpEmailArgs,
type ResolveSsoUserArgs,
type ResolveSsoUserResult,
type SsoCallbackError,
} from './types'
import type { UserDbRecord } from '../../types/auth.types'
export class SsoController {
private readonly ssoRepo = new SsoRepository()
@@ -21,6 +30,103 @@ export class SsoController {
private readonly orgRepo = new OrganizationRepository()
private readonly goalsRepo = new GoalsRepository()
private async resolveLogin(preferredUsername?: string): Promise<string> {
const base = preferredUsername?.trim().slice(0, 50)
if (!base) return generateString(7)
if (!(await this.authModel.getUserByLogin(base))) return base
for (let attempt = 0; attempt < 10; attempt++) {
const suffix = `.${generateLetters(3)}`
const candidate = `${base.slice(0, 50 - suffix.length)}${suffix}`
if (!(await this.authModel.getUserByLogin(candidate))) return candidate
}
return generateString(7)
}
private redirectSsoError(res: Response, error: SsoCallbackError) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=${error}`)
}
private async createSsoUser(args: ResolveSsoUserArgs): Promise<UserDbRecord | false> {
const password = generateString(16)
const login = await this.resolveLogin(args.preferredUsername)
const id = await this.authModel.registerUserInDb({
login,
email: args.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return false
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, args.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
return await this.authModel.fetchUserById(id)
}
private async applyIdpEmail(args: ApplySsoIdpEmailArgs): Promise<'ok' | 'email_in_use' | 'error'> {
if (args.user.email.toLowerCase() === args.email) return 'ok'
const taken = await this.authModel.getUserByLogin(args.email, true)
if (taken && taken.id !== args.user.id) return 'email_in_use'
const result = await this.authModel.updateUserEmail({
userId: args.user.id,
oldEmail: args.user.email,
email: args.email,
})
if (result === 'conflict') return 'email_in_use'
if (result !== 'ok') return 'error'
return 'ok'
}
private async resolveSsoUser(args: ResolveSsoUserArgs): Promise<ResolveSsoUserResult> {
const identity = await this.ssoRepo.findIdentity({
ssoConfigId: args.ssoConfigId,
externalId: args.externalId,
})
if (identity) {
const user = await this.authModel.fetchUserById(identity.userId)
if (!user) return { ok: false, error: 'authentication_failed' }
const emailResult = await this.applyIdpEmail({ user, email: args.email })
if (emailResult === 'email_in_use') return { ok: false, error: 'email_in_use' }
if (emailResult !== 'ok') return { ok: false, error: 'authentication_failed' }
const refreshed = await this.authModel.fetchUserById(user.id)
if (!refreshed) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: refreshed }
}
const existing = await this.authModel.getUserByLogin(args.email, true)
if (existing) {
const linked = await this.ssoRepo.findIdentityByUser({
ssoConfigId: args.ssoConfigId,
userId: existing.id,
})
if (linked && linked.externalId !== args.externalId) {
return { ok: false, error: 'email_in_use' }
}
return { ok: true, user: existing }
}
const created = await this.createSsoUser(args)
if (!created) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: created }
}
initiateLogin = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
@@ -28,6 +134,10 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const relayState = JSON.stringify({ platform: req.query.platform || '' })
@@ -45,50 +155,40 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const ssoResult = await provider.handleCallback(req)
if (config.emailDomainRestriction) {
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!config.emailDomainRestriction) {
return this.redirectSsoError(res, 'authentication_failed')
}
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
if (!userData) {
const password = generateString(16)
const login = generateString(7)
const id = await this.authModel.registerUserInDb({
login,
email: ssoResult.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return res.status(500).tvJson({ message: 'Failed to create user' })
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!userData) {
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
const resolved = await this.resolveSsoUser({
ssoConfigId: config.id,
email: ssoResult.email,
externalId: ssoResult.externalId,
preferredUsername: ssoResult.preferredUsername,
})
if (!resolved.ok) {
return this.redirectSsoError(res, resolved.error)
}
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
const userData = resolved.user
if (userData.block && !userData.confirm_email_code) {
return this.redirectSsoError(res, 'account_blocked')
}
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
await this.ssoRepo.upsertIdentity({
userId: userData.id,
@@ -132,7 +232,7 @@ export class SsoController {
if (!domain) return res.tvJson(null)
const config = await this.ssoRepo.findEnabledByDomain(domain)
if (!config) return res.tvJson(null)
if (!config || !isSsoDomainVerified(config)) return res.tvJson(null)
return res.tvJson({
id: config.id,
@@ -165,11 +265,18 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
if (existing) {
const domain = out.emailDomainRestriction.toLowerCase()
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: out.organizationId })
if (sameOrg) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
if (!config) {
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
@@ -186,8 +293,33 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
return res.tvJson(config ? stripSecrets(config) : null)
if (out.emailDomainRestriction) {
const domain = out.emailDomainRestriction.toLowerCase()
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified && verified.id !== configId) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const current = await this.ssoRepo.findById(configId)
if (current) {
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: current.organizationId })
if (sameOrg && sameOrg.id !== configId) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
}
}
try {
const config = await req.appUser.ssoManager.updateConfig(configId, out)
return res.tvJson(config ? stripSecrets(config) : null)
} catch (error) {
if (error instanceof SsoDomainNotVerifiedError) {
return res.status(403).tvJson({ message: 'Domain is not verified' })
}
logError(error)
return res.tvJson(null)
}
}
parseMetadata = async (req: Request, res: Response) => {
@@ -213,6 +345,28 @@ export class SsoController {
}
}
startDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.startDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
checkDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.checkDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
generateScimToken = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
+110 -5
View File
@@ -1,8 +1,22 @@
import type { AppUser } from '../../core/AppUser'
import { encrypt, encryptField } from '../../utils/crypto'
import { SsoRepository } from './SsoRepository'
import { SSO_SECRET_FIELDS } from './sso.utils'
import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types'
import {
SSO_SECRET_FIELDS,
generateDomainVerifyToken,
isSsoDomainVerified,
isTrustedSsoDomain,
proveSsoDomainOwnership,
ssoDomainVerifyDnsRecord,
ssoDomainVerifyHttpUrl,
} from './sso.utils'
import {
SsoDomainNotVerifiedError,
type CheckDomainVerificationResult,
type SsoConfigArgCreate,
type SsoConfigArgUpdate,
type StartDomainVerificationResult,
} from './types'
export class SsoManager {
public readonly repository: SsoRepository
@@ -18,11 +32,14 @@ export class SsoManager {
}
async createConfig(data: SsoConfigArgCreate) {
const domain = data.emailDomainRestriction.toLowerCase()
const trusted = isTrustedSsoDomain(domain)
return await this.repository.create({
organizationId: data.organizationId,
protocol: data.protocol,
displayName: data.displayName,
enabled: data.enabled ?? 1,
enabled: trusted ? (data.enabled ?? 1) : 0,
samlEntryPoint: data.samlEntryPoint ?? null,
samlIssuer: data.samlIssuer ?? null,
samlCert: encryptField(data.samlCert),
@@ -36,12 +53,22 @@ export class SsoManager {
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
oidcScope: data.oidcScope ?? null,
defaultOrgRole: data.defaultOrgRole ?? 'member',
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
emailDomainRestriction: domain,
domainVerifyToken: generateDomainVerifyToken(),
domainVerifiedAt: trusted ? new Date() : null,
})
}
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
const current = await this.repository.findById(configId)
if (!current) return null
const encrypted: Partial<SsoConfigArgUpdate> & {
domainVerifyToken?: string
domainVerifiedAt?: Date | null
enabled?: number
} = { ...data }
for (const field of SSO_SECRET_FIELDS) {
if (field in encrypted) {
if (encrypted[field]) {
@@ -51,9 +78,87 @@ export class SsoManager {
}
}
}
if (data.emailDomainRestriction) {
const domain = data.emailDomainRestriction.toLowerCase()
encrypted.emailDomainRestriction = domain
if (domain !== current.emailDomainRestriction) {
const trusted = isTrustedSsoDomain(domain)
encrypted.domainVerifyToken = generateDomainVerifyToken()
encrypted.domainVerifiedAt = trusted ? new Date() : null
if (!trusted) encrypted.enabled = 0
await this.repository.deleteIdentitiesByConfig(configId)
}
}
const nextDomain = encrypted.emailDomainRestriction ?? current.emailDomainRestriction
const nextVerifiedAt = 'domainVerifiedAt' in encrypted
? encrypted.domainVerifiedAt
: current.domainVerifiedAt
const wouldBeVerified = isTrustedSsoDomain(nextDomain) || !!nextVerifiedAt
if (data.enabled === 1 && !wouldBeVerified) {
throw new SsoDomainNotVerifiedError()
}
return await this.repository.update(configId, encrypted)
}
async startDomainVerification(configId: number): Promise<StartDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
let token = config.domainVerifyToken
if (!token) {
token = generateDomainVerifyToken()
const updated = await this.repository.update(configId, { domainVerifyToken: token })
if (!updated) return null
}
return {
token,
dnsRecord: ssoDomainVerifyDnsRecord(token),
httpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
isDomainVerified: isSsoDomainVerified({ ...config, domainVerifyToken: token }),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
}
}
async checkDomainVerification(configId: number): Promise<CheckDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
if (!config.domainVerifyToken) {
return {
verified: isSsoDomainVerified(config),
method: isTrustedSsoDomain(config.emailDomainRestriction) ? 'trusted' : null
}
}
const method = await proveSsoDomainOwnership({
domain: config.emailDomainRestriction,
token: config.domainVerifyToken,
})
if (!method) {
return { verified: isSsoDomainVerified(config), method: null }
}
if (!config.domainVerifiedAt || method === 'trusted') {
const updated = await this.repository.update(configId, {
domainVerifiedAt: new Date(),
enabled: 1,
})
// The partial unique index rejects a second verified config for the same
// domain another organization proved ownership first.
if (!updated) {
return { verified: false, method: null }
}
}
return { verified: true, method }
}
async deleteConfig(configId: number) {
return await this.repository.delete(configId)
}
+74 -7
View File
@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, isNotNull } from 'drizzle-orm'
import {
SsoConfigsSchema,
SsoIdentitiesSchema,
@@ -8,6 +8,12 @@ import {
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
FindSsoConfigByDomainAndOrgArgs,
FindSsoIdentityArgs,
FindSsoIdentityByUserArgs,
UpsertSsoIdentityArgs,
} from './types'
export class SsoRepository {
private readonly db: Database
@@ -16,6 +22,38 @@ export class SsoRepository {
this.db = Database.getInstance()
}
async findVerifiedByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
isNotNull(SsoConfigsSchema.domainVerifiedAt),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findByDomainAndOrg(args: FindSsoConfigByDomainAndOrgArgs): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, args.domain.toLowerCase()),
eq(SsoConfigsSchema.organizationId, args.organizationId),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
@@ -146,12 +184,41 @@ export class SsoRepository {
return !!(result?.rowCount && result.rowCount > 0)
}
async upsertIdentity(data: {
userId: number
ssoConfigId: number
externalId: string
email: string
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
async findIdentity(args: FindSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.externalId, args.externalId),
)
)
if (result.length === 0) return null
return result[0]
}
async findIdentityByUser(args: FindSsoIdentityByUserArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.userId, args.userId),
)
)
if (result.length === 0) return null
return result[0]
}
async deleteIdentitiesByConfig(ssoConfigId: number): Promise<void> {
await this.db.dbDrizzle
.delete(SsoIdentitiesSchema)
.where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId))
}
async upsertIdentity(data: UpsertSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const existing = await callWithCatch(() =>
this.db.dbDrizzle
.select()
+13 -7
View File
@@ -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,13 +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/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)
}
}
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { deriveSamlEmail } from '../sso.utils'
const EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const PERSISTENT_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
const EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
describe('deriveSamlEmail', () => {
it('takes the email attribute and lowercases it', () => {
expect(deriveSamlEmail({ email: 'User@Company.com', nameID: 'abc' })).toBe('user@company.com')
})
it('falls back to the xmlsoap emailaddress claim', () => {
expect(deriveSamlEmail({ [EMAIL_CLAIM]: 'a@b.com', nameID: 'abc' })).toBe('a@b.com')
})
it('uses nameID only when the NameID Format is emailAddress', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('user@company.com')
})
it('does not use nameID for a non-email NameID Format', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: PERSISTENT_NAMEID_FORMAT,
})).toBeNull()
})
it('does not use nameID when no format is provided', () => {
expect(deriveSamlEmail({ nameID: 'user@company.com' })).toBeNull()
})
it('prefers the email attribute over an emailAddress-format nameID', () => {
expect(deriveSamlEmail({
email: 'attr@company.com',
nameID: 'name@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('attr@company.com')
})
it('returns null for a blank or non-string email attribute', () => {
expect(deriveSamlEmail({ email: ' ', nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ email: 123, nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({})).toBeNull()
})
})
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto'
import * as client from 'openid-client'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
export class OidcProvider implements SsoProvider {
@@ -28,7 +29,12 @@ export class OidcProvider implements SsoProvider {
return this.oidcConfig
}
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
private resolveCallbackUrl(req: Request): string {
return this.config.oidcCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const config = await this.getOidcConfig()
const scope = this.config.oidcScope ?? 'openid email profile'
const codeVerifier = client.randomPKCECodeVerifier()
@@ -63,7 +69,7 @@ export class OidcProvider implements SsoProvider {
})
const params = new URLSearchParams({
redirect_uri: this.config.oidcCallbackUrl!,
redirect_uri: this.resolveCallbackUrl(req),
scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
@@ -110,7 +116,7 @@ export class OidcProvider implements SsoProvider {
throw new Error('CSRF state mismatch — possible CSRF attack')
}
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
const callbackOrigin = new URL(this.resolveCallbackUrl(req)).origin
const currentUrl = new URL(req.originalUrl, callbackOrigin)
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: codeVerifier,
@@ -128,6 +134,7 @@ export class OidcProvider implements SsoProvider {
email: (claims.email as string).toLowerCase(),
externalId: claims.sub,
displayName: claims.name as string | undefined,
preferredUsername: claims.preferred_username as string | undefined,
provider: `oidc-${this.config.id}`,
}
}
@@ -1,6 +1,9 @@
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import { deriveSamlEmail } from '../sso.utils'
import type { SamlOptionsArgs } from '../types'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
import { SamlDbCacheProvider } from './saml-cache-provider'
@@ -11,12 +14,12 @@ function normalizeCert(cert: string): string {
.replace(/[\s\r\n]/g, '')
}
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
function buildSamlOptions({ config, mode, callbackUrl }: SamlOptionsArgs) {
return {
entryPoint: config.samlEntryPoint!,
issuer: config.samlIssuer!,
idpCert: normalizeCert(config.samlCert!),
callbackUrl: config.samlCallbackUrl!,
callbackUrl,
wantAssertionsSigned: mode === 'assertion',
wantAuthnResponseSigned: mode === 'response',
validateInResponseTo: ValidateInResponseTo.always,
@@ -31,29 +34,38 @@ function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertio
}
export class SamlProvider implements SsoProvider {
private readonly samlAssertion: SAML
private readonly samlResponse: SAML
private readonly config: SsoConfigsSchemaTypeForSelect
constructor(config: SsoConfigsSchemaTypeForSelect) {
this.config = config
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
}
private resolveCallbackUrl(req: Request): string {
return this.config.samlCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
const saml = new SAML(buildSamlOptions({
config: this.config,
mode: 'assertion',
callbackUrl: this.resolveCallbackUrl(req),
}))
const loginUrl = await saml.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
res.redirect(loginUrl)
}
async handleCallback(req: Request): Promise<SsoAuthResult> {
const callbackUrl = this.resolveCallbackUrl(req)
let profile
try {
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'assertion', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
} catch {
const result = await this.samlResponse.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'response', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
}
@@ -61,14 +73,13 @@ export class SamlProvider implements SsoProvider {
throw new Error('SAML response missing nameID')
}
const email = (
profile.email
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
?? profile.nameID
) as string
const email = deriveSamlEmail(profile as Record<string, unknown>)
if (!email) {
throw new Error('SAML response missing email attribute')
}
return {
email: email.toLowerCase(),
email,
externalId: profile.nameID,
displayName: (profile.displayName
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined,
@@ -4,6 +4,7 @@ export type SsoAuthResult = {
email: string
externalId: string
displayName?: string
preferredUsername?: string
provider: string
}
+110 -1
View File
@@ -1,11 +1,64 @@
import { randomBytes } from 'crypto'
import { resolveTxt } from 'node:dns/promises'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { decryptField } from '../../utils/crypto'
import { generateString } from '../../utils/helpers'
import type { CheckSsoDomainProofArgs, SsoDomainVerificationMethod } from './types'
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
export const SSO_DOMAIN_TXT_PREFIX = 'taskview-sso-verify='
export const SSO_DOMAIN_WELL_KNOWN_PATH = '/.well-known/taskview-sso-verify.txt'
export function generateDomainVerifyToken(): string {
return `tvdom_${randomBytes(32).toString('hex')}`
}
const SAML_EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const SAML_EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
export function deriveSamlEmail(profile: Record<string, unknown>): string | null {
const fromAttribute = profile.email ?? profile[SAML_EMAIL_CLAIM]
if (typeof fromAttribute === 'string' && fromAttribute.trim()) {
return fromAttribute.trim().toLowerCase()
}
if (profile.nameIDFormat === SAML_EMAIL_NAMEID_FORMAT
&& typeof profile.nameID === 'string' && profile.nameID.trim()) {
return profile.nameID.trim().toLowerCase()
}
return null
}
export function trustedSsoDomains(): string[] {
const raw = process.env.SSO_TRUSTED_DOMAINS
if (!raw?.trim()) return []
return raw
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean)
}
export function isTrustedSsoDomain(domain: string): boolean {
return trustedSsoDomains().includes(domain.trim().toLowerCase())
}
export function isSsoDomainVerified(config: SsoConfigsSchemaTypeForSelect): boolean {
if (isTrustedSsoDomain(config.emailDomainRestriction)) return true
return !!config.domainVerifiedAt
}
export function ssoDomainVerifyHttpUrl(domain: string): string {
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`
}
export function ssoDomainVerifyDnsRecord(token: string): string {
return `${SSO_DOMAIN_TXT_PREFIX}${token}`
}
export function toClientSsoConfig(config: SsoConfigsSchemaTypeForSelect) {
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
const token = config.domainVerifyToken
return {
...safe,
hasSamlCert: !!samlCert,
@@ -13,9 +66,65 @@ export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
hasSamlSigningCert: !!samlSigningCert,
hasOidcClientSecret: !!oidcClientSecret,
hasScimToken: !!scimToken,
isDomainVerified: isSsoDomainVerified(config),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
domainVerifyDnsRecord: token ? ssoDomainVerifyDnsRecord(token) : null,
domainVerifyHttpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
}
}
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
return toClientSsoConfig(config)
}
function tokenMatchesProof(body: string, token: string): boolean {
const trimmed = body.trim()
return trimmed === token || trimmed === ssoDomainVerifyDnsRecord(token)
}
export async function checkSsoDomainDnsTxt(args: CheckSsoDomainProofArgs): Promise<boolean> {
try {
const records = await resolveTxt(args.domain)
return records.some((chunks) => tokenMatchesProof(chunks.join(''), args.token))
} catch {
return false
}
}
export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Promise<boolean> {
const urls = process.env.NODE_ENV === 'production'
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
: [
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
]
for (const url of urls) {
const urlError = validateMetadataUrl(url)
if (urlError) continue
try {
const response = await fetch(url, {
redirect: 'error',
signal: AbortSignal.timeout(5000),
})
if (!response.ok) continue
if (tokenMatchesProof(await response.text(), args.token)) return true
} catch {
continue
}
}
return false
}
export async function proveSsoDomainOwnership(args: CheckSsoDomainProofArgs): Promise<SsoDomainVerificationMethod | null> {
if (isTrustedSsoDomain(args.domain)) return 'trusted'
if (await checkSsoDomainDnsTxt(args)) return 'dns'
if (await checkSsoDomainHttpFile(args)) return 'http'
return null
}
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
return {
...config,
+78 -1
View File
@@ -1,4 +1,12 @@
import { type } from 'arktype'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import type { UserDbRecord } from '../../types/auth.types'
export type SamlOptionsArgs = {
config: SsoConfigsSchemaTypeForSelect
mode: 'assertion' | 'response'
callbackUrl: string
}
export const SsoProtocols = {
SAML: 'saml',
@@ -52,7 +60,76 @@ export const SsoConfigArkTypeUpdate = type({
'oidcScope?': 'string',
'defaultOrgRole?': "'admin' | 'member'",
'emailDomainRestriction?': 'string',
'emailDomainRestriction?': 'string > 0',
})
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
export type CheckSsoDomainProofArgs = {
domain: string
token: string
}
export type SsoDomainVerificationMethod = 'dns' | 'http' | 'trusted'
export type StartDomainVerificationResult = {
token: string
dnsRecord: string
httpUrl: string
isDomainVerified: boolean
isDomainTrusted: boolean
}
export type CheckDomainVerificationResult = {
verified: boolean
method: SsoDomainVerificationMethod | null
}
export class SsoDomainNotVerifiedError extends Error {
readonly code = 'domain_unverified'
constructor() {
super('SSO domain is not verified')
this.name = 'SsoDomainNotVerifiedError'
}
}
export type FindSsoConfigByDomainAndOrgArgs = {
domain: string
organizationId: number
}
export type FindSsoIdentityArgs = {
ssoConfigId: number
externalId: string
}
export type FindSsoIdentityByUserArgs = {
ssoConfigId: number
userId: number
}
export type UpsertSsoIdentityArgs = {
userId: number
ssoConfigId: number
externalId: string
email: string
}
export type ResolveSsoUserArgs = {
ssoConfigId: number
email: string
externalId: string
preferredUsername?: string
}
export type ApplySsoIdpEmailArgs = {
user: UserDbRecord
email: string
}
export type SsoCallbackError = 'authentication_failed' | 'email_in_use' | 'account_blocked'
export type ResolveSsoUserResult =
| { ok: true, user: UserDbRecord }
| { ok: false, error: SsoCallbackError }
-11
View File
@@ -2,25 +2,14 @@ import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { CanAddTaskNew } from './middlewares/CanAddTaskNew';
// import { CanAddTask } from './middlewares/CanAddTask';
// import { CanUpdateTaskStatus } from './middlewares/CanUpdateTaskStatus';
import { CanDeleteTask } from './middlewares/CanDeleteTask';
// import { CanUpdateTaskAssignee } from './middlewares/CanUpdateTaskAssignee';
import { CanFetchTask } from './middlewares/CanFetchTask';
// import { CanUpdateTaskDescription } from './middlewares/CanUpdateTaskDescription';
// import { CanUpdateTaskNote } from './middlewares/CanUpdateTaskNote';
// import { CanUpdateTaskDeadline } from './middlewares/CanUpdateTaskDeadline';
// import { CanFetchSubtasks } from './middlewares/CanFetchSubtasks';
// import { CanUpdateTaskPriority } from './middlewares/CanUpdateTaskPriority';
// import { CanMoveTask } from './middlewares/CanMoveTask';
// import { CanSeeTaskAssignedUsers } from './middlewares/CanSeeTaskAssignedUsers';
import { CanFetchTaskHistory } from './middlewares/CanFetchTaskHistory';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { CanRecoveryTaskHistory } from './middlewares/CanRecoveryTaskHistory';
import { CanUpdateTask } from './middlewares/CanUpdateTask';
import { CanUpdateTaskAssigneeNew } from './middlewares/CanUpdateTaskAssigneeNew';
import { TasksController } from './TasksController';
// import { MainCanCreateTaskAction } from './middlewares/MainCanCreateTaskAction';
export default class TasksRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
@@ -1,39 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
export const CanAddTask = async (req: Request, res: Response, next: NextFunction) => {
const listId = req.body.componentId;
if (!listId) {
return res.status(400).end();
}
let permissions;
if (Number(listId) === ALL_TASKS_LIST_ID && req.body.goalId && req.body.goalId !== DEFAULT_ID) {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(req.body.goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
} else {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(listId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASKLIST)
.catch(logError);
}
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchSubtasks = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanFetchSubtasks');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_SUBTASKS)) {
return next();
}
return res.status(403).end();
};
@@ -5,7 +5,7 @@ import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId || req.params.taskId;
const taskId = req.params.taskId;
if (!taskId) {
return res.status(400).end();
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanMoveTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanMoveTask');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_DELETE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanSeeTaskAssignedUsers = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanSeeTaskAssignedUsers');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,29 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
/** @deprecated */
export const CanUpdateTaskAssignee = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskDeadline = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDeadline');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DEADLINE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskDescription = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DESCRIPTION)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskNote = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskNote');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_NOTE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskPriority = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskPriority');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_PRIORITY)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskStatus = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return next();
}
return res.status(403).end();
};
+12 -8
View File
@@ -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)
}
}
+12
View File
@@ -115,6 +115,12 @@ export type UpdateUserCredentialsArgs = {
passwordHash: string;
};
export type UpdateUserEmailArgs = {
userId: number;
oldEmail: string;
email: string;
};
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
export const RefreshTokenSchema = z.object({
@@ -188,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',
+9
View File
@@ -29,6 +29,15 @@ export function generateString(length: number) {
return result
}
export function generateLetters(length: number) {
let result = ''
const characters = 'abcdefghijklmnopqrstuvwxyz'
for (let i = 0; i < length; i++) {
result += characters.charAt(randomInt(characters.length))
}
return result
}
export function time() {
return Math.floor(Date.now() / 1000);
}
+4
View File
@@ -0,0 +1,4 @@
export type OAuthConsentSelection = {
allowedGoalIds: number[]
allowedPermissions: string[]
}
+19
View File
@@ -22,6 +22,23 @@ Users who sign in via SSO are automatically added to the organization that owns
Go to your organization's settings → **SSO** tab. You need the **admin** or **owner** role.
### Domain verification
SSO login stays off until the organization proves it owns the email domain (so another org on a shared instance cannot claim `gmail.com` or your company domain).
After you save the SSO config, TaskView shows a verification token. Use **one** of:
1. **DNS TXT** — add a TXT record on the domain:
`taskview-sso-verify=<token>`
2. **HTTP file** — serve the token (plain text) at:
`https://<domain>/.well-known/taskview-sso-verify.txt`
Then click **Check domain**. Either method is enough. After a successful check, SSO login is enabled.
**Closed-network / air-gapped installs:** you may not have public DNS. Set `SSO_TRUSTED_DOMAINS=company.com,corp.local` on the API server. Domains in that list skip the DNS/HTTP check and are treated as verified.
Existing SSO configs created before this check are not verified: logins stop until an admin completes verification or the domain is listed in `SSO_TRUSTED_DOMAINS`.
### SAML 2.0
**Required fields:**
@@ -145,6 +162,8 @@ Request IDs expire after 5 minutes.
| POST | `/module/sso/admin/configs` | Create SSO config |
| PATCH | `/module/sso/admin/configs/{configId}` | Update SSO config |
| DELETE | `/module/sso/admin/configs/{configId}` | Delete SSO config |
| POST | `/module/sso/admin/configs/{configId}/verify-domain` | Return DNS TXT and HTTP well-known proof for the domain |
| POST | `/module/sso/admin/configs/{configId}/verify-domain/check` | Check DNS TXT then HTTP file; enable SSO on success |
## Database tables
+41 -7
View File
@@ -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,8 @@ 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"}
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
@@ -267,6 +269,8 @@ REFRESH_LIFE_TIME="9d"
#AUTH_LOGIN_METHODS="magic-link,password,sso,social"
# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed)
#PASSWORD_CHANGE_CONFIRMATION="email"
# Air-gapped SSO: skip DNS/HTTP domain proof for these email domains
#SSO_TRUSTED_DOMAINS="company.com,corp.local"
SMTP_HOST=smtp
SMTP_PORT=587
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.51.0",
"version": "1.53.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -0,0 +1,300 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, initApi } from './init-api'
/**
* GET /module/collaboration/:goalId must be an object-level protected route:
* only a member of the goal holding task_can_assign_users or goal_can_manage_users
* may read its collaborator list (emails, invitation dates, roles, goalOwner flag).
*/
describe('Collaboration goal member list access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let outsiderEmail: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
const permissionIdByName = new Map<string, number>()
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
outsiderEmail = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
for (const permission of allPermissions) {
permissionIdByName.set(permission.name, permission.id)
}
const found = permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
// A goal owned by user1 that user2 is NOT a member of, holding a third-party email
async function createPrivateGoal(organizationId?: number) {
const goal = await ownerApi.goals.createGoal({
name: `Private goal ${Date.now()}`,
...(organizationId ? { organizationId } : {}),
})
if (!goal) throw new Error('Failed to create goal')
const invitedEmail = `outside-party-${Date.now()}@test.com`
const invited = await ownerApi.collaboration.inviteUserToGoal({ email: invitedEmail, goalId: goal.id })
if (!invited) throw new Error('Failed to invite third-party email')
return { goal, invitedEmail }
}
// Invite user2 into user1's goal and grant a role carrying goal_can_manage_users
async function shareGoalWithOutsider() {
const goal = await ownerApi.goals.createGoal({ name: `Shared goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggled = await ownerApi.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
}
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role, collab }
}
describe('JWT session of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const { goal } = await createPrivateGoal()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
it('cannot enumerate collaborator emails by walking goal ids', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
let leaked: Awaited<ReturnType<typeof outsiderApi.collaboration.fetchUsersForGoal>> | null = null
try {
leaked = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
} catch {
return
}
expect(
leaked ?? [],
`Leaked collaborator list of goal ${goal.id}: ${JSON.stringify(leaked)}`,
).toEqual([])
expect((leaked ?? []).some(u => u.email === invitedEmail)).toBe(false)
expect((leaked ?? []).some(u => u.goalOwner)).toBe(false)
})
it('gets the same rejection for a goal id that does not exist', async () => {
const nonExistentGoalId = 999999999
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(nonExistentGoalId), 403)
})
})
describe('Organization boundary', () => {
it('a member of the same organization who is not a member of the goal is still rejected', async () => {
const org = await ownerApi.organizations.create({ name: `Access org ${Date.now()}` })
if (!org) throw new Error('Failed to create organization')
const added = await ownerApi.organizations.addMember({
organizationId: org.id,
email: outsiderEmail,
role: 'member',
})
if (!added) throw new Error('Failed to add user2 to the organization')
// The goal lives in the shared org, but user2 was never invited into the goal itself
const { goal } = await createPrivateGoal(org.id)
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Unauthenticated access', () => {
it('is rejected with 401 rather than served', async () => {
const { goal } = await createPrivateGoal()
const response = await axios.get(`${API_URL}/module/collaboration/${goal.id}`, {
validateStatus: () => true,
})
expect(
response.status,
`Anonymous request returned ${response.status}: ${JSON.stringify(response.data)}`,
).toBe(401)
})
})
describe('API token of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const created = await outsiderApi.apiTokens.create({ name: `Access probe ${Date.now()}` })
if (!created) throw new Error('Failed to create API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
it('cannot read a goal that is outside the token allowedGoalIds scope', async () => {
const ownGoal = await outsiderApi.goals.createGoal({ name: `User2 goal ${Date.now()}` })
if (!ownGoal) throw new Error('Failed to create user2 goal')
// Token is explicitly scoped to user2's own goal only
const created = await outsiderApi.apiTokens.create({
name: `Scoped probe ${Date.now()}`,
allowedGoalIds: [ownGoal.id],
})
if (!created) throw new Error('Failed to create scoped API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
})
describe('Legitimate access is preserved', () => {
it('the goal owner can read the collaborator list', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
const users = await ownerApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === invitedEmail)).toBe(true)
})
it('a member with goal_can_manage_users can read the collaborator list', async () => {
const { goal } = await shareGoalWithOutsider()
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
/**
* A rank-and-file member must still see the project roster, otherwise the UI
* cannot render task assignees. Both default roles created by the goal trigger
* (editor and executor, migration 1.6.1/5.default-roles-for-project.sql) carry
* task_can_watch_assigned_users, so this is the common case, not an edge one.
*/
it('a member with only task_can_watch_assigned_users can read the collaborator list', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Executor goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const roles = await ownerApi.collaboration.fetchRolesForGoal(goal.id)
const executor = roles?.find(r => r.name === 'executor')
if (!executor) throw new Error('Default "executor" role is missing on a fresh goal')
// the role grants the watch permission and neither of the two management ones,
// so a pass here can only come from task_can_watch_assigned_users
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(goal.id)
const executorPermissionIds = (matrix ?? [])
.filter(row => row.roleId === executor.id)
.map(row => row.permissionId)
expect(executorPermissionIds).toContain(permissionIdByName.get(TvPermissions.TASK_CAN_WATCH_ASSIGNED_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.TASK_CAN_ASSIGN_USERS))
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [executor.id],
})
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
it('a member whose role carries none of the three permissions is rejected', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Bare role goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
// a freshly created custom role carries no permissions at all
const bareRole = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Bare ${Date.now()}`,
})
if (!bareRole) throw new Error('Failed to create role')
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [bareRole.id],
})
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Revoked access', () => {
it('a removed collaborator loses access to the collaborator list', async () => {
const { goal, collab } = await shareGoalWithOutsider()
// sanity: access is real before removal
const before = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(before?.some(u => u.email === outsiderEmail)).toBe(true)
const removed = await ownerApi.collaboration.deleteUserFromGoal({ goalId: goal.id, id: collab.id })
expect(removed).toBeTruthy()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
})
@@ -38,6 +38,9 @@ services:
ALLOW_PUBLIC_REGISTRATION: "false"
# IdP-facing URLs are built from this base (see sso-public-urls.test.ts)
API_PUBLIC_URL: "https://api.public.example"
# Domains that skip DNS/HTTP ownership proof — used by sso.test.ts to
# deterministically produce a *verified* config (method 'trusted').
SSO_TRUSTED_DOMAINS: "owned-sso.example"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
@@ -0,0 +1,161 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* resolveGoalId() for the graph module inspects req.body.source before falling
* back to req.params.id, while deleteEdge acts on req.params.id. A caller must
* not be able to point the guard at a task they own while the handler operates
* on an edge belonging to someone else.
*/
describe('Graph object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerTaskId: number
let victimTaskId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim graph ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const victimTask = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-task-${Date.now()}`,
})
if (!victimTask) throw new Error('Failed to create victim task')
victimTaskId = victimTask.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker graph ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
const attackerTask = await outsiderApi.tasks.createTask({
goalId: attackerGoal.id,
description: `attacker-task-${Date.now()}`,
})
if (!attackerTask) throw new Error('Failed to create attacker task')
attackerTaskId = attackerTask.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
async function createVictimEdge(): Promise<number> {
const from = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-from-${Date.now()}`,
})
const to = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-to-${Date.now()}`,
})
if (!from || !to) throw new Error('Failed to create victim tasks')
const edge = await ownerApi.graph.addEdge({ source: from.id, target: to.id })
if (!edge) throw new Error('Failed to create victim edge')
return edge.id
}
async function victimEdgeExists(edgeId: number): Promise<boolean> {
const edges = await ownerApi.graph.fetchAllEdges(victimGoalId)
return (edges ?? []).some(e => e.id === edgeId)
}
it('rejects deleting another user edge even when a self-owned source task is supplied', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`, {
data: { source: attackerTaskId },
})
expect(
await victimEdgeExists(edgeId),
`victim edge ${edgeId} was destroyed by a non-member`,
).toBe(true)
expect(response.status).toBe(403)
})
// A graph lives inside one project, so an edge across two of them is not a
// permission question but an impossible object: it is refused before any
// permission is looked at. Driven through the SDK on purpose — this needs no
// crafted request at all, an ordinary client using the public API reaches it.
it('rejects creating an edge whose endpoints live in different projects', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: attackerTaskId, target: victimTaskId }),
400,
)
})
// the mirror of the case above: a foreign source with an own target. This one
// fails closed even without the endpoint comparison (the goal would resolve to
// the victim project and the permission check would deny it), which is exactly
// why it needs pinning — a regression here would be silent
it('rejects creating an edge from a foreign task into a project the caller owns', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: attackerTaskId }),
400,
)
})
// both endpoints inside the victim project: the goal resolves cleanly, so this
// is decided purely by the permission check on that goal
it('rejects creating an edge between two tasks of a project the caller is not a member of', async () => {
const second = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-second-${Date.now()}`,
})
if (!second) throw new Error('Failed to create second victim task')
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: second.id }),
403,
)
})
it('control: without the injected source the guard already rejects the delete', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`)
expect(response.status).toBe(403)
expect(await victimEdgeExists(edgeId)).toBe(true)
})
it('control: the owner can still delete their own edge', async () => {
const edgeId = await createVictimEdge()
const deleted = await ownerApi.graph.deleteEdge(edgeId)
expect(deleted).toBeTruthy()
expect(await victimEdgeExists(edgeId)).toBe(false)
})
})
@@ -0,0 +1,113 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* Several guards pick the goal to authorize with `req.body.goalId ? req.body : req.params`,
* while their handlers read `req.params`. Supplying a body that names a goal the caller owns
* must not authorize a request whose path points at someone else's goal.
*/
describe('Guard/handler parameter confusion', () => {
let ownerApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerGoalId: number
let victimColumnId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim confusion ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const attackerGoal = await axios.post(
`${API_URL}/module/goals`,
{ name: `Attacker confusion ${Date.now()}` },
{ headers: { Authorization: `Bearer ${auth.data.access}` } },
)
attackerGoalId = attackerGoal.data.response.id
await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `secret-task-${Date.now()}`,
})
const columns = await ownerApi.kanban.fetchAllColumns(victimGoalId)
if (!columns?.length) throw new Error('Victim goal has no kanban columns')
victimColumnId = columns[0].id
})
afterAll(async () => {
await deleteAllGoals()
})
it('rejects reading another goal kanban tasks when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(
response.status,
`Leaked kanban tasks of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('rejects reading another goal task order when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks-order/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(response.status).toBe(403)
})
it('rejects reading another goal role-to-permission matrix when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/collaborationroles/role-to-permissions/${victimGoalId}`,
data: { goalId: attackerGoalId },
})
expect(
response.status,
`Leaked role matrix of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('control: the same requests without a body are already rejected', async () => {
const kanban = await attackerAxios.get(`/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`)
expect(kanban.status).toBe(403)
const roles = await attackerAxios.get(`/module/collaborationroles/role-to-permissions/${victimGoalId}`)
expect(roles.status).toBe(403)
})
it('control: the owner still reads their own kanban tasks and role matrix', async () => {
const tasks = await ownerApi.kanban
.fetchTasksForColumn(victimGoalId, victimColumnId, 0)
.catch((e: any) => { throw new Error(`kanban read failed: ${e.response?.status}`) })
expect(tasks).toBeDefined()
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(victimGoalId)
.catch((e: any) => { throw new Error(`role matrix read failed: ${e.response?.status}`) })
expect(matrix).toBeDefined()
})
})
@@ -0,0 +1,236 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* The integrations guards resolve the project to authorize against via
* resolveProjectId(), which prefers a projectId supplied by the caller over the
* one derived from integrationId. The handlers, however, act on integrationId.
* A caller must not be able to pass a project they own alongside someone else's
* integration id and have the guard authorize the wrong object.
*/
describe('Integrations object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let outsiderEmail: string
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerGoalId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
outsiderEmail = init.user2Email
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim project ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker project ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
attackerGoalId = attackerGoal.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function createVictimIntegration(): Promise<number> {
const created = await ownerApi.integrations.createIntegration({
provider: 'github',
repoFullName: `victim-org/private-repo-${Date.now()}`,
projectId: victimGoalId,
})
if (!created) throw new Error('Failed to create victim integration')
return created.id
}
async function victimIntegrationExists(integrationId: number): Promise<boolean> {
const list = await ownerApi.integrations.fetchIntegrations(victimGoalId)
return (list ?? []).some(i => i.id === integrationId)
}
it('rejects deleting another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
data: { id: integrationId, projectId: attackerGoalId },
})
// impact first, mechanism second — so a failure reports whether data was actually destroyed
expect(
await victimIntegrationExists(integrationId),
`victim integration ${integrationId} was destroyed by a non-member`,
).toBe(true)
expect(
response.status,
`Guard authorized project ${attackerGoalId} while the handler acted on integration ${integrationId}`,
).toBe(403)
})
it('rejects toggling another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.patch('/module/integrations/toggle', {
id: integrationId,
isActive: false,
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
it('rejects reading another user integration repos even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.get('/module/integrations/repos', {
params: { integrationId, projectId: attackerGoalId },
})
expect(response.status).toBe(403)
})
it('rejects syncing another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.post('/module/integrations/sync', {
integrationId,
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
it('control: without the injected projectId the guard already rejects the same request', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
data: { id: integrationId },
})
expect(response.status).toBe(403)
expect(await victimIntegrationExists(integrationId)).toBe(true)
})
// select-repo is the most consequential handler of the four: besides writing to
// the integration it kicks off syncIssues() and registerWebhook() against the repo
it('rejects selecting a repo on another user integration even when a self-owned projectId is supplied', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.patch('/module/integrations/select-repo', {
integrationId,
repoFullName: 'attacker-org/planted-repo',
repoExternalId: '424242',
projectId: attackerGoalId,
})
expect(response.status).toBe(403)
})
// resolveProjectId reads projectId from the query string too, so a fix that only
// hardens the body would still leave this door open
it('rejects the same bypass when projectId arrives via the query string', async () => {
const integrationId = await createVictimIntegration()
const response = await attackerAxios.delete('/module/integrations', {
params: { projectId: attackerGoalId },
data: { id: integrationId },
})
expect(
await victimIntegrationExists(integrationId),
`victim integration ${integrationId} was destroyed via a query-string projectId`,
).toBe(true)
expect(response.status).toBe(403)
})
it('rejects listing the integrations of a project the caller is not a member of', async () => {
await createVictimIntegration()
const response = await attackerAxios.get('/module/integrations', {
params: { projectId: victimGoalId },
})
expect(
response.status,
`Leaked integrations of project ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('rejects planting a new integration into a project the caller is not a member of', async () => {
const response = await attackerAxios.post('/module/integrations', {
provider: 'github',
repoFullName: 'attacker-org/planted-repo',
projectId: victimGoalId,
})
expect(response.status).toBe(403)
})
it('control: the owner can still manage their own integration', async () => {
const integrationId = await createVictimIntegration()
const deleted = await ownerApi.integrations.deleteIntegration(integrationId)
expect(deleted).toBeTruthy()
expect(await victimIntegrationExists(integrationId)).toBe(false)
})
// guards against an over-strict fix: a project member holding integrations_can_manage
// must keep working, not just the goal owner
it('control: a project member with integrations_can_manage can delete the integration', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Shared integrations ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
const managePermission = allPermissions.find(p => p.name === TvPermissions.INTEGRATIONS_CAN_MANAGE)
if (!managePermission) throw new Error('Permission "integrations_can_manage" is not in DB')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Integrator ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggled = await ownerApi.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: managePermission.id,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected integrations_can_manage to be added, got ${JSON.stringify(toggled)}`)
}
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
const created = await ownerApi.integrations.createIntegration({
provider: 'github',
repoFullName: `shared-org/repo-${Date.now()}`,
projectId: goal.id,
})
if (!created) throw new Error('Failed to create integration')
const deleted = await outsiderApi.integrations.deleteIntegration(created.id)
expect(deleted).toBeTruthy()
})
})
@@ -0,0 +1,220 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* Board writes (columns and task placement) are gated by kanban_can_manage, the
* permission the UI has always used. They used to accept component_can_add_tasks
* or task_can_add_subtasks instead, which let a rank-and-file member delete other
* people's board columns.
*/
describe('Kanban permission boundaries', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let outsiderEmail: string
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
const permissionIdByName = new Map<string, number>()
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
outsiderEmail = init.user2Email
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
for (const permission of await ownerApi.collaboration.fetchAllPermissions()) {
permissionIdByName.set(permission.name, permission.id)
}
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
/** A goal of user1 that user2 joins through a role carrying exactly `permissionNames`. */
async function shareGoalWith(permissionNames: string[]) {
const goal = await ownerApi.goals.createGoal({ name: `Kanban access ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Role ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
for (const name of permissionNames) {
const permissionId = permissionIdByName.get(name)
if (!permissionId) throw new Error(`Permission "${name}" is not in DB`)
const toggled = await ownerApi.collaboration.toggleRolePermission({ roleId: role.id, permissionId })
if (!toggled || toggled.add !== true) {
throw new Error(`Expected "${name}" to be added, got ${JSON.stringify(toggled)}`)
}
}
await ownerApi.collaboration.toggleUserRoles({ goalId: goal.id, userId: collab.id, roles: [role.id] })
return goal
}
async function addColumn(goalId: number, name: string) {
const column = await ownerApi.kanban.addColumn({ goalId, name })
if (!column) throw new Error('Failed to create column')
return column
}
describe('a member holding kanban_can_manage', () => {
it('can create, rename and delete a board column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
const created = await outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Created by member' })
expect(created?.id).toBeGreaterThan(0)
const renamed = await outsiderApi.kanban.updateColumn({ id: created!.id, name: 'Renamed by member' })
expect(renamed).toBeTruthy()
const deleted = await outsiderApi.kanban.deleteColumn({ id: created!.id })
expect(deleted).toBeTruthy()
})
it('can move a task into another column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_MANAGE])
const from = await addColumn(goal.id, 'From')
const to = await addColumn(goal.id, 'To')
const task = await ownerApi.tasks.createTask({
goalId: goal.id,
description: `movable-${Date.now()}`,
statusId: from.id,
})
if (!task) throw new Error('Failed to create task')
const moved = await outsiderApi.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: to.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: null,
})
expect(moved).toBeDefined()
})
})
describe('a member holding only task-level permissions', () => {
// exactly the pair the routes used to accept — the escalation that was closed
const TASK_LEVEL = [TvPermissions.COMPONENT_CAN_ADD_TASKS, TvPermissions.TASK_CAN_ADD_SUBTASKS]
it('cannot create a board column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
})
it('cannot rename or delete a board column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
const column = await addColumn(goal.id, 'Owned by user1')
await expectHttpStatus(outsiderApi.kanban.updateColumn({ id: column.id, name: 'Nope' }), 403)
await expectHttpStatus(outsiderApi.kanban.deleteColumn({ id: column.id }), 403)
const survivors = await ownerApi.kanban.fetchAllColumns(goal.id)
expect(survivors?.some(c => c.id === column.id), 'column was destroyed').toBe(true)
})
it('cannot move a task into another column', async () => {
const goal = await shareGoalWith(TASK_LEVEL)
const from = await addColumn(goal.id, 'From')
const to = await addColumn(goal.id, 'To')
const task = await ownerApi.tasks.createTask({
goalId: goal.id,
description: `pinned-${Date.now()}`,
statusId: from.id,
})
if (!task) throw new Error('Failed to create task')
await expectHttpStatus(
outsiderApi.kanban.updateTasksOrderAndColumn({
goalId: goal.id,
columnId: to.id,
taskId: task.id,
prevTaskId: null,
nextTaskId: null,
}),
403,
)
})
})
describe('a member holding only kanban_can_view', () => {
it('can read the task order of a column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
const column = await addColumn(goal.id, 'Readable')
const order = await outsiderApi.kanban.getTaskOrdersForColumnAndCursor(goal.id, column.id, null)
expect(order).toBeDefined()
})
it('cannot create a board column', async () => {
const goal = await shareGoalWith([TvPermissions.KANBAN_CAN_VIEW])
await expectHttpStatus(outsiderApi.kanban.addColumn({ goalId: goal.id, name: 'Nope' }), 403)
})
})
describe('the goal a column belongs to is never taken from the request', () => {
it('rejects deleting or renaming a foreign column even when a self-owned goalId is supplied', async () => {
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim board ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
const victimColumn = await addColumn(victimGoal.id, 'Victim column')
// a project user2 fully controls, offered to the guard as the authorization target
const ownGoal = await outsiderApi.goals.createGoal({ name: `Attacker board ${Date.now()}` })
if (!ownGoal) throw new Error('Failed to create attacker goal')
const deleteResponse = await attackerAxios.post('/module/kanban/delete-status', {
id: victimColumn.id,
goalId: ownGoal.id,
})
const updateResponse = await attackerAxios.post('/module/kanban/update-status', {
id: victimColumn.id,
name: 'Renamed by an outsider',
goalId: ownGoal.id,
})
const survivors = await ownerApi.kanban.fetchAllColumns(victimGoal.id)
const survivor = (survivors ?? []).find(c => c.id === victimColumn.id)
expect(survivor, `victim column ${victimColumn.id} was destroyed`).toBeDefined()
expect(survivor?.name, 'victim column was renamed').toBe('Victim column')
expect(deleteResponse.status).toBe(403)
expect(updateResponse.status).toBe(403)
})
})
})
@@ -26,7 +26,7 @@ afterAll(async () => {
describe('SSO: config management', () => {
let configId: number
it('should create SSO config with SAML protocol', async () => {
it('should create an unverified SSO config', async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
@@ -43,6 +43,9 @@ describe('SSO: config management', () => {
expect(config.protocol).toBe('saml')
expect(config.displayName).toBe('Test SAML')
expect(config.emailDomainRestriction).toBe('sso-test.example')
expect(config.enabled).toBe(0)
expect(config.isDomainVerified).toBe(false)
expect(config.domainVerifyToken).toBeTruthy()
configId = config.id
})
@@ -82,12 +85,24 @@ describe('SSO: config management', () => {
expect(updated.displayName).toBe('Updated SAML')
})
it('should check domain and find provider', async () => {
it('should not list an unverified domain as a public provider', async () => {
const provider = await user1Api.sso.checkDomain('sso-test.example')
expect(provider).toBeNull()
})
expect(provider).toBeTruthy()
expect(provider!.id).toBe(configId)
expect(provider!.protocol).toBe('saml')
it('should return DNS and HTTP proof instructions', async () => {
const started = await user1Api.sso.startDomainVerification(configId)
expect(started.token).toBeTruthy()
expect(started.dnsRecord).toBe(`taskview-sso-verify=${started.token}`)
expect(started.httpUrl).toContain('/.well-known/taskview-sso-verify.txt')
expect(started.isDomainVerified).toBe(false)
})
it('should not mark a domain verified when DNS and HTTP proofs are missing', async () => {
const result = await user1Api.sso.checkDomainVerification(configId)
expect(result.verified).toBe(false)
expect(result.method).toBeNull()
})
it('should return null for unknown domain', async () => {
@@ -189,6 +204,131 @@ describe('SSO: config management', () => {
})
})
describe('SSO: cross-org domain squatting', () => {
const squatDomain = 'squat-test.example'
let secondOrgId: number
let firstConfigId: number
let secondConfigId: number
beforeAll(async () => {
const org = await user2Api.organizations.create({ name: 'SSO Squat Org' })
secondOrgId = org.id
})
afterAll(async () => {
await user1Api.sso.deleteConfig(firstConfigId).catch(() => {})
await user2Api.sso.deleteConfig(secondConfigId).catch(() => {})
await user2Api.organizations.delete(secondOrgId).catch(() => {})
})
it('lets a different org create an unverified config for the same domain (no squatting)', async () => {
const first = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Squat First',
emailDomainRestriction: squatDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-squat-1',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
firstConfigId = first.id
expect(first.isDomainVerified).toBe(false)
const second = await user2Api.sso.createConfig({
organizationId: secondOrgId,
protocol: 'saml',
displayName: 'Squat Second',
emailDomainRestriction: squatDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-squat-2',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
secondConfigId = second.id
expect(second.isDomainVerified).toBe(false)
expect(second.id).not.toBe(first.id)
})
it('still rejects a duplicate config for the same domain within one org', async () => {
try {
await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'oidc',
displayName: 'Squat Same Org',
emailDomainRestriction: squatDomain,
oidcIssuer: 'https://accounts.google.com',
oidcClientId: 'test',
oidcClientSecret: 'test',
oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected duplicate domain within the same org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
}
})
it('rejects a second org creating a config for a domain another org already verified', async () => {
const ownedDomain = 'owned-sso.example' // in SSO_TRUSTED_DOMAINS → verified on creation
const owner = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Owned First',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-1',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect(owner.isDomainVerified).toBe(true)
try {
await user2Api.sso.createConfig({
organizationId: secondOrgId,
protocol: 'saml',
displayName: 'Owned Second',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-2',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected a domain already verified by another org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
} finally {
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
}
})
it('rejects switching a pending config onto a domain another org already verified', async () => {
const ownedDomain = 'owned-sso.example'
const owner = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Owned For Update',
emailDomainRestriction: ownedDomain,
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-owned-3',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect(owner.isDomainVerified).toBe(true)
try {
// user2's still-pending squat config tries to grab the owned domain
await user2Api.sso.updateConfig(secondConfigId, { emailDomainRestriction: ownedDomain })
expect.fail('Should have rejected switching onto a domain owned by another org')
} catch (error: any) {
expect(error.response?.status).toBe(409)
} finally {
await user1Api.sso.deleteConfig(owner.id).catch(() => {})
}
})
})
describe('SSO: SCIM token management', () => {
let configId: number
@@ -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,80 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* CanFetchTask authorizes `req.query.taskId || req.params.taskId`, while
* fetchTaskByIdNew reads `req.params`. A caller must not be able to name a task
* they own in the query string and have the guard authorize it while the handler
* returns someone else's task.
*/
describe('Task object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimTaskId: number
let attackerTaskId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim tasks ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
const victimTask = await ownerApi.tasks.createTask({
goalId: victimGoal.id,
description: `victim-secret-${Date.now()}`,
})
if (!victimTask) throw new Error('Failed to create victim task')
victimTaskId = victimTask.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker tasks ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
const attackerTask = await outsiderApi.tasks.createTask({
goalId: attackerGoal.id,
description: `attacker-own-${Date.now()}`,
})
if (!attackerTask) throw new Error('Failed to create attacker task')
attackerTaskId = attackerTask.id
})
afterAll(async () => {
await deleteAllGoals()
})
it('rejects reading another user task when a self-owned taskId is put in the query string', async () => {
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`, {
params: { taskId: attackerTaskId },
})
expect(
response.status,
`Leaked task ${victimTaskId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('control: without the query parameter the guard already rejects the same request', async () => {
const response = await attackerAxios.get(`/module/tasks/${victimTaskId}`)
expect(response.status).toBe(403)
})
it('control: the owner can still read their own task', async () => {
const task = await ownerApi.tasks.fetchTaskById(victimTaskId)
expect(task?.id).toBe(victimTaskId)
})
})
@@ -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;
};
@@ -13,6 +13,14 @@ export default class TvGraph extends TvApiBase {
);
}
public async fetchTaskEdges(taskId: number) {
return this.request(
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
`${this.moduleUrl}/task/${taskId}`
)
);
}
public async fetchAllEdges(goalId: number) {
return this.request(
this.$axios.get<AppResponse<GraphResponseAddEdge[]>>(
@@ -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;
};
@@ -4,6 +4,8 @@ import type {
SsoConfig,
SsoConfigArgCreate,
SsoConfigArgUpdate,
SsoDomainVerificationCheck,
SsoDomainVerificationStart,
SsoProviderPublic,
SsoPublicUrls,
} from './sso.types'
@@ -63,6 +65,18 @@ export default class TvSsoApi extends TvApiBase {
)
}
public async startDomainVerification(configId: number) {
return this.request(
this.$axios.post<AppResponse<SsoDomainVerificationStart>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain`)
)
}
public async checkDomainVerification(configId: number) {
return this.request(
this.$axios.post<AppResponse<SsoDomainVerificationCheck>>(`${this.moduleUrl}/admin/configs/${configId}/verify-domain/check`)
)
}
public async checkDomain(domain: string) {
return this.request(
this.$axios.get<AppResponse<SsoProviderPublic | null>>(`${this.moduleUrl}/providers`, {
@@ -25,11 +25,30 @@ export type SsoConfig = {
hasSamlSigningCert: boolean
hasOidcClientSecret: boolean
hasScimToken: boolean
domainVerifyToken: string | null
domainVerifiedAt: string | null
isDomainVerified: boolean
isDomainTrusted: boolean
domainVerifyDnsRecord: string | null
domainVerifyHttpUrl: string
createdAt: string
updatedAt: string
}
export type SsoDomainVerificationStart = {
token: string
dnsRecord: string
httpUrl: string
isDomainVerified: boolean
isDomainTrusted: boolean
}
export type SsoDomainVerificationCheck = {
verified: boolean
method: 'dns' | 'http' | 'trusted' | null
}
export type SsoConfigArgCreate = {
organizationId: number
protocol: 'saml' | 'oidc'
@@ -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;
};

Some files were not shown because too many files have changed in this diff Show More