mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e1597c13 | |||
| 3403e1a71d | |||
| e9fb4f6f1e | |||
| a173a870d7 | |||
| 968c3d2eeb | |||
| 171f269ee2 | |||
| f79614ebc5 | |||
| a4c465469d | |||
| f3e2663ebe | |||
| 5ce2fe99f0 | |||
| e11aba4d79 | |||
| 0b6f95ec3d | |||
| a596e023db | |||
| c1685e42b8 | |||
| 9635120e66 | |||
| 19e0212edf | |||
| 5dc5b387de | |||
| 8011da1259 | |||
| a6329e998d | |||
| b372854636 | |||
| a243f9ec56 | |||
| ebd25a94ea | |||
| 4b142619c0 | |||
| 6f576e7297 | |||
| 15050541fa | |||
| 3afdca99d9 | |||
| aaa876412d | |||
| 915e8d9f9f | |||
| 55ded8bac9 | |||
| 784652ef5b | |||
| 1f1a1b770f | |||
| e80ab33dda | |||
| 8c7be7362f | |||
| 57ec7c01b6 | |||
| d0f664f78e | |||
| 7bbb36d45e | |||
| 9934bf06d8 | |||
| fd13b33915 | |||
| 64089fd6e7 | |||
| b7a50049bb | |||
| 504ae503dd | |||
| 263883f32d | |||
| 90b55fd82a | |||
| 2e9a4945ce | |||
| 0d70023fb3 | |||
| 645f2e21e5 | |||
| adce016a05 | |||
| b7f2380d45 | |||
| 58eb93e564 | |||
| 4e5e5fb579 | |||
| 314e5a6377 | |||
| 955697f40f | |||
| e5dbba8e2a | |||
| bb28bac9f7 | |||
| 6c990aff4f | |||
| b296046605 | |||
| 40cd8064f5 | |||
| f4a765f40b | |||
| bc08c839bc |
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -27,6 +39,10 @@ SMTP_PASSWORD=your_smtp_password_here
|
||||
SMTP_ENCRYPTION=ssl
|
||||
SMTP_FROM_NAME=TaskView
|
||||
SMTP_FROM_EMAIL=your_email@example.com
|
||||
# Email a person when they are invited to a project (requires SMTP)
|
||||
INVITE_EMAIL_ENABLED=false
|
||||
# Max invite emails one user may trigger per hour (default 30)
|
||||
# INVITE_EMAIL_HOURLY_LIMIT=30
|
||||
|
||||
# Encryption (32-byte hex key for AES-256-GCM)
|
||||
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
@@ -48,6 +64,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
|
||||
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
|
||||
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
|
||||
|
||||
# Gitea Integration OAuth
|
||||
GITEA_INTEGRATION_CLIENT_ID=
|
||||
GITEA_INTEGRATION_CLIENT_SECRET=
|
||||
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
|
||||
# For self-hosted Gitea, override these:
|
||||
# GITEA_BASE_URL=https://gitea.yourcompany.com
|
||||
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
|
||||
|
||||
# Firebase Cloud Messaging (push notifications for mobile, optional)
|
||||
# Path to Firebase service account JSON file
|
||||
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
||||
|
||||
+30
-1
@@ -1,9 +1,38 @@
|
||||
// https://github.com/Gimanh/taskview-community/issues/88
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
instances,
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
autorestart: true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "taskview-ce-api-server",
|
||||
"version": "1.50.1",
|
||||
"version": "1.53.0",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch ./server.ts",
|
||||
"start": "NODE_ENV=production node ./dist/taskview-server.js",
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
|
||||
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
|
||||
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
|
||||
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
|
||||
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
|
||||
const rawInstances = process.env.PM2_INSTANCES;
|
||||
const instances = rawInstances === 'max'
|
||||
? 'max'
|
||||
: Number(rawInstances) > 0
|
||||
? Number(rawInstances)
|
||||
: 2;
|
||||
|
||||
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
|
||||
|
||||
if (instances === 'max') {
|
||||
console.warn(
|
||||
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
|
||||
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
|
||||
+ 'the Postgres max_connections limit (default 100).'
|
||||
);
|
||||
} else if (instances * poolMax > 80) {
|
||||
console.warn(
|
||||
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
|
||||
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
|
||||
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'taskview-server',
|
||||
script: 'taskview-server.js',
|
||||
instances: 'max',
|
||||
watch: true,
|
||||
ignore_watch: ['logs'],
|
||||
instances,
|
||||
watch: false,
|
||||
autorestart: true,
|
||||
max_memory_restart: '1G',
|
||||
env_production: {
|
||||
|
||||
@@ -6,6 +6,8 @@ import errorHandler from './middlewares/error-handler';
|
||||
import routes from './routes';
|
||||
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
|
||||
import { LoginMethods } from './tv-modules/auth/LoginMethods';
|
||||
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
|
||||
import { PublicApiUrl } from './modules/public-url';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
|
||||
|
||||
@@ -15,6 +17,8 @@ export default class App {
|
||||
|
||||
constructor(port: number) {
|
||||
LoginMethods.validateOnStartup();
|
||||
PublicApiUrl.validateOnStartup();
|
||||
InviteEmailDispatcher.validateOnStartup();
|
||||
|
||||
this.app = express();
|
||||
this.port = port;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
|
||||
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
|
||||
import { $logger } from '../modules/logget';
|
||||
|
||||
export interface AppEvents {
|
||||
@@ -8,7 +9,7 @@ export interface AppEvents {
|
||||
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
|
||||
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
|
||||
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
|
||||
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
|
||||
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
|
||||
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTracking
|
||||
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
|
||||
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
|
||||
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
|
||||
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
|
||||
|
||||
const dispatchers: Dispatcher[] = [
|
||||
new NotificationDispatcher(),
|
||||
@@ -16,6 +17,7 @@ const dispatchers: Dispatcher[] = [
|
||||
new SprintsDispatcher(),
|
||||
new RecurrenceDispatcher(),
|
||||
new MessagingDispatcher(),
|
||||
new InviteEmailDispatcher(),
|
||||
];
|
||||
|
||||
export function registerAllEventHandlers() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { RequireTokenPermission } from '../require-token-permission';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
|
||||
const runWith = (tokenPermissions: string[] | undefined) => {
|
||||
const next = vi.fn();
|
||||
const end = vi.fn();
|
||||
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
|
||||
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
|
||||
|
||||
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
|
||||
return { next, res };
|
||||
};
|
||||
|
||||
describe('RequireTokenPermission', () => {
|
||||
it('lets a browser session through — it carries no token permissions', () => {
|
||||
const { next, res } = runWith(undefined);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an unrestricted token through, keeping existing integrations working', () => {
|
||||
const { next } = runWith([]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a token holding the permission through', () => {
|
||||
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks a restricted token that was not given the permission', () => {
|
||||
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('does not accept a neighbouring permission from the same group', () => {
|
||||
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { $logger } from '../modules/logget';
|
||||
import AuthController from '../tv-modules/auth/AuthController';
|
||||
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
|
||||
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
|
||||
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/types';
|
||||
|
||||
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
@@ -13,7 +14,7 @@ export const appUserMiddleware = async (req: Request, res: Response, next: NextF
|
||||
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
|
||||
if (token && token.startsWith(TOKEN_PREFIX)) {
|
||||
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
|
||||
const record = await getApiTokensManager().validateToken(token);
|
||||
if (record) {
|
||||
const authManager = new AppUser().authManager;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { GoalPermissionType } from '../types/auth.types';
|
||||
|
||||
/**
|
||||
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
|
||||
* by an organization role or by project ownership rather than by the project RBAC
|
||||
* — organizations, SSO configuration, webhooks. Those checks never consult
|
||||
* GoalPermissionsFetcher, so without this the scope chosen when the token was
|
||||
* issued would simply not apply to them.
|
||||
*
|
||||
* It only ever removes access. Put it AFTER the role or ownership guard, so that
|
||||
* guard still has the final say on what the human behind the token may do:
|
||||
*
|
||||
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
|
||||
*
|
||||
* A browser session has no token permissions and passes. A token issued with an
|
||||
* empty permission list is unrestricted by design — the same meaning it carries
|
||||
* everywhere else — and also passes, which keeps existing integrations working.
|
||||
*/
|
||||
export const RequireTokenPermission = (permission: GoalPermissionType) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const tokenPermissions = req.appUser.getTokenPermissions();
|
||||
|
||||
if (!tokenPermissions || tokenPermissions.length === 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (tokenPermissions.includes(permission)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
};
|
||||
@@ -704,5 +704,81 @@
|
||||
"description": [
|
||||
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
|
||||
]
|
||||
},
|
||||
"55": {
|
||||
"version": "1.60.0",
|
||||
"name": "Recurrence schedule mode",
|
||||
"releaseDate": "20260712",
|
||||
"scripts": [
|
||||
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
|
||||
],
|
||||
"description": [
|
||||
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
|
||||
]
|
||||
},
|
||||
"56": {
|
||||
"version": "1.61.0",
|
||||
"name": "Gitea integration provider",
|
||||
"releaseDate": "20260726",
|
||||
"scripts": [
|
||||
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
|
||||
],
|
||||
"description": [
|
||||
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
|
||||
]
|
||||
},
|
||||
"57": {
|
||||
"version": "1.62.0",
|
||||
"name": "Invite email rate limiting",
|
||||
"releaseDate": "20260730",
|
||||
"scripts": [
|
||||
"/1.62.0/0.create-invite-emails.sql"
|
||||
],
|
||||
"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,10 @@
|
||||
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
|
||||
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
|
||||
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
|
||||
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
|
||||
ALTER TABLE tasks.recurrence_rules
|
||||
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
|
||||
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Log of sent project-invite emails, used to rate-limit sending:
|
||||
-- a 24h per-recipient cooldown and an hourly cap per initiator.
|
||||
-- Rows older than 24 hours are pruned opportunistically before each insert.
|
||||
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
|
||||
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export class PublicApiUrl {
|
||||
static configured(): string | null {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
return raw.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
static base(req: Request): string {
|
||||
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const raw = process.env.API_PUBLIC_URL;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw.trim());
|
||||
} catch {
|
||||
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -153,6 +153,11 @@ export default class AuthController {
|
||||
}
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:sendLoginCode] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7),
|
||||
login = this.makeidLogin(7);
|
||||
|
||||
@@ -227,6 +232,11 @@ export default class AuthController {
|
||||
);
|
||||
|
||||
if (!userData) {
|
||||
if (!(await this.canCreateAccount(req, user.email))) {
|
||||
$logger.info(`[AuthController:loginByProvider] public registration disabled, email not invited`);
|
||||
return res.redirect(`${process.env.APP_URL}/login?sso_error=registration-disabled`);
|
||||
}
|
||||
|
||||
const password = this.makeidLogin(7);
|
||||
const login = this.makeidLogin(7);
|
||||
|
||||
@@ -357,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,
|
||||
@@ -430,6 +450,11 @@ export default class AuthController {
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
if (!(await this.canCreateAccount(req, email))) {
|
||||
$logger.info(`[AuthController:registration] public registration disabled, email not invited`);
|
||||
return res.status(403).send({ registrationDisabled: true });
|
||||
}
|
||||
|
||||
password = hashSync(password, 10);
|
||||
|
||||
if (!(await this.comparePasswords(passwordRepeat, password))) {
|
||||
@@ -671,9 +696,15 @@ export default class AuthController {
|
||||
password: LoginMethods.isEnabled('password'),
|
||||
sso: LoginMethods.isEnabled('sso'),
|
||||
socialProviders: LoginMethods.availableSocialProviders(),
|
||||
publicRegistration: LoginMethods.publicRegistrationAllowed(),
|
||||
});
|
||||
};
|
||||
|
||||
private canCreateAccount = async (req: Request, email: string): Promise<boolean> => {
|
||||
if (LoginMethods.publicRegistrationAllowed()) return true;
|
||||
return await req.appUser.authManager.repository.isEmailInvited(email);
|
||||
};
|
||||
|
||||
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
|
||||
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
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;
|
||||
@@ -69,6 +69,28 @@ export default class AuthModel {
|
||||
}
|
||||
}
|
||||
|
||||
async isEmailInvited(email: string): Promise<boolean> {
|
||||
const normalized = email.toLowerCase();
|
||||
try {
|
||||
const orgMembers = await this.db.dbDrizzle
|
||||
.select({ email: OrganizationMembersSchema.email })
|
||||
.from(OrganizationMembersSchema)
|
||||
.where(sql`lower(${OrganizationMembersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
if (orgMembers.length > 0) return true;
|
||||
|
||||
const collaborators = await this.db.dbDrizzle
|
||||
.select({ email: CollaborationUsersSchema.email })
|
||||
.from(CollaborationUsersSchema)
|
||||
.where(sql`lower(${CollaborationUsersSchema.email}) = ${normalized}`)
|
||||
.limit(1);
|
||||
return collaborators.length > 0;
|
||||
} catch (error: unknown) {
|
||||
$logger.error(error, '[AuthModel:isEmailInvited] failed to check invitations');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUserById(id: number): Promise<UserDbRecord | false> {
|
||||
const query = 'SELECT * FROM tv_auth.users WHERE id = $1;';
|
||||
try {
|
||||
@@ -97,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
|
||||
@@ -167,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';
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Routable } from '../../types/routable.type';
|
||||
import AuthController from './AuthController';
|
||||
import { IsLoggedIn } from './middlewares/is-logged-in';
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth';
|
||||
import { RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
|
||||
import { RequireAnyLoginMethod, RequireLoginMethod, RequireSocialProvider } from './middlewares/require-login-method';
|
||||
import passport from './strategies/passport-login';
|
||||
import { ExternalProviderScope } from './strategies/external-auth.types';
|
||||
export default class AuthRoutes implements Routable {
|
||||
@@ -23,7 +23,9 @@ export default class AuthRoutes implements Routable {
|
||||
initRoutes() {
|
||||
this.router.get('/login-options', this.authController.getLoginOptions);
|
||||
this.router.post('/send-login-code', [RequireLoginMethod('magic-link')], this.authController.sendLoginCode);
|
||||
this.router.post('/login-by-code', [RequireLoginMethod('magic-link')], this.authController.loginByCode);
|
||||
// Shared one-time-code redemption: magic-link emails, SSO callbacks and social
|
||||
// OAuth callbacks all complete the login through this endpoint
|
||||
this.router.post('/login-by-code', [RequireAnyLoginMethod(['magic-link', 'sso', 'social'])], this.authController.loginByCode);
|
||||
this.router.post('/login', [RequireLoginMethod('password')], this.authController.login);
|
||||
this.router.post('/registration', this.authController.registration);
|
||||
this.router.get('/confirm/email/:code/login/:login', this.authController.confirmEmail);
|
||||
|
||||
@@ -15,7 +15,21 @@ export class LoginMethods {
|
||||
return LoginMethods.enabled().has(method);
|
||||
}
|
||||
|
||||
static publicRegistrationAllowed(): boolean {
|
||||
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
|
||||
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
|
||||
const normalized = registrationRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(
|
||||
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const raw = process.env.AUTH_LOGIN_METHODS;
|
||||
if (!raw || !raw.trim()) return;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,15 @@ export const RequireLoginMethod = (method: LoginMethod) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireAnyLoginMethod = (methods: LoginMethod[]) => {
|
||||
return (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (!methods.some((method) => LoginMethods.isEnabled(method))) {
|
||||
return res.status(403).send();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const RequireSocialProvider = (req: Request, res: Response, next: NextFunction) => {
|
||||
const providerName = String(req.params.providerName || '').toLowerCase();
|
||||
if (!LoginMethods.availableSocialProviders().includes(providerName)) {
|
||||
|
||||
+18
-1
@@ -1,8 +1,12 @@
|
||||
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 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();
|
||||
@@ -19,5 +23,18 @@ export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, r
|
||||
return next();
|
||||
}
|
||||
|
||||
const permissions = await req.appUser.permissionsFetcher
|
||||
.getPermissionsForType(Number(goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
|
||||
.catch(logError);
|
||||
|
||||
if (!permissions) {
|
||||
$logger.error('Can not get permissions for CanFetchRolesPermissionsCollaborationRoles middleware');
|
||||
return res.status(500).end();
|
||||
}
|
||||
|
||||
if (permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).end();
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './collaboration.types';
|
||||
|
||||
export class CollaborationController {
|
||||
/** @deprecated */
|
||||
fetchAllUsers = async (req: Request, res: Response) => {
|
||||
const users = await req.appUser.collaborationManager.fetchAllUsers();
|
||||
return res.tvJson(users);
|
||||
@@ -82,19 +83,30 @@ export class CollaborationController {
|
||||
return res.status(400).send(output.summary);
|
||||
}
|
||||
|
||||
const user = await req.appUser.collaborationManager.addUserNew(output);
|
||||
const result = await req.appUser.collaborationManager.addUserNew(output);
|
||||
|
||||
if (user) {
|
||||
// created=false means the person was already in the goal — re-POSTing must not re-notify
|
||||
if (result?.created) {
|
||||
eventBus.emit('collaboration.userAdded', {
|
||||
goalId: output.goalId,
|
||||
email: output.email.toLowerCase(),
|
||||
initiatorId: req.appUser.getUserData()!.id,
|
||||
locale: this.resolveLocale(req),
|
||||
});
|
||||
}
|
||||
|
||||
return res.tvJson(user ?? null);
|
||||
return res.tvJson(result?.user ?? null);
|
||||
};
|
||||
|
||||
// The invitee has no stored locale (often no account yet), so localize by the inviter's browser language
|
||||
private resolveLocale(req: Request): 'en' | 'ru' {
|
||||
const acceptLanguage = req.headers['accept-language'];
|
||||
if (!acceptLanguage) return 'en';
|
||||
|
||||
const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim().toLowerCase());
|
||||
return languages.some((lang) => lang === 'ru' || lang.startsWith('ru-')) ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
deleteUserNew = async (req: Request, res: Response) => {
|
||||
const output = CollaborationArkTypeDeleteUser(req.body);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AppUser } from '../../core/AppUser';
|
||||
import { GoalPermissions } from '../../types/auth.types';
|
||||
import { CollaborationRepository } from './CollaborationRepository';
|
||||
import type {
|
||||
CollaborationAddUserResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -24,6 +25,7 @@ export class CollaborationManager {
|
||||
this.repository = new CollaborationRepository();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
|
||||
|
||||
@@ -69,6 +71,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(args: FetchGoalUsersArg): Promise<CollaborationUserWithRoles[] | false> {
|
||||
const users = await this.repository.fetchUsersForGoal(args.goalId);
|
||||
|
||||
@@ -103,6 +106,7 @@ export class CollaborationManager {
|
||||
return Object.values(resultMap);
|
||||
}
|
||||
|
||||
/** @deprecated*/
|
||||
async toggleUserRoles(args: ToggleUserRolesArg): Promise<number[] | false> {
|
||||
return await this.repository.updateUserRoles(args.userId, args.roles);
|
||||
}
|
||||
@@ -120,7 +124,7 @@ export class CollaborationManager {
|
||||
return await this.repository.deleteUser(args);
|
||||
}
|
||||
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
|
||||
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationAddUserResult | null> {
|
||||
const email = args.email.toLowerCase();
|
||||
|
||||
const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId);
|
||||
@@ -131,19 +135,22 @@ export class CollaborationManager {
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.repository.addUserForCollaborationNew({
|
||||
const result = await this.repository.addUserForCollaborationNew({
|
||||
...args,
|
||||
email,
|
||||
});
|
||||
if (!user) return null;
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
...user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
user: {
|
||||
...result.user,
|
||||
goalId: args.goalId,
|
||||
goal_id: args.goalId,
|
||||
invitation_date: result.user.invitationDate,
|
||||
roles: [],
|
||||
goalOwner: false,
|
||||
},
|
||||
created: result.created,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +184,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
@@ -210,7 +217,7 @@ export class CollaborationManager {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resultMap: Record<string, CollaborationUserWithRoles> = {};
|
||||
|
||||
users.forEach((item) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq, exists, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
CollaborationRolesSchema,
|
||||
CollaborationUsersSchema,
|
||||
type CollaborationUsersSchemaTypeForSelect,
|
||||
CollaborationUsersToGoalsSchema,
|
||||
@@ -10,6 +11,7 @@ import { $logger } from '../../modules/logget';
|
||||
import { logError } from '../../utils/api';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type {
|
||||
CollaborationAddUserRepoResult,
|
||||
CollaborationArgAddUser,
|
||||
CollaborationArgDeleteUser,
|
||||
CollaborationArgToggleUserRoles,
|
||||
@@ -23,6 +25,7 @@ export class CollaborationRepository {
|
||||
this.db = Database.getInstance();
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchAllUsers(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -34,6 +37,10 @@ export class CollaborationRepository {
|
||||
FROM collaboration.users u
|
||||
left join collaboration.users_to_goals utg on u.id = utg.user_id
|
||||
LEFT JOIN collaboration.users_to_roles utr ON u.id = utr.user_id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM collaboration.roles r
|
||||
WHERE r.id = utr.role_id AND r.goal_id = utg.goal_id
|
||||
)
|
||||
WHERE utg.goal_id IN (${placeholders})
|
||||
`;
|
||||
|
||||
@@ -91,7 +98,8 @@ export class CollaborationRepository {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoal(goalId: number): Promise<FetchUsersForGoal[] | false> {
|
||||
const query = `
|
||||
SELECT u.*, u.invitation_date::text, utr.role_id, utg.goal_id
|
||||
@@ -111,6 +119,7 @@ export class CollaborationRepository {
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async fetchUsersForGoals(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
|
||||
if (goalIds.length === 0) {
|
||||
return [];
|
||||
@@ -145,6 +154,7 @@ export class CollaborationRepository {
|
||||
return !!(result.rowCount && result.rowCount > 0);
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async updateUserRoles(userId: number, roles: number[]): Promise<number[] | false> {
|
||||
const deleteQuery = `DELETE FROM collaboration.users_to_roles WHERE user_id = $1`;
|
||||
let i = 1;
|
||||
@@ -197,8 +207,8 @@ export class CollaborationRepository {
|
||||
|
||||
async addUserForCollaborationNew(
|
||||
args: CollaborationArgAddUser
|
||||
): Promise<CollaborationUsersSchemaTypeForSelect | null> {
|
||||
const user = await callWithCatch(() =>
|
||||
): Promise<CollaborationAddUserRepoResult | null> {
|
||||
return await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
let userId: number;
|
||||
let user: CollaborationUsersSchemaTypeForSelect;
|
||||
@@ -217,18 +227,14 @@ export class CollaborationRepository {
|
||||
user = userTransaction;
|
||||
}
|
||||
|
||||
await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
const linked = await tx.insert(CollaborationUsersToGoalsSchema).values({
|
||||
userId: userId,
|
||||
goalId: args.goalId,
|
||||
}).onConflictDoNothing();
|
||||
}).onConflictDoNothing().returning();
|
||||
|
||||
return user;
|
||||
return { user, created: linked.length > 0 };
|
||||
})
|
||||
);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUserNew(args: CollaborationArgDeleteUser) {
|
||||
@@ -250,13 +256,29 @@ export class CollaborationRepository {
|
||||
async toggleUserRolesNew(args: CollaborationArgToggleUserRoles): Promise<number[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(eq(CollaborationUsersToRolesSchema.userId, args.userId));
|
||||
if (args.roles.length > 0) {
|
||||
|
||||
const goalRoles = await tx
|
||||
.select({ id: CollaborationRolesSchema.id })
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(eq(CollaborationRolesSchema.goalId, args.goalId));
|
||||
const goalRoleIds = goalRoles.map((role) => role.id);
|
||||
|
||||
if (goalRoleIds.length > 0) {
|
||||
await tx
|
||||
.delete(CollaborationUsersToRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationUsersToRolesSchema.userId, args.userId),
|
||||
inArray(CollaborationUsersToRolesSchema.roleId, goalRoleIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rolesToAssign = args.roles.filter((roleId) => goalRoleIds.includes(roleId));
|
||||
if (rolesToAssign.length > 0) {
|
||||
return await tx
|
||||
.insert(CollaborationUsersToRolesSchema)
|
||||
.values(args.roles.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.values(rolesToAssign.map((roleId) => ({ userId: args.userId, roleId })))
|
||||
.returning();
|
||||
}
|
||||
return [];
|
||||
@@ -287,7 +309,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(inArray(CollaborationUsersToGoalsSchema.goalId, goalIds))
|
||||
);
|
||||
@@ -311,7 +346,20 @@ export class CollaborationRepository {
|
||||
)
|
||||
.leftJoin(
|
||||
CollaborationUsersToRolesSchema,
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
|
||||
and(
|
||||
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
|
||||
exists(
|
||||
this.db.dbDrizzle
|
||||
.select()
|
||||
.from(CollaborationRolesSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
|
||||
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { and, count, eq, gte, lt, sql } from 'drizzle-orm';
|
||||
import { GoalsSchema, InviteEmailsSchema, OrganizationsSchema, UsersSchema } from 'taskview-db-schemas';
|
||||
import type { Dispatcher } from '../../core/Dispatcher';
|
||||
import { Email } from '../../core/Email';
|
||||
import { eventBus, type AppEvents } from '../../core/EventBus';
|
||||
import { Database } from '../../modules/db';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { escapeHtml, parsePositiveInt } from '../../utils/helpers';
|
||||
import InviteEmailTemplateEn from './mail/invite-en';
|
||||
import InviteEmailTemplateRu from './mail/invite-ru';
|
||||
import type { InviteEmailRateLimitArgs, InviteEmailSendArgs } from './collaboration.server.types';
|
||||
|
||||
const DEFAULT_HOURLY_LIMIT = 30;
|
||||
|
||||
export class InviteEmailDispatcher implements Dispatcher {
|
||||
static enabled(): boolean {
|
||||
return process.env.INVITE_EMAIL_ENABLED?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
static hourlyLimit(): number {
|
||||
return parsePositiveInt(process.env.INVITE_EMAIL_HOURLY_LIMIT) ?? DEFAULT_HOURLY_LIMIT;
|
||||
}
|
||||
|
||||
static validateOnStartup(): void {
|
||||
const enabledRaw = process.env.INVITE_EMAIL_ENABLED;
|
||||
if (enabledRaw !== undefined && enabledRaw.trim() !== '') {
|
||||
const normalized = enabledRaw.trim().toLowerCase();
|
||||
if (normalized !== 'true' && normalized !== 'false') {
|
||||
throw new Error(`INVITE_EMAIL_ENABLED has unrecognized value "${enabledRaw}". Allowed: true, false`);
|
||||
}
|
||||
}
|
||||
|
||||
const limitRaw = process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
if (limitRaw !== undefined && limitRaw.trim() !== '' && parsePositiveInt(limitRaw) === null) {
|
||||
throw new Error(
|
||||
`INVITE_EMAIL_HOURLY_LIMIT has unrecognized value "${limitRaw}". Expected a positive integer`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
register(): void {
|
||||
eventBus.on('collaboration.userAdded', (data) => this.onUserAdded(data));
|
||||
}
|
||||
|
||||
async registerWorkers(): Promise<void> {}
|
||||
|
||||
private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
|
||||
if (!InviteEmailDispatcher.enabled() || !process.env.SMTP_HOST) return;
|
||||
|
||||
const db = Database.getInstance();
|
||||
|
||||
const [goal] = await db.dbDrizzle
|
||||
.select({ name: GoalsSchema.name, organizationId: GoalsSchema.organizationId })
|
||||
.from(GoalsSchema)
|
||||
.where(eq(GoalsSchema.id, data.goalId))
|
||||
.limit(1);
|
||||
if (!goal) return;
|
||||
|
||||
const [inviter] = await db.dbDrizzle
|
||||
.select({ login: UsersSchema.login })
|
||||
.from(UsersSchema)
|
||||
.where(eq(UsersSchema.id, data.initiatorId))
|
||||
.limit(1);
|
||||
if (!inviter) return;
|
||||
|
||||
const allowed = await this.passesRateLimit({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
if (!allowed) return;
|
||||
|
||||
const link = await this.buildGoalLink(data.goalId, goal.organizationId);
|
||||
if (!link) {
|
||||
$logger.warn('APP_URL is not set — skipping invite email');
|
||||
return;
|
||||
}
|
||||
|
||||
await db.dbDrizzle.insert(InviteEmailsSchema).values({
|
||||
initiatorId: data.initiatorId,
|
||||
email: data.email,
|
||||
goalId: data.goalId,
|
||||
});
|
||||
|
||||
const fallbackName = data.locale === 'ru' ? 'Пользователь TaskView' : 'A TaskView user';
|
||||
|
||||
await this.sendInviteEmail({
|
||||
email: data.email,
|
||||
inviterName: this.truncate(inviter.login?.trim() || fallbackName),
|
||||
goalName: this.truncate(goal.name || ''),
|
||||
link,
|
||||
locale: data.locale,
|
||||
});
|
||||
}
|
||||
|
||||
// Two rules: a 24h cooldown per (goal, recipient) — closes the delete/re-add resend loop —
|
||||
// and an hourly cap per initiator against using the instance as a mail relay.
|
||||
// Rows older than the cooldown window are pruned first, keeping the table tiny.
|
||||
private async passesRateLimit(args: InviteEmailRateLimitArgs): Promise<boolean> {
|
||||
const db = Database.getInstance();
|
||||
|
||||
await db.dbDrizzle
|
||||
.delete(InviteEmailsSchema)
|
||||
.where(lt(InviteEmailsSchema.sentAt, sql`now() - interval '24 hours'`));
|
||||
|
||||
const [cooldown] = await db.dbDrizzle
|
||||
.select({ id: InviteEmailsSchema.id })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(and(eq(InviteEmailsSchema.goalId, args.goalId), eq(InviteEmailsSchema.email, args.email)))
|
||||
.limit(1);
|
||||
if (cooldown) return false;
|
||||
|
||||
const [hourly] = await db.dbDrizzle
|
||||
.select({ count: count() })
|
||||
.from(InviteEmailsSchema)
|
||||
.where(
|
||||
and(
|
||||
eq(InviteEmailsSchema.initiatorId, args.initiatorId),
|
||||
gte(InviteEmailsSchema.sentAt, sql`now() - interval '1 hour'`)
|
||||
)
|
||||
);
|
||||
if ((hourly?.count ?? 0) >= InviteEmailDispatcher.hourlyLimit()) {
|
||||
$logger.warn(
|
||||
{ initiatorId: args.initiatorId, goalId: args.goalId },
|
||||
'Invite email hourly limit reached — skipping send'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async sendInviteEmail(args: InviteEmailSendArgs): Promise<void> {
|
||||
const template = args.locale === 'ru' ? InviteEmailTemplateRu : InviteEmailTemplateEn;
|
||||
const subject =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас в проект «${args.goalName}» в TaskView`
|
||||
: `${args.inviterName} invited you to "${args.goalName}" on TaskView`;
|
||||
const text =
|
||||
args.locale === 'ru'
|
||||
? `${args.inviterName} приглашает вас присоединиться к проекту «${args.goalName}» в TaskView.\n\nОткрыть проект: ${args.link}`
|
||||
: `${args.inviterName} has invited you to join the project "${args.goalName}" on TaskView.\n\nOpen the project: ${args.link}`;
|
||||
|
||||
// Single-pass replace with a function: no re-substitution of placeholders inside
|
||||
// inserted values, and no special treatment of $-patterns in the replacement
|
||||
const values: Record<string, string> = {
|
||||
inviter: args.inviterName,
|
||||
project: args.goalName,
|
||||
link: args.link,
|
||||
};
|
||||
const html = template.replace(/\{(inviter|project|link)\}/g, (_, key: string) => escapeHtml(values[key]));
|
||||
|
||||
await Email.send({
|
||||
text,
|
||||
subject,
|
||||
to: args.email,
|
||||
from: process.env.SMTP_FROM_EMAIL as string,
|
||||
attachment: [{ data: html, alternative: true }],
|
||||
});
|
||||
}
|
||||
|
||||
// Frontend project route is /:orgSlug/:projectId; goals without an organization fall back to the app root
|
||||
private async buildGoalLink(goalId: number, organizationId: number | null): Promise<string | null> {
|
||||
const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, '');
|
||||
if (!appUrl) return null;
|
||||
if (!organizationId) return appUrl;
|
||||
|
||||
const db = Database.getInstance();
|
||||
const [org] = await db.dbDrizzle
|
||||
.select({ slug: OrganizationsSchema.slug })
|
||||
.from(OrganizationsSchema)
|
||||
.where(eq(OrganizationsSchema.id, organizationId))
|
||||
.limit(1);
|
||||
if (!org?.slug) return appUrl;
|
||||
|
||||
return `${appUrl}/${encodeURIComponent(org.slug)}/${goalId}`;
|
||||
}
|
||||
|
||||
private truncate(value: string): string {
|
||||
const max = 80;
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Email } from '../../../core/Email';
|
||||
import type { AppEvents } from '../../../core/EventBus';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { InviteEmailDispatcher } from '../InviteEmailDispatcher';
|
||||
|
||||
vi.mock('../../../core/Email', () => ({
|
||||
Email: {
|
||||
send: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../modules/db', () => ({
|
||||
Database: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Each select() call consumes the next result; the returned query is both awaitable
|
||||
// (count query) and .limit()-able (lookups), matching the Drizzle chains in the dispatcher
|
||||
function mockDb(selectResults: unknown[][]) {
|
||||
const queue = [...selectResults];
|
||||
const insertValues = vi.fn(async () => undefined);
|
||||
const dbDrizzle = {
|
||||
select: vi.fn(() => {
|
||||
const rows = queue.shift() ?? [];
|
||||
const query = {
|
||||
limit: async () => rows,
|
||||
then: (resolve: (rows: unknown[]) => void, reject: (err: unknown) => void) =>
|
||||
Promise.resolve(rows).then(resolve, reject),
|
||||
};
|
||||
return { from: () => ({ where: () => query }) };
|
||||
}),
|
||||
delete: vi.fn(() => ({ where: async () => undefined })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
};
|
||||
vi.mocked(Database.getInstance).mockReturnValue({ dbDrizzle } as any);
|
||||
return { dbDrizzle, insertValues };
|
||||
}
|
||||
|
||||
const goalRow = { name: 'Marketing', organizationId: 3 };
|
||||
const inviterRow = { login: 'Alice' };
|
||||
const noCooldown: unknown[] = [];
|
||||
const underLimit = [{ count: 0 }];
|
||||
const orgRow = [{ slug: 'acme' }];
|
||||
|
||||
const inviteEvent: AppEvents['collaboration.userAdded'] = {
|
||||
goalId: 42,
|
||||
email: 'invitee@example.com',
|
||||
initiatorId: 7,
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
describe('InviteEmailDispatcher', () => {
|
||||
const dispatcher = new InviteEmailDispatcher();
|
||||
const onUserAdded = (data: typeof inviteEvent) => (dispatcher as any).onUserAdded(data);
|
||||
const sentHtml = () => (vi.mocked(Email.send).mock.calls[0][0] as any).attachment[0].data as string;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'true';
|
||||
process.env.SMTP_HOST = 'smtp.test';
|
||||
process.env.SMTP_FROM_EMAIL = 'noreply@test';
|
||||
process.env.APP_URL = 'http://localhost:3000';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not send when the flag is off', async () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not send when the flag is unset', async () => {
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
mockDb([]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a localized email with a project deep link and records the send', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, underLimit, orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.to).toBe('invitee@example.com');
|
||||
expect(message.from).toBe('noreply@test');
|
||||
expect(message.subject).toBe('Alice invited you to "Marketing" on TaskView');
|
||||
expect(message.text).toContain('http://localhost:3000/acme/42');
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain("You've been invited to a project");
|
||||
expect(html).toContain('Alice');
|
||||
expect(html).toContain('href="http://localhost:3000/acme/42"');
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith({
|
||||
initiatorId: 7,
|
||||
email: 'invitee@example.com',
|
||||
goalId: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the Russian template for the ru locale', async () => {
|
||||
mockDb([[{ name: 'Маркетинг', organizationId: null }], [{ login: 'Алиса' }], noCooldown, underLimit]);
|
||||
|
||||
await onUserAdded({ ...inviteEvent, locale: 'ru' });
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toBe('Алиса приглашает вас в проект «Маркетинг» в TaskView');
|
||||
expect(sentHtml()).toContain('Вас пригласили в проект');
|
||||
expect(sentHtml()).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('skips the send during the per-recipient cooldown', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], [{ id: 1 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the send when the hourly limit is reached', async () => {
|
||||
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 30 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects a custom INVITE_EMAIL_HOURLY_LIMIT', async () => {
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '2';
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 2 }]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
|
||||
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 1 }], orgRow]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('escapes user-controlled values in the html', async () => {
|
||||
mockDb([
|
||||
[{ name: '<img src=x onerror=alert(1)>', organizationId: null }],
|
||||
[{ login: 'Bob & "Co"' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).not.toContain('<img src=x');
|
||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(html).toContain('Bob & "Co"');
|
||||
});
|
||||
|
||||
it('is immune to $-patterns and placeholder strings in user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'Project $` name', organizationId: null }],
|
||||
[{ login: '{link}' }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const html = sentHtml();
|
||||
expect(html).toContain('Project $` name');
|
||||
expect(html).toContain('{link}');
|
||||
expect(html).toContain('href="http://localhost:3000"');
|
||||
});
|
||||
|
||||
it('truncates overlong user values', async () => {
|
||||
mockDb([
|
||||
[{ name: 'p'.repeat(200), organizationId: null }],
|
||||
[{ login: 'i'.repeat(200) }],
|
||||
noCooldown,
|
||||
underLimit,
|
||||
]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
|
||||
expect(message.subject).toContain(`"${'p'.repeat(80)}…"`);
|
||||
expect(message.text).toContain(`${'i'.repeat(80)}… has invited`);
|
||||
});
|
||||
|
||||
it('does not send when the goal no longer exists', async () => {
|
||||
mockDb([[]]);
|
||||
|
||||
await onUserAdded(inviteEvent);
|
||||
|
||||
expect(Email.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validateOnStartup rejects unrecognized values', () => {
|
||||
process.env.INVITE_EMAIL_ENABLED = 'ture';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED');
|
||||
|
||||
process.env.INVITE_EMAIL_ENABLED = 'false';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '0';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
|
||||
|
||||
process.env.INVITE_EMAIL_HOURLY_LIMIT = '10';
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
|
||||
delete process.env.INVITE_EMAIL_ENABLED;
|
||||
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
|
||||
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
|
||||
export const CollaborationArkTypeAddUser = type({
|
||||
goalId: 'number',
|
||||
@@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({
|
||||
});
|
||||
|
||||
export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer;
|
||||
|
||||
// created=false means the person was already a collaborator of the goal — no invitation happened
|
||||
export type CollaborationAddUserRepoResult = {
|
||||
user: CollaborationUsersSchemaTypeForSelect;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type CollaborationAddUserResult = {
|
||||
user: CollaborationUserWithRoles;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type InviteEmailLocale = 'en' | 'ru';
|
||||
|
||||
export type InviteEmailSendArgs = {
|
||||
email: string;
|
||||
inviterName: string;
|
||||
goalName: string;
|
||||
link: string;
|
||||
locale: InviteEmailLocale;
|
||||
};
|
||||
|
||||
export type InviteEmailRateLimitArgs = {
|
||||
initiatorId: number;
|
||||
email: string;
|
||||
goalId: number;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Project invitation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,50 @@
|
||||
export default `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="color-scheme" content="only" />
|
||||
<title>Приглашение в проект</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
|
||||
<tr>
|
||||
<td style="padding: 40px 32px 24px; text-align: center;">
|
||||
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 16px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 28px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 32px 28px;">
|
||||
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 32px; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
|
||||
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export default class GoalsManager {
|
||||
|
||||
await this.user.collaborationManager.repository.toggleUserRolesNew({
|
||||
goalId,
|
||||
userId: collabUser.id,
|
||||
userId: collabUser.user.id,
|
||||
roles: [role.id],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type } from 'arktype';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError } from '../../utils/api';
|
||||
import { $logger } from '../../modules/logget';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
import { decrypt } from '../../utils/crypto';
|
||||
import AuthController from '../auth/AuthController';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { verifyGiteaWebhookSignature, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
|
||||
|
||||
export default class IntegrationsController {
|
||||
@@ -47,23 +50,29 @@ export default class IntegrationsController {
|
||||
|
||||
initiateOAuth = async (req: Request, res: Response) => {
|
||||
try {
|
||||
integrationsDebugLog({ step: 'initiate:start', data: { provider: req.params.provider, projectId: req.query.projectId, hasToken: !!req.query.token } });
|
||||
const token = req.query.token as string;
|
||||
if (!token) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'token is required' });
|
||||
return res.status(401).send('token is required');
|
||||
}
|
||||
const userPayload = await AuthController.validateTokens(token);
|
||||
if (!userPayload?.userData?.id) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'invalid token' });
|
||||
return res.status(401).send('Invalid token');
|
||||
}
|
||||
|
||||
const provider = req.params.provider;
|
||||
const projectId = Number(req.query.projectId);
|
||||
if (!projectId || isNaN(projectId)) {
|
||||
integrationsDebugLog({ step: 'initiate:reject', data: 'projectId is required' });
|
||||
return res.status(400).send('projectId is required');
|
||||
}
|
||||
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
|
||||
integrationsDebugLog({ step: 'initiate:redirect', data: { userId: userPayload.userData.id, url } });
|
||||
return res.redirect(url);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({ step: 'initiate:error', data: { message: err?.message, stack: err?.stack } });
|
||||
logError(err);
|
||||
return res.status(500).send('Failed to initiate OAuth');
|
||||
}
|
||||
@@ -74,15 +83,36 @@ export default class IntegrationsController {
|
||||
const provider = req.params.provider;
|
||||
const code = req.query.code as string;
|
||||
const state = req.query.state as string;
|
||||
integrationsDebugLog({ step: 'callback:start', data: { provider, hasCode: !!code, hasState: !!state, queryKeys: Object.keys(req.query) } });
|
||||
|
||||
if (!code || !state) {
|
||||
integrationsDebugLog({ step: 'callback:reject', data: 'missing code or state' });
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
|
||||
const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
const { projectId, orgSlug } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
|
||||
integrationsDebugLog({ step: 'callback:success', data: { projectId, orgSlug } });
|
||||
return res.redirect(`${process.env.APP_URL}/${orgSlug}/${projectId}/integrations?oauth=success`);
|
||||
} catch (err: any) {
|
||||
integrationsDebugLog({
|
||||
step: 'callback:error',
|
||||
data: {
|
||||
message: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
});
|
||||
$logger.error(
|
||||
{
|
||||
provider: req.params.provider,
|
||||
errorMessage: err?.message,
|
||||
responseStatus: err?.response?.status,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack,
|
||||
},
|
||||
'[integrations] OAuth callback failed',
|
||||
);
|
||||
return res.redirect(`${process.env.APP_URL}?oauth=error`);
|
||||
}
|
||||
};
|
||||
@@ -210,6 +240,91 @@ export default class IntegrationsController {
|
||||
}
|
||||
};
|
||||
|
||||
handleGiteaWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const signature = req.headers['x-gitea-signature'] as string;
|
||||
const event = req.headers['x-gitea-event'] as string;
|
||||
|
||||
if (!signature) {
|
||||
return res.status(401).send('Missing signature');
|
||||
}
|
||||
|
||||
if (event !== 'issues') {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const repoFullName = req.body?.repository?.full_name;
|
||||
if (!repoFullName) {
|
||||
return res.status(400).send('Missing repository');
|
||||
}
|
||||
|
||||
const repo = new IntegrationsRepository();
|
||||
const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName);
|
||||
if (integrations.length === 0) {
|
||||
return res.status(404).send('Integration not found');
|
||||
}
|
||||
|
||||
// Verify signature with the first integration that has a webhook secret
|
||||
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
|
||||
if (!withSecret) {
|
||||
return res.status(401).send('No webhook secret');
|
||||
}
|
||||
const secret = decrypt(withSecret.webhookSecretEncrypted!);
|
||||
const rawBody = (req as any).rawBody as Buffer;
|
||||
if (!rawBody || !verifyGiteaWebhookSignature({ rawBody, signature, secret })) {
|
||||
return res.status(401).send('Invalid signature');
|
||||
}
|
||||
|
||||
const action = req.body.action as string;
|
||||
const issue = req.body.issue;
|
||||
if (!issue) {
|
||||
return res.status(200).send('OK');
|
||||
}
|
||||
|
||||
const issueNumber = issue.number as number;
|
||||
const issueTitle = issue.title as string;
|
||||
const issueBody = (issue.body as string) || null;
|
||||
|
||||
for (const integration of integrations) {
|
||||
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber);
|
||||
|
||||
if (action === 'opened') {
|
||||
if (!mapping) {
|
||||
await repo.createTaskAndMapping(
|
||||
integration.projectId,
|
||||
issueTitle,
|
||||
integration.id,
|
||||
issueNumber,
|
||||
'open',
|
||||
issueBody,
|
||||
false,
|
||||
`${GITEA_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (action === 'edited') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody);
|
||||
}
|
||||
} else if (action === 'closed') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, true);
|
||||
await repo.updateMappingState(mapping.id, 'closed');
|
||||
}
|
||||
} else if (action === 'reopened') {
|
||||
if (mapping) {
|
||||
await repo.updateTaskComplete(mapping.taskId, false);
|
||||
await repo.updateMappingState(mapping.id, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (err) {
|
||||
logError(err);
|
||||
return res.status(500).send('Webhook processing failed');
|
||||
}
|
||||
};
|
||||
|
||||
handleGitLabWebhook = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = req.headers['x-gitlab-token'] as string;
|
||||
|
||||
@@ -8,10 +8,12 @@ import { $logger } from '../../modules/logget';
|
||||
import { IntegrationsRepository } from './IntegrationsRepository';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
|
||||
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
|
||||
import { getGiteaOAuthUrl, exchangeGiteaCode, fetchGiteaRepos, fetchGiteaIssues, createGiteaWebhook, updateGiteaIssueState, refreshGiteaToken, verifyGiteaToken, GITEA_BASE_URL } from './providers/gitea.provider';
|
||||
import { integrationsDebugLog } from './debugLog';
|
||||
|
||||
export class IntegrationsManager {
|
||||
public readonly repository: IntegrationsRepository;
|
||||
@@ -55,13 +57,16 @@ export class IntegrationsManager {
|
||||
return getGitHubOAuthUrl(state);
|
||||
} else if (provider === 'gitlab') {
|
||||
return getGitLabOAuthUrl(state);
|
||||
} else if (provider === 'gitea') {
|
||||
return getGiteaOAuthUrl(state);
|
||||
}
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
|
||||
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; orgSlug: string }> {
|
||||
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
|
||||
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
|
||||
integrationsDebugLog({ step: 'callback:state-verified', data: { userId: payload.userId, projectId: payload.projectId, provider: payload.provider } });
|
||||
|
||||
if (payload.provider !== provider) {
|
||||
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
|
||||
@@ -69,6 +74,7 @@ export class IntegrationsManager {
|
||||
}
|
||||
|
||||
const userLogin = await this.repository.fetchUserLogin(payload.userId);
|
||||
integrationsDebugLog({ step: 'callback:user-fetched', data: { userLogin } });
|
||||
if (!userLogin) {
|
||||
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
|
||||
throw new Error('User not found');
|
||||
@@ -84,19 +90,35 @@ export class IntegrationsManager {
|
||||
const tokens = await exchangeGitLabCode(code);
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = encrypt(tokens.refreshToken);
|
||||
} else if (provider === 'gitea') {
|
||||
const tokens = await exchangeGiteaCode(code);
|
||||
integrationsDebugLog({ step: 'callback:token-exchanged', data: { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken } });
|
||||
accessTokenEncrypted = encrypt(tokens.accessToken);
|
||||
refreshTokenEncrypted = tokens.refreshToken ? encrypt(tokens.refreshToken) : null;
|
||||
} else {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:tokens-encrypted' });
|
||||
|
||||
await this.repository.createWithToken(
|
||||
provider as 'github' | 'gitlab',
|
||||
const created = await this.repository.createWithToken(
|
||||
provider as IntegrationProvider,
|
||||
payload.projectId,
|
||||
accessTokenEncrypted,
|
||||
refreshTokenEncrypted,
|
||||
);
|
||||
if (!created) {
|
||||
integrationsDebugLog({ step: 'callback:db-insert-failed' });
|
||||
throw new Error('Failed to store integration record');
|
||||
}
|
||||
integrationsDebugLog({ step: 'callback:integration-created', data: { integrationId: created.id } });
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, userLogin };
|
||||
// The app routes are /:orgSlug/:projectId/... — redirect must use the slug
|
||||
// of the project's organization, falling back to the user login for legacy
|
||||
// projects without an organization.
|
||||
const orgSlug = await this.repository.fetchProjectOrgSlug(payload.projectId) ?? userLogin;
|
||||
|
||||
$logger.debug({ provider, projectId: payload.projectId, orgSlug }, '[integrations] OAuth callback completed');
|
||||
return { projectId: payload.projectId, orgSlug };
|
||||
}
|
||||
|
||||
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
|
||||
@@ -126,6 +148,16 @@ export class IntegrationsManager {
|
||||
description: r.description,
|
||||
url: r.web_url,
|
||||
}));
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const repos = await fetchGiteaRepos(accessToken);
|
||||
return repos.map((r) => ({
|
||||
id: r.id,
|
||||
fullName: r.full_name,
|
||||
name: r.name,
|
||||
isPrivate: r.private,
|
||||
description: r.description,
|
||||
url: r.html_url,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -174,6 +206,14 @@ export class IntegrationsManager {
|
||||
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
|
||||
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
|
||||
webhookId = String(result.id);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const result = await createGiteaWebhook({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
webhookUrl,
|
||||
secret: webhookSecret,
|
||||
});
|
||||
webhookId = String(result.id);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -197,12 +237,11 @@ export class IntegrationsManager {
|
||||
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
|
||||
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
|
||||
|
||||
const issueUrlPrefix = this.getIssueUrlPrefix(integration);
|
||||
|
||||
// Backfill sourceUrl for existing tasks that don't have it yet
|
||||
if (existingMappings.length > 0) {
|
||||
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
|
||||
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
|
||||
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
|
||||
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
|
||||
await this.repository.backfillSourceUrls(integrationId, issueUrlPrefix).catch(logError);
|
||||
}
|
||||
|
||||
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
|
||||
@@ -263,6 +302,34 @@ export class IntegrationsManager {
|
||||
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
|
||||
});
|
||||
}
|
||||
} else if (integration.provider === 'gitea') {
|
||||
const issues = await fetchGiteaIssues({ accessToken, repoFullName: integration.repoFullName, since });
|
||||
for (const issue of issues) {
|
||||
const existing = mappingsByIssueNumber.get(issue.number);
|
||||
if (existing) {
|
||||
const isClosed = issue.state === 'closed';
|
||||
const targetState = isClosed ? 'closed' : 'open';
|
||||
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
|
||||
if (existing.issueState !== targetState) {
|
||||
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
|
||||
}
|
||||
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError);
|
||||
await this.repository.updateTaskSourceUrl(existing.taskId, `${issueUrlPrefix}${issue.number}`).catch(logError);
|
||||
continue;
|
||||
}
|
||||
const isClosed = issue.state === 'closed';
|
||||
newItems.push({
|
||||
goalId: integration.projectId,
|
||||
description: issue.title,
|
||||
integrationId,
|
||||
issueNumber: issue.number,
|
||||
issueState: isClosed ? 'closed' : 'open',
|
||||
note: issue.body ?? null,
|
||||
complete: isClosed,
|
||||
kanbanOrder: 0,
|
||||
sourceUrl: `${issueUrlPrefix}${issue.number}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Issues come newest-first from API.
|
||||
@@ -319,6 +386,13 @@ export class IntegrationsManager {
|
||||
mapping.issueNumber,
|
||||
complete ? 'close' : 'reopen',
|
||||
);
|
||||
} else if (integration.provider === 'gitea') {
|
||||
await updateGiteaIssueState({
|
||||
accessToken,
|
||||
repoFullName: integration.repoFullName,
|
||||
issueNumber: mapping.issueNumber,
|
||||
state: targetState,
|
||||
});
|
||||
}
|
||||
|
||||
await this.repository.updateMappingState(mapping.id, targetState);
|
||||
@@ -326,41 +400,56 @@ export class IntegrationsManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getIssueUrlPrefix(integration: IntegrationsSchemaTypeForSelect): string {
|
||||
if (integration.provider === 'gitlab') {
|
||||
return `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/`;
|
||||
}
|
||||
const baseUrl = integration.provider === 'gitea' ? GITEA_BASE_URL : GITHUB_BASE_URL;
|
||||
return `${baseUrl}/${integration.repoFullName}/issues/`;
|
||||
}
|
||||
|
||||
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
|
||||
if (!integration.accessTokenEncrypted) return null;
|
||||
|
||||
const accessToken = decrypt(integration.accessTokenEncrypted);
|
||||
|
||||
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
|
||||
const hasExpiringToken = integration.provider === 'gitlab' || integration.provider === 'gitea';
|
||||
if (!hasExpiringToken || !integration.refreshTokenEncrypted) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
// Try the current token, refresh on 401
|
||||
try {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (integration.provider === 'gitea') {
|
||||
await verifyGiteaToken(accessToken);
|
||||
} else {
|
||||
const axios = (await import('axios')).default;
|
||||
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
|
||||
await axios.get(`${gitlabApiUrl}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
return accessToken;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status !== 401) return accessToken;
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token expired (401), refreshing');
|
||||
}
|
||||
|
||||
// Token expired, refresh it
|
||||
try {
|
||||
const refreshToken = decrypt(integration.refreshTokenEncrypted);
|
||||
const tokens = await refreshGitLabToken(refreshToken);
|
||||
const tokens = integration.provider === 'gitea'
|
||||
? await refreshGiteaToken(refreshToken)
|
||||
: await refreshGitLabToken(refreshToken);
|
||||
await this.repository.updateTokens(
|
||||
integration.id,
|
||||
encrypt(tokens.accessToken),
|
||||
encrypt(tokens.refreshToken),
|
||||
);
|
||||
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully');
|
||||
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token refreshed successfully');
|
||||
return tokens.accessToken;
|
||||
} catch (err) {
|
||||
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
|
||||
$logger.error({ integrationId: integration.id, provider: integration.provider, err }, '[integrations] token refresh failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
|
||||
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { GoalsSchema, IntegrationsSchema, IntegrationTaskMapSchema, OrganizationsSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
|
||||
import { Database } from '../../modules/db';
|
||||
import { callWithCatch } from '../../utils/helpers';
|
||||
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
|
||||
import { TasksRepository } from '../tasks/TasksRepository';
|
||||
|
||||
export class IntegrationsRepository {
|
||||
@@ -62,7 +62,7 @@ export class IntegrationsRepository {
|
||||
}
|
||||
|
||||
async createWithToken(
|
||||
provider: 'github' | 'gitlab',
|
||||
provider: IntegrationProvider,
|
||||
projectId: number,
|
||||
accessTokenEncrypted: string,
|
||||
refreshTokenEncrypted?: string | null,
|
||||
@@ -322,6 +322,17 @@ export class IntegrationsRepository {
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async fetchProjectOrgSlug(projectId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
|
||||
.from(GoalsSchema)
|
||||
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
|
||||
.where(eq(GoalsSchema.id, projectId))
|
||||
);
|
||||
if (!result || result.length === 0) return null;
|
||||
return result[0].slug;
|
||||
}
|
||||
|
||||
async fetchUserLogin(userId: number): Promise<string | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
|
||||
|
||||
@@ -31,5 +31,6 @@ export default class IntegrationsRoutes implements Routable {
|
||||
this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback);
|
||||
this.router.post('/webhook/github', this.controller.handleGitHubWebhook);
|
||||
this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook);
|
||||
this.router.post('/webhook/gitea', this.controller.handleGiteaWebhook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appendFileSync } from 'fs';
|
||||
import type { IntegrationsDebugLogEntry } from './types';
|
||||
|
||||
// TEMPORARY debug instrumentation for the integrations OAuth flow.
|
||||
// Remove this file and all integrationsDebugLog() calls once the Gitea
|
||||
// connect issue is resolved.
|
||||
const LOG_PATH = '/private/tmp/claude-501/-Users-nikolaygiman-Programming-HandScreamInc-taskview/1d568266-6fbf-457c-83c2-5c5ca619edf1/scratchpad/integrations-debug.log';
|
||||
|
||||
export function integrationsDebugLog(entry: IntegrationsDebugLogEntry): void {
|
||||
try {
|
||||
appendFileSync(LOG_PATH, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
||||
} catch {
|
||||
// debug logging must never break the flow
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import axios from 'axios';
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { GiteaCreateWebhookArgs, GiteaFetchIssuesArgs, GiteaUpdateIssueStateArgs, GiteaVerifyWebhookSignatureArgs } from '../types';
|
||||
|
||||
export const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/+$/, '');
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL || `${GITEA_BASE_URL}/api/v1`;
|
||||
|
||||
export type GiteaRepo = {
|
||||
id: number;
|
||||
full_name: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
description: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export type GiteaIssue = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
export function getGiteaOAuthUrl(state: string): string {
|
||||
const clientId = process.env.GITEA_INTEGRATION_CLIENT_ID;
|
||||
const redirectUri = process.env.GITEA_INTEGRATION_CALLBACK_URL;
|
||||
if (!clientId || !redirectUri) {
|
||||
throw new Error('Gitea integration OAuth is not configured');
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `${GITEA_BASE_URL}/login/oauth/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function exchangeGiteaCode(code: string): Promise<{ accessToken: string; refreshToken: string | null }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token?: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: process.env.GITEA_INTEGRATION_CALLBACK_URL,
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to exchange Gitea code for token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshGiteaToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
`${GITEA_BASE_URL}/login/oauth/access_token`,
|
||||
{
|
||||
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
|
||||
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
},
|
||||
);
|
||||
if (!res.data.access_token) {
|
||||
throw new Error('Failed to refresh Gitea token');
|
||||
}
|
||||
return {
|
||||
accessToken: res.data.access_token,
|
||||
refreshToken: res.data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyGiteaToken(accessToken: string): Promise<void> {
|
||||
await axios.get(`${GITEA_API_URL}/user`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGiteaRepos(accessToken: string): Promise<GiteaRepo[]> {
|
||||
const repos: GiteaRepo[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaRepo[]>(`${GITEA_API_URL}/user/repos`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
params: {
|
||||
limit: perPage,
|
||||
page,
|
||||
},
|
||||
});
|
||||
repos.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return repos;
|
||||
}
|
||||
|
||||
export async function fetchGiteaIssues(args: GiteaFetchIssuesArgs): Promise<GiteaIssue[]> {
|
||||
const issues: GiteaIssue[] = [];
|
||||
let page = 1;
|
||||
const perPage = 50;
|
||||
|
||||
while (true) {
|
||||
const res = await axios.get<GiteaIssue[]>(`${GITEA_API_URL}/repos/${args.repoFullName}/issues`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
state: 'all',
|
||||
// Gitea returns pull requests from the issues endpoint too — this excludes them
|
||||
type: 'issues',
|
||||
limit: perPage,
|
||||
page,
|
||||
...(args.since ? { since: args.since } : {}),
|
||||
},
|
||||
});
|
||||
issues.push(...res.data);
|
||||
if (res.data.length < perPage) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function createGiteaWebhook(args: GiteaCreateWebhookArgs): Promise<{ id: number }> {
|
||||
const res = await axios.post<{ id: number }>(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/hooks`,
|
||||
{
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['issues'],
|
||||
config: {
|
||||
url: args.webhookUrl,
|
||||
content_type: 'json',
|
||||
secret: args.secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
return { id: res.data.id };
|
||||
}
|
||||
|
||||
export function verifyGiteaWebhookSignature(args: GiteaVerifyWebhookSignatureArgs): boolean {
|
||||
const expected = createHmac('sha256', args.secret).update(args.rawBody).digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(args.signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGiteaIssueState(args: GiteaUpdateIssueStateArgs): Promise<void> {
|
||||
await axios.patch(
|
||||
`${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`,
|
||||
{ state: args.state },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
export const IntegrationsArkTypeAdd = type({
|
||||
provider: "'github' | 'gitlab'",
|
||||
provider: "'github' | 'gitlab' | 'gitea'",
|
||||
repoFullName: 'string',
|
||||
projectId: 'number',
|
||||
});
|
||||
@@ -30,10 +30,43 @@ export const IntegrationsArkTypeSelectRepo = type({
|
||||
});
|
||||
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
|
||||
|
||||
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
userId: number;
|
||||
projectId: number;
|
||||
provider: 'github' | 'gitlab';
|
||||
provider: IntegrationProvider;
|
||||
};
|
||||
|
||||
export type GiteaFetchIssuesArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export type GiteaCreateWebhookArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
webhookUrl: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaVerifyWebhookSignatureArgs = {
|
||||
rawBody: Buffer;
|
||||
signature: string;
|
||||
secret: string;
|
||||
};
|
||||
|
||||
export type GiteaUpdateIssueStateArgs = {
|
||||
accessToken: string;
|
||||
repoFullName: string;
|
||||
issueNumber: number;
|
||||
state: 'open' | 'closed';
|
||||
};
|
||||
|
||||
export type IntegrationsDebugLogEntry = {
|
||||
step: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type RepoItemForClient = {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 ============
|
||||
|
||||
|
||||
@@ -21,16 +21,7 @@ export function isSafeUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
|
||||
// so a user-controlled value can't break out of a Telegram HTML message.
|
||||
export function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
export { escapeHtml } from '../../utils/helpers';
|
||||
|
||||
// Slack mrkdwn requires escaping these three in text (incl. link labels).
|
||||
export function escapeSlackText(text: string): string {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { ArkErrors } from 'arktype'
|
||||
import { PublicApiUrl } from '../../modules/public-url'
|
||||
import { OAuthManager } from './OAuthManager'
|
||||
import {
|
||||
type SendTokenErrorArgs,
|
||||
type SendTokenSuccessArgs,
|
||||
OAuthAuthorizeArkType,
|
||||
OAuthConsentArkType,
|
||||
OAuthRegisterArkType,
|
||||
OAuthRevokeArkType,
|
||||
OAuthTokenArkType,
|
||||
} from './types'
|
||||
import {
|
||||
buildRedirectUrl,
|
||||
isAcceptableRedirectUri,
|
||||
isDcrEnabled,
|
||||
} from './oauth.utils'
|
||||
|
||||
export class OAuthController {
|
||||
private get manager() { return OAuthManager.getInstance() }
|
||||
|
||||
authorize = async (req: Request, res: Response) => {
|
||||
const params = OAuthAuthorizeArkType(req.query)
|
||||
if (params instanceof ArkErrors) {
|
||||
return res.status(400).send(params.summary)
|
||||
}
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: params.client_id,
|
||||
redirectUri: params.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
const consentUrl = buildRedirectUrl({
|
||||
redirectUri: `${process.env.APP_URL}/oauth/consent`,
|
||||
params: {
|
||||
client_id: params.client_id,
|
||||
client_name: client.value.name,
|
||||
redirect_uri: params.redirect_uri,
|
||||
code_challenge: params.code_challenge,
|
||||
code_challenge_method: params.code_challenge_method,
|
||||
state: params.state,
|
||||
resource: params.resource,
|
||||
},
|
||||
})
|
||||
return res.redirect(consentUrl)
|
||||
}
|
||||
|
||||
consent = async (req: Request, res: Response) => {
|
||||
const data = OAuthConsentArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: data.client_id,
|
||||
redirectUri: data.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
// Empty means "do not narrow", matching how a tvk_ token with no
|
||||
// permissions selected behaves. The user's own RBAC is still the ceiling.
|
||||
const code = await this.manager.issueAuthCode({
|
||||
clientId: data.client_id,
|
||||
userId,
|
||||
redirectUri: data.redirect_uri,
|
||||
codeChallenge: data.code_challenge,
|
||||
codeChallengeMethod: data.code_challenge_method,
|
||||
allowedPermissions: data.allowedPermissions ?? [],
|
||||
allowedGoalIds: data.allowedGoalIds ?? [],
|
||||
resource: data.resource ?? null,
|
||||
})
|
||||
if (!code) return res.status(500).end()
|
||||
|
||||
return res.tvJson({
|
||||
redirectUrl: buildRedirectUrl({
|
||||
redirectUri: data.redirect_uri,
|
||||
params: { code, state: data.state },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
denyConsent = async (req: Request, res: Response) => {
|
||||
const data = OAuthConsentArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
|
||||
const client = await this.manager.validateRedirectUri({
|
||||
clientId: data.client_id,
|
||||
redirectUri: data.redirect_uri,
|
||||
})
|
||||
if (!client.ok) {
|
||||
return res.status(400).send(client.description)
|
||||
}
|
||||
|
||||
return res.tvJson({
|
||||
redirectUrl: buildRedirectUrl({
|
||||
redirectUri: data.redirect_uri,
|
||||
params: { error: 'access_denied', state: data.state },
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
token = async (req: Request, res: Response) => {
|
||||
const data = OAuthTokenArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: data.summary })
|
||||
}
|
||||
|
||||
const basic = this.readBasicAuth(req)
|
||||
const clientId = basic?.clientId ?? data.client_id
|
||||
const clientSecret = basic?.clientSecret ?? data.client_secret
|
||||
if (!clientId) {
|
||||
return this.sendTokenError({ res, status: 401, error: 'invalid_client', description: 'client_id is required' })
|
||||
}
|
||||
|
||||
const client = await this.manager.authenticateClient({ clientId, clientSecret })
|
||||
if (!client.ok) {
|
||||
return this.sendTokenError({ res, status: 401, error: client.error, description: client.description })
|
||||
}
|
||||
|
||||
const resource = data.resource ?? null
|
||||
|
||||
if (data.grant_type === 'authorization_code') {
|
||||
if (!data.code || !data.code_verifier || !data.redirect_uri) {
|
||||
return this.sendTokenError({
|
||||
res,
|
||||
status: 400,
|
||||
error: 'invalid_request',
|
||||
description: 'code, code_verifier and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
const result = await this.manager.exchangeCode({
|
||||
code: data.code,
|
||||
codeVerifier: data.code_verifier,
|
||||
clientId,
|
||||
redirectUri: data.redirect_uri,
|
||||
resource,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return this.sendTokenError({ res, status: 400, error: result.error, description: result.description })
|
||||
}
|
||||
return this.sendTokenSuccess({ res, body: result.value })
|
||||
}
|
||||
|
||||
if (!data.refresh_token) {
|
||||
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: 'refresh_token is required' })
|
||||
}
|
||||
const refreshed = await this.manager.refreshTokens({
|
||||
refreshToken: data.refresh_token,
|
||||
clientId,
|
||||
resource,
|
||||
})
|
||||
if (!refreshed.ok) {
|
||||
return this.sendTokenError({ res, status: 400, error: refreshed.error, description: refreshed.description })
|
||||
}
|
||||
return this.sendTokenSuccess({ res, body: refreshed.value })
|
||||
}
|
||||
|
||||
register = async (req: Request, res: Response) => {
|
||||
if (!isDcrEnabled()) {
|
||||
return res.status(403).json({
|
||||
error: 'access_denied',
|
||||
error_description: 'Dynamic client registration is disabled on this instance',
|
||||
})
|
||||
}
|
||||
|
||||
const data = OAuthRegisterArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).json({ error: 'invalid_client_metadata', error_description: data.summary })
|
||||
}
|
||||
|
||||
const rejected = data.redirect_uris.find((uri) => !isAcceptableRedirectUri(uri))
|
||||
if (rejected) {
|
||||
return res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `redirect_uri must be https, loopback http, or an app scheme, and carry no fragment: ${rejected}`,
|
||||
})
|
||||
}
|
||||
|
||||
const name = data.client_name?.trim() || 'Unnamed client'
|
||||
const isPublic = (data.token_endpoint_auth_method ?? 'none') === 'none'
|
||||
const result = await this.manager.registerClient({
|
||||
name,
|
||||
redirectUris: data.redirect_uris,
|
||||
isPublic,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return res.status(500).json({ error: result.error, error_description: result.description })
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
client_id: result.value.clientId,
|
||||
// RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a
|
||||
// secret is issued. 0 means it does not expire.
|
||||
...(result.value.clientSecret
|
||||
? { client_secret: result.value.clientSecret, client_secret_expires_at: 0 }
|
||||
: {}),
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_name: name,
|
||||
redirect_uris: data.redirect_uris,
|
||||
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
})
|
||||
}
|
||||
|
||||
revoke = async (req: Request, res: Response) => {
|
||||
const data = OAuthRevokeArkType(req.body)
|
||||
if (data instanceof ArkErrors) {
|
||||
return res.status(400).send(data.summary)
|
||||
}
|
||||
// RFC 7009: revocation always answers 200, even for an unknown token.
|
||||
await this.manager.revokeToken(data.token)
|
||||
return res.status(200).end()
|
||||
}
|
||||
|
||||
fetchConnectedApps = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const result = await this.manager.fetchConnectedApps(userId)
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
revokeConnectedApp = async (req: Request, res: Response) => {
|
||||
const userId = req.appUser.getUserData()?.id
|
||||
if (!userId) return res.status(401).end()
|
||||
|
||||
const grantId = Number(req.body?.grantId)
|
||||
if (!Number.isInteger(grantId) || grantId <= 0) {
|
||||
return res.status(400).send('grantId must be a positive integer')
|
||||
}
|
||||
|
||||
const result = await this.manager.revokeGrant({ grantId, userId })
|
||||
return res.tvJson(result)
|
||||
}
|
||||
|
||||
authorizationServerMetadata = async (req: Request, res: Response) => {
|
||||
const issuer = PublicApiUrl.base(req)
|
||||
return res.json({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/module/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/module/oauth/token`,
|
||||
revocation_endpoint: `${issuer}/module/oauth/revoke`,
|
||||
...(isDcrEnabled() ? { registration_endpoint: `${issuer}/module/oauth/register` } : {}),
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
|
||||
})
|
||||
}
|
||||
|
||||
protectedResourceMetadata = async (req: Request, res: Response) => {
|
||||
const issuer = PublicApiUrl.base(req)
|
||||
return res.json({
|
||||
resource: issuer,
|
||||
authorization_servers: [issuer],
|
||||
bearer_methods_supported: ['header'],
|
||||
})
|
||||
}
|
||||
|
||||
private readBasicAuth(req: Request): { clientId: string; clientSecret: string } | null {
|
||||
const header = req.headers.authorization
|
||||
if (!header?.toLowerCase().startsWith('basic ')) return null
|
||||
|
||||
const decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')
|
||||
const separator = decoded.indexOf(':')
|
||||
if (separator < 0) return null
|
||||
|
||||
return {
|
||||
clientId: decodeURIComponent(decoded.slice(0, separator)),
|
||||
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
private sendTokenSuccess(args: SendTokenSuccessArgs) {
|
||||
args.res.setHeader('Cache-Control', 'no-store')
|
||||
args.res.setHeader('Pragma', 'no-cache')
|
||||
return args.res.json(args.body)
|
||||
}
|
||||
|
||||
private sendTokenError(args: SendTokenErrorArgs) {
|
||||
args.res.setHeader('Cache-Control', 'no-store')
|
||||
return args.res.status(args.status).json({
|
||||
error: args.error,
|
||||
error_description: args.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { OAuthClientsSchemaTypeForSelect, OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { OAuthRepository } from './OAuthRepository'
|
||||
import {
|
||||
OAUTH_ACCESS_TOKEN_PREFIX,
|
||||
OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
OAUTH_CODE_TTL_SECONDS,
|
||||
OAUTH_REFRESH_TOKEN_TTL_SECONDS,
|
||||
type AuthenticateClientArgs,
|
||||
type ConnectedApp,
|
||||
type CreateAuthCodeArgs,
|
||||
type ExchangeCodeArgs,
|
||||
type IssueTokenPairArgs,
|
||||
type OAuthResult,
|
||||
type OAuthTokenResponse,
|
||||
type RefreshTokensArgs,
|
||||
type RegisterClientArgs,
|
||||
type RevokeOwnGrantArgs,
|
||||
type ValidateRedirectUriArgs,
|
||||
} from './types'
|
||||
import {
|
||||
matchesRegisteredRedirectUri,
|
||||
randomToken,
|
||||
resourceMatches,
|
||||
safeCompareHex,
|
||||
sha256Hex,
|
||||
verifyPkce,
|
||||
} from './oauth.utils'
|
||||
|
||||
export class OAuthManager {
|
||||
private static instance: OAuthManager | null = null
|
||||
|
||||
public readonly repository: OAuthRepository
|
||||
|
||||
constructor() {
|
||||
this.repository = new OAuthRepository()
|
||||
}
|
||||
|
||||
static getInstance(): OAuthManager {
|
||||
if (!OAuthManager.instance) OAuthManager.instance = new OAuthManager()
|
||||
return OAuthManager.instance
|
||||
}
|
||||
|
||||
async findClient(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
return this.repository.findClientByClientId(clientId)
|
||||
}
|
||||
|
||||
async validateRedirectUri(args: ValidateRedirectUriArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
|
||||
const client = await this.repository.findClientByClientId(args.clientId)
|
||||
if (!client) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
|
||||
}
|
||||
if (!matchesRegisteredRedirectUri({ candidate: args.redirectUri, registered: client.redirectUris })) {
|
||||
return { ok: false, error: 'invalid_request', description: 'redirect_uri does not match a registered URI' }
|
||||
}
|
||||
return { ok: true, value: client }
|
||||
}
|
||||
|
||||
async issueAuthCode(args: CreateAuthCodeArgs): Promise<string | null> {
|
||||
const code = randomToken()
|
||||
const created = await this.repository.createAuthCode({
|
||||
...args,
|
||||
codeHash: sha256Hex(code),
|
||||
expiresAt: new Date(Date.now() + OAUTH_CODE_TTL_SECONDS * 1000),
|
||||
})
|
||||
if (!created) return null
|
||||
|
||||
this.repository.deleteExpiredAuthCodes().catch(() => {})
|
||||
return code
|
||||
}
|
||||
|
||||
async exchangeCode(args: ExchangeCodeArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
const record = await this.repository.findAuthCodeByHash(sha256Hex(args.code))
|
||||
if (!record) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code is not valid' }
|
||||
}
|
||||
|
||||
// A code presented twice means it leaked. Kill the grant it already produced.
|
||||
if (record.usedAt) {
|
||||
if (record.grantId) await this.repository.revokeGrant({ grantId: record.grantId })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
|
||||
}
|
||||
if (record.expiresAt < new Date()) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has expired' }
|
||||
}
|
||||
if (record.clientId !== args.clientId) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code was issued to another client' }
|
||||
}
|
||||
if (record.redirectUri !== args.redirectUri) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'redirect_uri does not match the authorization request' }
|
||||
}
|
||||
if (!verifyPkce({ codeVerifier: args.codeVerifier, codeChallenge: record.codeChallenge })) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'code_verifier does not match code_challenge' }
|
||||
}
|
||||
if (!resourceMatches({ granted: record.resource, requested: args.resource })) {
|
||||
return { ok: false, error: 'invalid_target', description: 'resource does not match the authorization request' }
|
||||
}
|
||||
|
||||
const client = await this.repository.findClientByClientId(record.clientId)
|
||||
|
||||
const grant = await this.repository.createGrant({
|
||||
userId: record.userId,
|
||||
clientId: record.clientId,
|
||||
allowedPermissions: record.allowedPermissions,
|
||||
allowedGoalIds: record.allowedGoalIds,
|
||||
resource: record.resource,
|
||||
})
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not create the grant' }
|
||||
}
|
||||
|
||||
const consumed = await this.repository.consumeAuthCode({ id: record.id, grantId: grant.id })
|
||||
if (!consumed) {
|
||||
await this.repository.revokeGrant({ grantId: grant.id })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
|
||||
}
|
||||
|
||||
return this.issueTokenPair({ grant, clientName: client?.name ?? record.clientId })
|
||||
}
|
||||
|
||||
async refreshTokens(args: RefreshTokensArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
const presentedHash = sha256Hex(args.refreshToken)
|
||||
const grant = await this.repository.findGrantByRefreshHash(presentedHash)
|
||||
if (!grant) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token is not valid' }
|
||||
}
|
||||
if (grant.revokedAt) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
|
||||
}
|
||||
|
||||
// The previous token in the rotation chain showing up means it leaked.
|
||||
if (grant.refreshTokenPrevHash && safeCompareHex(grant.refreshTokenPrevHash, presentedHash)) {
|
||||
await this.repository.revokeGrant({ grantId: grant.id })
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token was reused; the authorization has been revoked' }
|
||||
}
|
||||
if (grant.refreshExpiresAt && grant.refreshExpiresAt < new Date()) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token has expired' }
|
||||
}
|
||||
if (grant.clientId !== args.clientId) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'Refresh token was issued to another client' }
|
||||
}
|
||||
if (!resourceMatches({ granted: grant.resource, requested: args.resource })) {
|
||||
return { ok: false, error: 'invalid_target', description: 'resource does not match the granted audience' }
|
||||
}
|
||||
|
||||
const client = await this.repository.findClientByClientId(grant.clientId)
|
||||
return this.issueTokenPair({ grant, clientName: client?.name ?? grant.clientId, prevRefreshHash: presentedHash })
|
||||
}
|
||||
|
||||
async registerClient(args: RegisterClientArgs): Promise<OAuthResult<{ clientId: string; clientSecret: string | null }>> {
|
||||
const clientId = randomBytes(16).toString('hex')
|
||||
const clientSecret = args.isPublic ? null : randomToken()
|
||||
|
||||
const client = await this.repository.createClient({
|
||||
clientId,
|
||||
clientSecretHash: clientSecret ? sha256Hex(clientSecret) : null,
|
||||
name: args.name.slice(0, 200),
|
||||
redirectUris: args.redirectUris,
|
||||
createdVia: 'dcr',
|
||||
})
|
||||
if (!client) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not register the client' }
|
||||
}
|
||||
return { ok: true, value: { clientId, clientSecret } }
|
||||
}
|
||||
|
||||
async authenticateClient(args: AuthenticateClientArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
|
||||
const client = await this.repository.findClientByClientId(args.clientId)
|
||||
if (!client) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
|
||||
}
|
||||
if (client.clientSecretHash) {
|
||||
if (!args.clientSecret || !safeCompareHex(client.clientSecretHash, sha256Hex(args.clientSecret))) {
|
||||
return { ok: false, error: 'invalid_client', description: 'Client authentication failed' }
|
||||
}
|
||||
}
|
||||
return { ok: true, value: client }
|
||||
}
|
||||
|
||||
async fetchConnectedApps(userId: number): Promise<ConnectedApp[]> {
|
||||
const grants = await this.repository.fetchGrantsByUserId(userId)
|
||||
if (!grants.length) return []
|
||||
|
||||
const clients = await this.repository.fetchClientsByClientIds(grants.map((grant) => grant.clientId))
|
||||
const nameByClientId = new Map(clients.map((client) => [client.clientId, client.name]))
|
||||
|
||||
return grants.map((grant) => ({
|
||||
grantId: grant.id,
|
||||
clientId: grant.clientId,
|
||||
clientName: nameByClientId.get(grant.clientId) ?? grant.clientId,
|
||||
allowedPermissions: grant.allowedPermissions,
|
||||
allowedGoalIds: grant.allowedGoalIds,
|
||||
createdAt: grant.createdAt,
|
||||
lastUsedAt: grant.lastUsedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
async revokeGrant(args: RevokeOwnGrantArgs): Promise<boolean> {
|
||||
return this.repository.revokeGrant(args)
|
||||
}
|
||||
|
||||
/** RFC 7009: accepts either an access token or a refresh token. */
|
||||
async revokeToken(token: string): Promise<void> {
|
||||
const hash = sha256Hex(token)
|
||||
|
||||
const grantId = await this.repository.findGrantIdByAccessTokenHash(hash)
|
||||
if (grantId) {
|
||||
await this.repository.revokeGrant({ grantId })
|
||||
return
|
||||
}
|
||||
|
||||
const grant = await this.repository.findGrantByRefreshHash(hash)
|
||||
if (grant) await this.repository.revokeGrant({ grantId: grant.id })
|
||||
}
|
||||
|
||||
private async issueTokenPair(args: IssueTokenPairArgs): Promise<OAuthResult<OAuthTokenResponse>> {
|
||||
// Copied straight from the grant: these are the RBAC keys the user
|
||||
// ticked. An empty list means "do not narrow anything", the same thing
|
||||
// it means for a manually issued tvk_ token.
|
||||
const allowedPermissions = args.grant.allowedPermissions
|
||||
const accessToken = OAUTH_ACCESS_TOKEN_PREFIX + randomToken()
|
||||
const refreshToken = randomToken()
|
||||
const now = Date.now()
|
||||
|
||||
const rotated = await this.repository.rotateRefreshToken({
|
||||
grantId: args.grant.id,
|
||||
refreshTokenHash: sha256Hex(refreshToken),
|
||||
prevHash: args.prevRefreshHash ?? null,
|
||||
refreshExpiresAt: new Date(now + OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000),
|
||||
presentedHash: args.prevRefreshHash ?? null,
|
||||
})
|
||||
if (!rotated) {
|
||||
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
|
||||
}
|
||||
|
||||
const created = await this.repository.createAccessToken({
|
||||
userId: args.grant.userId,
|
||||
name: args.clientName.slice(0, 100),
|
||||
tokenHash: sha256Hex(accessToken),
|
||||
allowedPermissions,
|
||||
allowedGoalIds: args.grant.allowedGoalIds,
|
||||
grantId: args.grant.id,
|
||||
expiresAt: new Date(now + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000),
|
||||
})
|
||||
if (!created) {
|
||||
return { ok: false, error: 'server_error', description: 'Could not issue the access token' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: OAUTH_ACCESS_TOKEN_TTL_SECONDS,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { and, eq, isNull, lt, or } from 'drizzle-orm'
|
||||
import {
|
||||
ApiTokensSchema,
|
||||
OAuthAuthCodesSchema,
|
||||
OAuthClientsSchema,
|
||||
OAuthGrantsSchema,
|
||||
type OAuthAuthCodesSchemaTypeForSelect,
|
||||
type OAuthClientsSchemaTypeForSelect,
|
||||
type OAuthGrantsSchemaTypeForInsert,
|
||||
type OAuthGrantsSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas'
|
||||
import { Database } from '../../modules/db'
|
||||
import { callWithCatch } from '../../utils/helpers'
|
||||
import type {
|
||||
ConsumeAuthCodeArgs,
|
||||
CreateAccessTokenArgs,
|
||||
CreateAuthCodeArgs,
|
||||
CreateOAuthClientArgs,
|
||||
RevokeGrantArgs,
|
||||
RotateRefreshTokenArgs,
|
||||
} from './types'
|
||||
|
||||
export class OAuthRepository {
|
||||
private readonly db: Database
|
||||
|
||||
constructor() {
|
||||
this.db = Database.getInstance()
|
||||
}
|
||||
|
||||
async createClient(data: CreateOAuthClientArgs): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthClientsSchema).values(data).returning(),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async findClientByClientId(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthClientsSchema).where(eq(OAuthClientsSchema.clientId, clientId)),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async createAuthCode(data: CreateAuthCodeArgs & { codeHash: string; expiresAt: Date }): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthAuthCodesSchema).values({
|
||||
codeHash: data.codeHash,
|
||||
clientId: data.clientId,
|
||||
userId: data.userId,
|
||||
redirectUri: data.redirectUri,
|
||||
codeChallenge: data.codeChallenge,
|
||||
codeChallengeMethod: data.codeChallengeMethod,
|
||||
allowedPermissions: data.allowedPermissions,
|
||||
allowedGoalIds: data.allowedGoalIds,
|
||||
resource: data.resource,
|
||||
expiresAt: data.expiresAt,
|
||||
}).returning(),
|
||||
)
|
||||
return !!result?.length
|
||||
}
|
||||
|
||||
async findAuthCodeByHash(codeHash: string): Promise<OAuthAuthCodesSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthAuthCodesSchema).where(eq(OAuthAuthCodesSchema.codeHash, codeHash)),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-use redemption. The UPDATE only matches while used_at is still NULL,
|
||||
* so two concurrent exchanges of the same code cannot both win.
|
||||
*/
|
||||
async consumeAuthCode(args: ConsumeAuthCodeArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthAuthCodesSchema)
|
||||
.set({ usedAt: new Date(), grantId: args.grantId })
|
||||
.where(and(eq(OAuthAuthCodesSchema.id, args.id), isNull(OAuthAuthCodesSchema.usedAt))),
|
||||
)
|
||||
return !!result?.rowCount
|
||||
}
|
||||
|
||||
async deleteExpiredAuthCodes(): Promise<void> {
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(OAuthAuthCodesSchema).where(lt(OAuthAuthCodesSchema.expiresAt, new Date())),
|
||||
)
|
||||
}
|
||||
|
||||
async createGrant(data: OAuthGrantsSchemaTypeForInsert): Promise<OAuthGrantsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(OAuthGrantsSchema).values(data).returning(),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
/** Matches the current refresh token or the previous one, so replay is detectable. */
|
||||
async findGrantByRefreshHash(refreshTokenHash: string): Promise<OAuthGrantsSchemaTypeForSelect | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
|
||||
or(
|
||||
eq(OAuthGrantsSchema.refreshTokenHash, refreshTokenHash),
|
||||
eq(OAuthGrantsSchema.refreshTokenPrevHash, refreshTokenHash),
|
||||
),
|
||||
),
|
||||
)
|
||||
return result?.[0] ?? null
|
||||
}
|
||||
|
||||
async rotateRefreshToken(args: RotateRefreshTokenArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthGrantsSchema)
|
||||
.set({
|
||||
refreshTokenHash: args.refreshTokenHash,
|
||||
refreshTokenPrevHash: args.prevHash,
|
||||
refreshExpiresAt: args.refreshExpiresAt,
|
||||
lastUsedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(OAuthGrantsSchema.id, args.grantId),
|
||||
isNull(OAuthGrantsSchema.revokedAt),
|
||||
// The presented token must still be the current one. Without this,
|
||||
// two concurrent refreshes both succeed and the second overwrites
|
||||
// the first, silently orphaning the refresh token it just handed out.
|
||||
args.presentedHash
|
||||
? eq(OAuthGrantsSchema.refreshTokenHash, args.presentedHash)
|
||||
: isNull(OAuthGrantsSchema.refreshTokenHash),
|
||||
)),
|
||||
)
|
||||
return !!result?.rowCount
|
||||
}
|
||||
|
||||
async fetchGrantsByUserId(userId: number): Promise<OAuthGrantsSchemaTypeForSelect[]> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
|
||||
and(eq(OAuthGrantsSchema.userId, userId), isNull(OAuthGrantsSchema.revokedAt)),
|
||||
),
|
||||
)
|
||||
return result ?? []
|
||||
}
|
||||
|
||||
async fetchClientsByClientIds(clientIds: string[]): Promise<OAuthClientsSchemaTypeForSelect[]> {
|
||||
if (!clientIds.length) return []
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select().from(OAuthClientsSchema),
|
||||
)
|
||||
return (result ?? []).filter((client) => clientIds.includes(client.clientId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoking a grant also deletes its live access tokens — that is the whole
|
||||
* point of storing them as opaque rows instead of self-contained JWTs.
|
||||
*/
|
||||
async revokeGrant(args: RevokeGrantArgs): Promise<boolean> {
|
||||
const where = args.userId === undefined
|
||||
? eq(OAuthGrantsSchema.id, args.grantId)
|
||||
: and(eq(OAuthGrantsSchema.id, args.grantId), eq(OAuthGrantsSchema.userId, args.userId))
|
||||
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.update(OAuthGrantsSchema)
|
||||
.set({ revokedAt: new Date(), refreshTokenHash: null, refreshTokenPrevHash: null })
|
||||
.where(where),
|
||||
)
|
||||
if (!result?.rowCount) return false
|
||||
|
||||
await callWithCatch(() =>
|
||||
this.db.dbDrizzle.delete(ApiTokensSchema).where(eq(ApiTokensSchema.grantId, args.grantId)),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async createAccessToken(data: CreateAccessTokenArgs): Promise<boolean> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning(),
|
||||
)
|
||||
return !!result?.length
|
||||
}
|
||||
|
||||
async findGrantIdByAccessTokenHash(tokenHash: string): Promise<number | null> {
|
||||
const result = await callWithCatch(() =>
|
||||
this.db.dbDrizzle.select({ grantId: ApiTokensSchema.grantId })
|
||||
.from(ApiTokensSchema)
|
||||
.where(eq(ApiTokensSchema.tokenHash, tokenHash)),
|
||||
)
|
||||
return result?.[0]?.grantId ?? null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
|
||||
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
|
||||
import { OAuthController } from './OAuthController'
|
||||
|
||||
export default class OAuthRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: OAuthController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new OAuthController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/authorize', this.controller.authorize)
|
||||
this.router.post('/token', this.controller.token)
|
||||
this.router.post('/revoke', this.controller.revoke)
|
||||
this.router.post('/register', this.controller.register)
|
||||
|
||||
// Consent is the one place a real human decides. It must be a browser
|
||||
// session, never an API token acting on the user's behalf.
|
||||
this.router.post('/consent', [IsLoggedIn, RejectApiTokenAuth], this.controller.consent)
|
||||
this.router.post('/consent/deny', [IsLoggedIn, RejectApiTokenAuth], this.controller.denyConsent)
|
||||
|
||||
this.router.get('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchConnectedApps)
|
||||
this.router.delete('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.revokeConnectedApp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express'
|
||||
import type { Routable } from '../../types/routable.type'
|
||||
import { OAuthController } from './OAuthController'
|
||||
|
||||
/**
|
||||
* RFC 8414 / RFC 9728 discovery. These must sit at the origin root, not under
|
||||
* /module, because that is where clients look before they have any credentials.
|
||||
*/
|
||||
export default class OAuthWellKnownRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
private readonly controller: OAuthController
|
||||
|
||||
constructor() {
|
||||
this.router = Router()
|
||||
this.controller = new OAuthController()
|
||||
this.initRoutes()
|
||||
}
|
||||
|
||||
getRouter() {
|
||||
return this.router
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.get('/oauth-authorization-server', this.controller.authorizationServerMetadata)
|
||||
this.router.get('/oauth-protected-resource', this.controller.protectedResourceMetadata)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios';
|
||||
import type http from 'http';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../../App';
|
||||
|
||||
const port = 1812;
|
||||
const url = `http://localhost:${port}`;
|
||||
|
||||
let server: http.Server;
|
||||
const api = axios.create({ baseURL: url, validateStatus: () => true });
|
||||
|
||||
/**
|
||||
* OAUTH_DYNAMIC_REGISTRATION=false is the lever a self-hosted operator pulls to
|
||||
* keep the client registry closed. It has to do two things: refuse registration,
|
||||
* and stop advertising the endpoint — a client that reads the metadata should
|
||||
* never attempt a registration this instance will reject.
|
||||
*/
|
||||
describe('OAuth with dynamic client registration disabled', () => {
|
||||
vi.mock('emailjs', () => ({
|
||||
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OAUTH_DYNAMIC_REGISTRATION = 'false';
|
||||
server = new App(port).listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
delete process.env.OAUTH_DYNAMIC_REGISTRATION;
|
||||
});
|
||||
|
||||
it('refuses to register a client', async () => {
|
||||
const response = await api.post('/module/oauth/register', {
|
||||
client_name: 'Should be refused',
|
||||
redirect_uris: ['https://client.test/cb'],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.data.error).toBe('access_denied');
|
||||
});
|
||||
|
||||
it('stops advertising the registration endpoint in the metadata', async () => {
|
||||
const response = await api.get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.registration_endpoint).toBeUndefined();
|
||||
// The rest of the flow stays available for manually seeded clients.
|
||||
expect(response.data.authorization_endpoint).toContain('/module/oauth/authorize');
|
||||
expect(response.data.token_endpoint).toContain('/module/oauth/token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
const repositoryMock = {
|
||||
findClientByClientId: vi.fn(),
|
||||
findAuthCodeByHash: vi.fn(),
|
||||
consumeAuthCode: vi.fn(),
|
||||
createGrant: vi.fn(),
|
||||
createAccessToken: vi.fn(),
|
||||
rotateRefreshToken: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
findGrantByRefreshHash: vi.fn(),
|
||||
findGrantIdByAccessTokenHash: vi.fn(),
|
||||
createAuthCode: vi.fn(),
|
||||
deleteExpiredAuthCodes: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../OAuthRepository', () => ({
|
||||
OAuthRepository: vi.fn(() => repositoryMock),
|
||||
}))
|
||||
|
||||
const { OAuthManager } = await import('../OAuthManager')
|
||||
|
||||
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
const VERIFIER = randomBytes(40).toString('base64url')
|
||||
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url')
|
||||
|
||||
const validCode = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
codeHash: sha256('the-code'),
|
||||
clientId: 'client-a',
|
||||
userId: 7,
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
codeChallenge: CHALLENGE,
|
||||
codeChallengeMethod: 'S256',
|
||||
allowedPermissions: ['goal_can_watch_content'],
|
||||
allowedGoalIds: [],
|
||||
resource: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
usedAt: null,
|
||||
grantId: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const validGrant = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 42,
|
||||
userId: 7,
|
||||
clientId: 'client-a',
|
||||
allowedPermissions: ['goal_can_watch_content'],
|
||||
allowedGoalIds: [],
|
||||
resource: null,
|
||||
refreshTokenHash: sha256('current-refresh'),
|
||||
refreshTokenPrevHash: null,
|
||||
refreshExpiresAt: new Date(Date.now() + 86_400_000),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const exchangeArgs = (overrides: Record<string, unknown> = {}) => ({
|
||||
code: 'the-code',
|
||||
codeVerifier: VERIFIER,
|
||||
clientId: 'client-a',
|
||||
redirectUri: 'https://app.example.com/cb',
|
||||
resource: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OAuthManager.exchangeCode', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
|
||||
repositoryMock.createGrant.mockResolvedValue(validGrant())
|
||||
repositoryMock.consumeAuthCode.mockResolvedValue(true)
|
||||
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
|
||||
repositoryMock.createAccessToken.mockResolvedValue(true)
|
||||
repositoryMock.revokeGrant.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('issues a tvo_ access token and a refresh token on a valid exchange', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.access_token.startsWith('tvo_')).toBe(true)
|
||||
expect(result.value.refresh_token).toBeTruthy()
|
||||
expect(result.value.token_type).toBe('Bearer')
|
||||
})
|
||||
|
||||
it('stores only the hash of the access token, never the token itself', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.tokenHash).toBe(sha256(result.value.access_token))
|
||||
expect(JSON.stringify(stored)).not.toContain(result.value.access_token)
|
||||
})
|
||||
|
||||
it('rejects a wrong code_verifier', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ codeVerifier: randomBytes(40).toString('base64url') }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.createGrant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a redirect_uri that differs from the authorization request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ redirectUri: 'https://app.example.com/other' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('rejects a code presented by a different client', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ clientId: 'client-b' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('rejects an expired code', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ expiresAt: new Date(Date.now() - 1000) }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('revokes the original grant when a used code is replayed', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ usedAt: new Date(), grantId: 42 }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('rolls back the grant when the code was consumed by a concurrent request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
|
||||
repositoryMock.consumeAuthCode.mockResolvedValue(false)
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('rejects a resource that does not match the authorization request', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ resource: 'https://mcp.example.com' }))
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs({ resource: 'https://other.example.com' }))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_target' })
|
||||
})
|
||||
|
||||
it('rejects an unknown code', async () => {
|
||||
repositoryMock.findAuthCodeByHash.mockResolvedValue(null)
|
||||
|
||||
const result = await manager.exchangeCode(exchangeArgs())
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthManager.refreshTokens', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
|
||||
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
|
||||
repositoryMock.createAccessToken.mockResolvedValue(true)
|
||||
repositoryMock.revokeGrant.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('rotates the refresh token and keeps the presented one as the previous hash', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.refresh_token).not.toBe('current-refresh')
|
||||
|
||||
const rotation = repositoryMock.rotateRefreshToken.mock.calls[0][0]
|
||||
expect(rotation.prevHash).toBe(sha256('current-refresh'))
|
||||
expect(rotation.refreshTokenHash).toBe(sha256(result.value.refresh_token))
|
||||
})
|
||||
|
||||
it('revokes the whole grant when a superseded refresh token is replayed', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
refreshTokenHash: sha256('rotated-refresh'),
|
||||
refreshTokenPrevHash: sha256('leaked-refresh'),
|
||||
}))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'leaked-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
|
||||
})
|
||||
|
||||
it('refuses a revoked grant', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ revokedAt: new Date() }))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('refuses an expired refresh token', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
refreshExpiresAt: new Date(Date.now() - 1000),
|
||||
}))
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-a',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('refuses a refresh token presented by another client', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
|
||||
|
||||
const result = await manager.refreshTokens({
|
||||
refreshToken: 'current-refresh',
|
||||
clientId: 'client-b',
|
||||
resource: null,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
|
||||
})
|
||||
|
||||
it('carries the consented permission keys onto the reissued token', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
|
||||
allowedPermissions: ['timetracking_can_view', 'task_can_edit_status'],
|
||||
}))
|
||||
|
||||
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
|
||||
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.allowedPermissions).toEqual(['timetracking_can_view', 'task_can_edit_status'])
|
||||
})
|
||||
|
||||
it('keeps an unrestricted grant unrestricted, which is what "all permissions" means', async () => {
|
||||
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ allowedPermissions: [] }))
|
||||
|
||||
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
|
||||
|
||||
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
|
||||
expect(stored.allowedPermissions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuthManager.authenticateClient', () => {
|
||||
let manager: InstanceType<typeof OAuthManager>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
manager = new OAuthManager()
|
||||
})
|
||||
|
||||
it('accepts a public client with no secret', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'pub', clientSecretHash: null })
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'pub' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a confidential client presenting the wrong secret', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({
|
||||
clientId: 'conf',
|
||||
clientSecretHash: sha256('right'),
|
||||
})
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'conf', clientSecret: 'wrong' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
|
||||
it('rejects a confidential client presenting no secret at all', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue({
|
||||
clientId: 'conf',
|
||||
clientSecretHash: sha256('right'),
|
||||
})
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'conf' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
|
||||
it('rejects an unknown client', async () => {
|
||||
repositoryMock.findClientByClientId.mockResolvedValue(null)
|
||||
|
||||
const result = await manager.authenticateClient({ clientId: 'nope' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import {
|
||||
buildRedirectUrl,
|
||||
isAcceptableRedirectUri,
|
||||
matchesRegisteredRedirectUri,
|
||||
resourceMatches,
|
||||
verifyPkce,
|
||||
} from '../oauth.utils'
|
||||
|
||||
const challengeFor = (verifier: string) =>
|
||||
createHash('sha256').update(verifier).digest('base64url')
|
||||
|
||||
describe('verifyPkce', () => {
|
||||
const verifier = randomBytes(40).toString('base64url')
|
||||
|
||||
it('accepts the verifier that produced the challenge', () => {
|
||||
expect(verifyPkce({ codeVerifier: verifier, codeChallenge: challengeFor(verifier) })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different verifier', () => {
|
||||
const other = randomBytes(40).toString('base64url')
|
||||
expect(verifyPkce({ codeVerifier: other, codeChallenge: challengeFor(verifier) })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a verifier shorter than the RFC 7636 minimum', () => {
|
||||
const short = 'abc'
|
||||
expect(verifyPkce({ codeVerifier: short, codeChallenge: challengeFor(short) })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a verifier longer than the RFC 7636 maximum', () => {
|
||||
const long = 'a'.repeat(129)
|
||||
expect(verifyPkce({ codeVerifier: long, codeChallenge: challengeFor(long) })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesRegisteredRedirectUri', () => {
|
||||
it('accepts an exact match', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com/callback',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a different path on the same host', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com/evil',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an attacker host that merely prefixes the registered one', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://chat.example.com.evil.test/callback',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('allows any loopback port for the same path (RFC 8252)', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'http://127.0.0.1:55123/callback',
|
||||
registered: ['http://127.0.0.1:8080/callback'],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('does not extend the loopback port carve-out to other hosts', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'https://example.com:9999/callback',
|
||||
registered: ['https://example.com:443/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a loopback candidate whose path differs', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'http://127.0.0.1:55123/other',
|
||||
registered: ['http://127.0.0.1:8080/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unparseable candidate', () => {
|
||||
expect(matchesRegisteredRedirectUri({
|
||||
candidate: 'not a url',
|
||||
registered: ['https://chat.example.com/callback'],
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAcceptableRedirectUri', () => {
|
||||
it('accepts https', () => {
|
||||
expect(isAcceptableRedirectUri('https://example.com/cb')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts loopback http', () => {
|
||||
expect(isAcceptableRedirectUri('http://127.0.0.1:1234/cb')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a URI carrying a fragment', () => {
|
||||
expect(isAcceptableRedirectUri('https://example.com/cb#token')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a private-use scheme, which is how native apps come back (RFC 8252)', () => {
|
||||
expect(isAcceptableRedirectUri('com.example.app://oauth/callback')).toBe(true)
|
||||
expect(isAcceptableRedirectUri('myapp://callback')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects schemes where redirecting would execute something', () => {
|
||||
expect(isAcceptableRedirectUri('javascript:alert(1)')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('data:text/html,<script>alert(1)</script>')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('vbscript:msgbox(1)')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('file:///etc/passwd')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('blob:https://example.com/x')).toBe(false)
|
||||
expect(isAcceptableRedirectUri('about:blank')).toBe(false)
|
||||
})
|
||||
|
||||
it('still refuses plaintext http off loopback', () => {
|
||||
expect(isAcceptableRedirectUri('http://evil.example.com/cb')).toBe(false)
|
||||
})
|
||||
|
||||
it('still refuses a fragment on any scheme', () => {
|
||||
expect(isAcceptableRedirectUri('com.example.app://cb#token')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a string that is not a URL at all', () => {
|
||||
expect(isAcceptableRedirectUri('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRedirectUrl', () => {
|
||||
it('appends parameters and skips undefined ones', () => {
|
||||
const url = buildRedirectUrl({
|
||||
redirectUri: 'https://example.com/cb?existing=1',
|
||||
params: { code: 'abc', state: undefined },
|
||||
})
|
||||
expect(url).toBe('https://example.com/cb?existing=1&code=abc')
|
||||
})
|
||||
|
||||
it('encodes parameter values', () => {
|
||||
const url = buildRedirectUrl({
|
||||
redirectUri: 'https://example.com/cb',
|
||||
params: { state: 'a b&c' },
|
||||
})
|
||||
expect(url).toContain('state=a+b%26c')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resourceMatches', () => {
|
||||
it('ignores a trailing slash', () => {
|
||||
expect(resourceMatches({ granted: 'https://mcp.example.com/', requested: 'https://mcp.example.com' })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a token replayed against another resource', () => {
|
||||
expect(resourceMatches({ granted: 'https://mcp.example.com', requested: 'https://other.example.com' })).toBe(false)
|
||||
})
|
||||
|
||||
it('is permissive when the grant carries no audience', () => {
|
||||
expect(resourceMatches({ granted: null, requested: 'https://mcp.example.com' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
|
||||
import type {
|
||||
BuildRedirectUrlArgs,
|
||||
MatchRedirectUriArgs,
|
||||
ResourceMatchArgs,
|
||||
VerifyPkceArgs,
|
||||
} from './types'
|
||||
|
||||
export function isDcrEnabled(): boolean {
|
||||
const raw = process.env.OAUTH_DYNAMIC_REGISTRATION
|
||||
if (raw === undefined || raw.trim() === '') return true
|
||||
return raw.trim().toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
export function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export function randomToken(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export function safeCompareHex(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
|
||||
}
|
||||
|
||||
export function verifyPkce(args: VerifyPkceArgs): boolean {
|
||||
if (args.codeVerifier.length < 43 || args.codeVerifier.length > 128) return false
|
||||
const digest = createHash('sha256').update(args.codeVerifier).digest('base64url')
|
||||
return safeCompareHex(digest, args.codeChallenge)
|
||||
}
|
||||
|
||||
function isLoopbackUrl(url: URL): boolean {
|
||||
return url.hostname === '127.0.0.1'
|
||||
|| url.hostname === '::1'
|
||||
|| url.hostname === '[::1]'
|
||||
|| url.hostname === 'localhost'
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact match, with one carve-out: RFC 8252 lets a native client bind an
|
||||
* arbitrary loopback port, so the port is ignored for 127.0.0.1 / ::1 only.
|
||||
* Everything else must match the registered string exactly — this is the guard
|
||||
* against turning /authorize into an open redirect.
|
||||
*/
|
||||
export function matchesRegisteredRedirectUri(args: MatchRedirectUriArgs): boolean {
|
||||
if (args.registered.includes(args.candidate)) return true
|
||||
|
||||
let candidateUrl: URL
|
||||
try {
|
||||
candidateUrl = new URL(args.candidate)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!isLoopbackUrl(candidateUrl)) return false
|
||||
|
||||
return args.registered.some((registered) => {
|
||||
try {
|
||||
const registeredUrl = new URL(registered)
|
||||
return (
|
||||
isLoopbackUrl(registeredUrl)
|
||||
&& registeredUrl.protocol === candidateUrl.protocol
|
||||
&& registeredUrl.hostname === candidateUrl.hostname
|
||||
&& registeredUrl.pathname === candidateUrl.pathname
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Schemes where "redirecting" means executing something rather than handing
|
||||
* control to an application. Everything else is allowed: RFC 8252 §7.1 has
|
||||
* native apps return through a private-use scheme (com.example.app://), and
|
||||
* refusing those locks every mobile client out of the flow. Interception of a
|
||||
* private-use scheme by another app on the device is what PKCE — mandatory here,
|
||||
* S256 only — exists to make useless.
|
||||
*/
|
||||
const DANGEROUS_REDIRECT_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:']
|
||||
|
||||
export function isAcceptableRedirectUri(raw: string): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.hash) return false
|
||||
if (DANGEROUS_REDIRECT_SCHEMES.includes(parsed.protocol)) return false
|
||||
if (parsed.protocol === 'https:') return true
|
||||
// http stays loopback-only. There is deliberately no NODE_ENV escape hatch:
|
||||
// an install running without NODE_ENV=production would otherwise let any
|
||||
// client register a plaintext redirect to a host it does not control.
|
||||
if (parsed.protocol === 'http:') return isLoopbackUrl(parsed)
|
||||
return true
|
||||
}
|
||||
|
||||
export function buildRedirectUrl(args: BuildRedirectUrlArgs): string {
|
||||
const url = new URL(args.redirectUri)
|
||||
for (const [key, value] of Object.entries(args.params)) {
|
||||
if (value !== undefined) url.searchParams.set(key, value)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding: a token minted for one MCP resource must not be
|
||||
* replayable against another. Compared on origin + path, ignoring trailing slash.
|
||||
*/
|
||||
export function resourceMatches(args: ResourceMatchArgs): boolean {
|
||||
if (!args.granted || !args.requested) return true
|
||||
const normalize = (value: string) => value.replace(/\/+$/, '').toLowerCase()
|
||||
return normalize(args.granted) === normalize(args.requested)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Response } from 'express'
|
||||
import type { OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
|
||||
import { type } from 'arktype'
|
||||
|
||||
export const OAUTH_ACCESS_TOKEN_PREFIX = 'tvo_'
|
||||
export const OAUTH_CODE_TTL_SECONDS = 60
|
||||
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60
|
||||
export const OAUTH_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30
|
||||
|
||||
export const OAuthAuthorizeArkType = type({
|
||||
response_type: "'code'",
|
||||
client_id: 'string > 0',
|
||||
redirect_uri: 'string > 0',
|
||||
code_challenge: 'string > 0',
|
||||
code_challenge_method: "'S256'",
|
||||
'state?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthAuthorizeArgs = typeof OAuthAuthorizeArkType.infer
|
||||
|
||||
export const OAuthConsentArkType = type({
|
||||
client_id: 'string > 0',
|
||||
redirect_uri: 'string > 0',
|
||||
code_challenge: 'string > 0',
|
||||
code_challenge_method: "'S256'",
|
||||
'allowedPermissions?': 'string[]',
|
||||
'allowedGoalIds?': 'number[]',
|
||||
'state?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthConsentArgs = typeof OAuthConsentArkType.infer
|
||||
|
||||
export const OAuthTokenArkType = type({
|
||||
grant_type: "'authorization_code'|'refresh_token'",
|
||||
'client_id?': 'string',
|
||||
'client_secret?': 'string',
|
||||
'code?': 'string',
|
||||
'code_verifier?': 'string',
|
||||
'redirect_uri?': 'string',
|
||||
'refresh_token?': 'string',
|
||||
'resource?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthTokenArgs = typeof OAuthTokenArkType.infer
|
||||
|
||||
export const OAuthRegisterArkType = type({
|
||||
'client_name?': 'string',
|
||||
redirect_uris: 'string[] > 0',
|
||||
'token_endpoint_auth_method?': 'string',
|
||||
'grant_types?': 'string[]',
|
||||
'response_types?': 'string[]',
|
||||
})
|
||||
|
||||
export type OAuthRegisterArgs = typeof OAuthRegisterArkType.infer
|
||||
|
||||
export const OAuthRevokeArkType = type({
|
||||
token: 'string > 0',
|
||||
'token_type_hint?': 'string',
|
||||
})
|
||||
|
||||
export type OAuthRevokeArgs = typeof OAuthRevokeArkType.infer
|
||||
|
||||
export type CreateAuthCodeArgs = {
|
||||
clientId: string
|
||||
userId: number
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
codeChallengeMethod: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type ExchangeCodeArgs = {
|
||||
code: string
|
||||
codeVerifier: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type RefreshTokensArgs = {
|
||||
refreshToken: string
|
||||
clientId: string
|
||||
resource: string | null
|
||||
}
|
||||
|
||||
export type RegisterClientArgs = {
|
||||
name: string
|
||||
redirectUris: string[]
|
||||
isPublic: boolean
|
||||
}
|
||||
|
||||
export type OAuthTokenResponse = {
|
||||
access_token: string
|
||||
token_type: 'Bearer'
|
||||
expires_in: number
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
export type OAuthFailure = { ok: false; error: string; description: string }
|
||||
export type OAuthSuccess<T> = { ok: true; value: T }
|
||||
export type OAuthResult<T> = OAuthSuccess<T> | OAuthFailure
|
||||
|
||||
export type ConnectedApp = {
|
||||
grantId: number
|
||||
clientId: string
|
||||
clientName: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
createdAt: Date
|
||||
lastUsedAt: Date | null
|
||||
}
|
||||
|
||||
export type CreateOAuthClientArgs = {
|
||||
clientId: string
|
||||
clientSecretHash: string | null
|
||||
name: string
|
||||
redirectUris: string[]
|
||||
createdVia: string
|
||||
}
|
||||
|
||||
export type ConsumeAuthCodeArgs = {
|
||||
id: number
|
||||
grantId: number
|
||||
}
|
||||
|
||||
export type RotateRefreshTokenArgs = {
|
||||
grantId: number
|
||||
refreshTokenHash: string
|
||||
prevHash: string | null
|
||||
refreshExpiresAt: Date
|
||||
/** Hash the caller presented; null on the first issue, when the grant has none yet. */
|
||||
presentedHash: string | null
|
||||
}
|
||||
|
||||
export type RevokeGrantArgs = {
|
||||
grantId: number
|
||||
/** Omitted for server-side revocation; set when the owner revokes from the UI. */
|
||||
userId?: number
|
||||
}
|
||||
|
||||
export type RevokeOwnGrantArgs = {
|
||||
grantId: number
|
||||
userId: number
|
||||
}
|
||||
|
||||
export type CreateAccessTokenArgs = {
|
||||
userId: number
|
||||
name: string
|
||||
tokenHash: string
|
||||
allowedPermissions: string[]
|
||||
allowedGoalIds: number[]
|
||||
grantId: number
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
export type ValidateRedirectUriArgs = {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export type AuthenticateClientArgs = {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
export type IssueTokenPairArgs = {
|
||||
grant: OAuthGrantsSchemaTypeForSelect
|
||||
clientName: string
|
||||
prevRefreshHash?: string
|
||||
}
|
||||
|
||||
export type VerifyPkceArgs = {
|
||||
codeVerifier: string
|
||||
codeChallenge: string
|
||||
}
|
||||
|
||||
export type MatchRedirectUriArgs = {
|
||||
candidate: string
|
||||
registered: string[]
|
||||
}
|
||||
|
||||
export type BuildRedirectUrlArgs = {
|
||||
redirectUri: string
|
||||
params: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export type ResourceMatchArgs = {
|
||||
granted: string | null
|
||||
requested: string | null
|
||||
}
|
||||
|
||||
export type SendTokenSuccessArgs = {
|
||||
res: Response
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type SendTokenErrorArgs = {
|
||||
res: Response
|
||||
status: number
|
||||
error: string
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { OrganizationController } from './OrganizationController'
|
||||
import { IsOrgAdmin } from './middlewares/IsOrgAdmin'
|
||||
import { IsOrgMember } from './middlewares/IsOrgMember'
|
||||
import { IsOrgOwner } from './middlewares/IsOrgOwner'
|
||||
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
|
||||
import { GoalPermissions } from '../../types/auth.types'
|
||||
|
||||
export default class OrganizationRoutes implements Routable {
|
||||
private readonly router: ReturnType<typeof Router>
|
||||
@@ -21,16 +23,20 @@ export default class OrganizationRoutes implements Routable {
|
||||
}
|
||||
|
||||
initRoutes() {
|
||||
this.router.post('', [IsLoggedIn], this.controller.create)
|
||||
const canManage = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)
|
||||
const canManageMembers = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE_MEMBERS)
|
||||
const canView = RequireTokenPermission(GoalPermissions.ORG_CAN_VIEW)
|
||||
|
||||
this.router.post('', [IsLoggedIn, canManage], this.controller.create)
|
||||
this.router.get('', [IsLoggedIn], this.controller.fetch)
|
||||
|
||||
this.router.post('/members', [IsLoggedIn, IsOrgAdmin], this.controller.addMember)
|
||||
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin], this.controller.updateMemberRole)
|
||||
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin], this.controller.removeMember)
|
||||
this.router.post('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.addMember)
|
||||
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.updateMemberRole)
|
||||
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.removeMember)
|
||||
|
||||
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById)
|
||||
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin], this.controller.update)
|
||||
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner], this.controller.delete)
|
||||
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers)
|
||||
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember, canView], this.controller.getById)
|
||||
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.update)
|
||||
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner, canManage], this.controller.delete)
|
||||
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin, canView], this.controller.fetchMembers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import type http from 'http';
|
||||
import { join } from 'path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import App from '../../../App';
|
||||
import { Database } from '../../../modules/db';
|
||||
import { GoalPermissions } from '../../../types/auth.types';
|
||||
|
||||
const port = 1811;
|
||||
const url = `http://localhost:${port}`;
|
||||
const MIGRATION_DIR = join(__dirname, '../../../migrations/taskview/sql/1.65.0');
|
||||
|
||||
const LOGIN = 'test@mail.dest';
|
||||
const PASSWORD = 'user1!#Q';
|
||||
|
||||
let server: http.Server;
|
||||
let jwt = '';
|
||||
const createdTokenIds: number[] = [];
|
||||
const createdOrgIds: number[] = [];
|
||||
let ownedGoalId = 0;
|
||||
|
||||
const api = axios.create({ baseURL: url, validateStatus: () => true });
|
||||
|
||||
const asUser = () => ({ headers: { Authorization: `Bearer ${jwt}` } });
|
||||
|
||||
async function issueToken(allowedPermissions: string[]) {
|
||||
const response = await api.post(
|
||||
'/module/api-tokens',
|
||||
{ name: 'scope-probe', allowedPermissions, allowedGoalIds: [] },
|
||||
asUser(),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
createdTokenIds.push(response.data.response.item.id);
|
||||
return { headers: { Authorization: `Bearer ${response.data.response.token}` } };
|
||||
}
|
||||
|
||||
async function createOrg(auth: { headers: Record<string, string> }, name: string) {
|
||||
const response = await api.post('/module/organizations', { name }, auth);
|
||||
const id = response.data?.response?.id;
|
||||
if (id) createdOrgIds.push(id);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe('API token scope on organization-level surfaces', () => {
|
||||
vi.mock('emailjs', () => ({
|
||||
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = Database.getInstance();
|
||||
const client = await db.getClient();
|
||||
for (const file of (await fs.readdir(MIGRATION_DIR)).sort()) {
|
||||
await client.query(await fs.readFile(join(MIGRATION_DIR, file), 'utf-8'));
|
||||
}
|
||||
client.release();
|
||||
|
||||
server = new App(port).listen();
|
||||
|
||||
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
jwt = login.data.access;
|
||||
|
||||
// A goal the caller actually owns: otherwise IsGoalOwnerByGoalId answers 403
|
||||
// on its own and the webhook tests below would pass without the token check.
|
||||
const goal = await api.post('/module/goals', { name: `scope-goal-${Date.now()}` }, asUser());
|
||||
expect(goal.status).toBe(200);
|
||||
ownedGoalId = goal.data.response.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (ownedGoalId) {
|
||||
await api.delete('/module/goals', { ...asUser(), data: { goalId: ownedGoalId } });
|
||||
}
|
||||
for (const id of createdOrgIds) {
|
||||
await api.delete(`/module/organizations/${id}`, asUser());
|
||||
}
|
||||
for (const id of createdTokenIds) {
|
||||
await api.delete('/module/api-tokens', { ...asUser(), data: { id } });
|
||||
}
|
||||
server?.close();
|
||||
});
|
||||
|
||||
it('seeds the organization permission group', async () => {
|
||||
const db = Database.getInstance();
|
||||
const result = await db.query<{ name: string }>(
|
||||
'SELECT name FROM tv_auth.permissions WHERE permission_group = 6 ORDER BY name',
|
||||
);
|
||||
expect(result?.rows.map((row) => row.name)).toEqual([
|
||||
'org_can_manage', 'org_can_manage_members', 'org_can_view', 'sso_can_manage', 'webhooks_can_manage',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers the new permissions for selection, with localized descriptions', async () => {
|
||||
const response = await api.get('/module/api-tokens/permissions', asUser());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const rows = response.data.response as {
|
||||
name: string;
|
||||
permissionGroup: number;
|
||||
descriptionLocales: Record<string, string> | null;
|
||||
}[];
|
||||
|
||||
const orgView = rows.find((row) => row.name === 'org_can_view');
|
||||
expect(orgView?.permissionGroup).toBe(6);
|
||||
expect(orgView?.descriptionLocales?.ru).toBeTruthy();
|
||||
|
||||
// Group 1 is enforced nowhere, so it must not be offered as a restriction.
|
||||
expect(rows.some((row) => row.permissionGroup === 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks a narrowly scoped token from creating an organization', async () => {
|
||||
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-denied');
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('allows a token that was given org_can_manage', async () => {
|
||||
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-allowed');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps an unrestricted token working, so existing integrations do not break', async () => {
|
||||
const token = await issueToken([]);
|
||||
|
||||
const response = await createOrg(token, 'scope-probe-unrestricted');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('does not restrict a browser session', async () => {
|
||||
const response = await createOrg(asUser(), 'scope-probe-session');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('separates managing the organization from managing its members', async () => {
|
||||
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
const org = await createOrg(token, 'scope-probe-members');
|
||||
expect(org.status).toBe(200);
|
||||
|
||||
const response = await api.post(
|
||||
'/module/organizations/members',
|
||||
{ organizationId: org.data.response.id, email: LOGIN, role: 'admin' },
|
||||
token,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a token holding org_can_manage_members add one', async () => {
|
||||
const owner = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
|
||||
const org = await createOrg(owner, 'scope-probe-members-ok');
|
||||
expect(org.status).toBe(200);
|
||||
|
||||
const member = await issueToken([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
|
||||
const response = await api.post(
|
||||
'/module/organizations/members',
|
||||
{ organizationId: org.data.response.id, email: `member-${Date.now()}@test.dest`, role: 'member' },
|
||||
member,
|
||||
);
|
||||
|
||||
// A precise status, not merely "not 403" — that would also pass on a 500.
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('blocks a narrowly scoped token from reaching webhooks of a goal it owns', async () => {
|
||||
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
|
||||
|
||||
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('lets webhooks_can_manage through on that same goal, proving the 403 came from the token', async () => {
|
||||
const token = await issueToken([GoalPermissions.WEBHOOKS_CAN_MANAGE]);
|
||||
|
||||
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -84,12 +84,20 @@ export class RecurrenceGenerator {
|
||||
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
|
||||
const today = RecurrenceParser.todayInTimezone(rule.timezone);
|
||||
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
// Fixed series follow the calendar schedule; after-completion series
|
||||
// take one interval step from the completion day. Stepping from
|
||||
// max(lastInstanceDate, today) keeps instance dates strictly
|
||||
// increasing, so the (rule_id, instance_date) unique index can
|
||||
// never collide with an earlier instance of the series.
|
||||
const nextDate =
|
||||
rule.scheduleMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: rule.rrule,
|
||||
dtstart: rule.dtstart,
|
||||
afterDate,
|
||||
skipDates,
|
||||
});
|
||||
if (!nextDate) {
|
||||
await tx
|
||||
.update(RecurrenceRulesSchema)
|
||||
|
||||
@@ -61,10 +61,12 @@ export class RecurrenceManager {
|
||||
return fail('invalid_rule', 'timezone must be a valid IANA name');
|
||||
}
|
||||
|
||||
const scheduleMode = args.scheduleMode ?? 'fixed';
|
||||
let dtstart: Date;
|
||||
let hasTime: boolean;
|
||||
try {
|
||||
RecurrenceParser.validateRuleString(args.rrule);
|
||||
if (scheduleMode === 'after-completion') RecurrenceParser.validateForAfterCompletion(args.rrule);
|
||||
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
@@ -99,6 +101,7 @@ export class RecurrenceManager {
|
||||
dtstart,
|
||||
hasTime,
|
||||
timezone: args.timezone,
|
||||
scheduleMode,
|
||||
lastInstanceDate: originInstanceDate,
|
||||
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
|
||||
creatorId: this.initiatorId,
|
||||
@@ -196,13 +199,26 @@ export class RecurrenceManager {
|
||||
}
|
||||
patch.timezone = args.timezone;
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
|
||||
const nextDate = RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (args.scheduleMode !== undefined) patch.scheduleMode = args.scheduleMode;
|
||||
const effectiveMode = patch.scheduleMode ?? rule.scheduleMode;
|
||||
if (effectiveMode === 'after-completion') {
|
||||
try {
|
||||
RecurrenceParser.validateForAfterCompletion(patch.rrule ?? rule.rrule);
|
||||
} catch (err) {
|
||||
return fail('invalid_rule', (err as Error).message);
|
||||
}
|
||||
}
|
||||
if (patch.rrule !== undefined || patch.dtstart !== undefined || patch.scheduleMode !== undefined) {
|
||||
const afterDate = RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone);
|
||||
const nextDate =
|
||||
effectiveMode === 'after-completion'
|
||||
? RecurrenceParser.nextDateAfterCompletion({ rrule: patch.rrule ?? rule.rrule, afterDate })
|
||||
: RecurrenceParser.nextOccurrenceDate({
|
||||
rrule: patch.rrule ?? rule.rrule,
|
||||
dtstart: patch.dtstart ?? rule.dtstart,
|
||||
afterDate,
|
||||
skipDates: new Set<string>(),
|
||||
});
|
||||
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
|
||||
}
|
||||
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { RRule } from 'rrule';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
import type { InstanceWindow, InstanceWindowArgs, NextDateAfterCompletionArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
|
||||
|
||||
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
|
||||
const MAX_COUNT = 10000;
|
||||
|
||||
const FREQ_TO_STEP_UNIT: Record<number, 'years' | 'months' | 'weeks' | 'days'> = {
|
||||
[RRule.YEARLY]: 'years',
|
||||
[RRule.MONTHLY]: 'months',
|
||||
[RRule.WEEKLY]: 'weeks',
|
||||
[RRule.DAILY]: 'days',
|
||||
};
|
||||
|
||||
/**
|
||||
* All recurrence math happens in a single floating wall-clock frame:
|
||||
* `dtstart` is a Date whose UTC components equal the wall-clock components of
|
||||
@@ -41,6 +48,40 @@ export class RecurrenceParser {
|
||||
return RRule.parseString(rruleString).count ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After-completion series step from the completion day, so calendar anchors
|
||||
* (BYDAY, BYMONTHDAY) have no defined meaning for them — reject instead of
|
||||
* silently ignoring what the client asked for.
|
||||
*/
|
||||
static validateForAfterCompletion(rruleString: string): void {
|
||||
const options = RRule.parseString(rruleString);
|
||||
if (options.byweekday !== undefined && options.byweekday !== null) {
|
||||
throw new Error('BYDAY is not supported for after-completion series');
|
||||
}
|
||||
if (options.bymonthday !== undefined && options.bymonthday !== null) {
|
||||
throw new Error('BYMONTHDAY is not supported for after-completion series');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next date of an after-completion series: one FREQ/INTERVAL step after
|
||||
* `afterDate` (the completion day), no calendar anchor. Month/year steps
|
||||
* clamp to the last valid day (Jan 31 + 1 month → Feb 28). COUNT is
|
||||
* enforced by the caller via instances_created (same as fixed series);
|
||||
* returns null when the step lands past UNTIL — the series is over.
|
||||
*/
|
||||
static nextDateAfterCompletion(args: NextDateAfterCompletionArgs): string | null {
|
||||
const options = RRule.parseString(args.rrule);
|
||||
const unit = options.freq !== undefined ? FREQ_TO_STEP_UNIT[options.freq] : undefined;
|
||||
if (!unit) return null;
|
||||
const nextDate = DateTime.fromISO(args.afterDate, { zone: 'utc' })
|
||||
.plus({ [unit]: options.interval ?? 1 })
|
||||
.toISODate();
|
||||
if (!nextDate) return null;
|
||||
if (options.until && nextDate > RecurrenceParser.toIsoDate(options.until)) return null;
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* First occurrence date strictly after `afterDate`, skipping explicit skip
|
||||
* dates. COUNT is intentionally stripped: the cap is "N materialized
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type } from 'arktype';
|
||||
import type {
|
||||
RecurrenceRulesSchemaTypeForInsert,
|
||||
RecurrenceRulesSchemaTypeForSelect,
|
||||
RecurrenceScheduleMode,
|
||||
TasksSchemaTypeForSelect,
|
||||
} from 'taskview-db-schemas';
|
||||
|
||||
@@ -12,6 +13,7 @@ export const RecurrenceArkTypeCreate = type({
|
||||
rrule: 'string > 0',
|
||||
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
|
||||
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
});
|
||||
|
||||
@@ -20,6 +22,7 @@ export const RecurrenceArkTypeUpdate = type({
|
||||
'rrule?': 'string > 0',
|
||||
'dtstart?': 'string',
|
||||
'timezone?': 'string > 0',
|
||||
'scheduleMode?': '"fixed" | "after-completion"',
|
||||
'notifyOnOccurrence?': 'boolean',
|
||||
'templateOverrides?': type({
|
||||
'description?': 'string',
|
||||
@@ -63,6 +66,11 @@ export type NextOccurrenceArgs = {
|
||||
afterDate: string;
|
||||
skipDates: Set<string>;
|
||||
};
|
||||
export type NextDateAfterCompletionArgs = {
|
||||
rrule: string;
|
||||
/** 'YYYY-MM-DD' — the completion day; the next date is one FREQ/INTERVAL step after it. */
|
||||
afterDate: string;
|
||||
};
|
||||
export type InstanceWindowArgs = {
|
||||
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
|
||||
occurrenceDate: string;
|
||||
@@ -93,6 +101,7 @@ export type RecurrenceRulePatchArgs = {
|
||||
dtstart: Date;
|
||||
hasTime: boolean;
|
||||
timezone: string;
|
||||
scheduleMode: RecurrenceScheduleMode;
|
||||
state: 'active' | 'paused' | 'ended';
|
||||
lastInstanceDate: string;
|
||||
instancesCreated: number;
|
||||
|
||||
@@ -3,16 +3,26 @@ import { type } from 'arktype'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
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()
|
||||
@@ -20,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' })
|
||||
@@ -27,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 || '' })
|
||||
@@ -44,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,
|
||||
@@ -131,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,
|
||||
@@ -140,6 +241,16 @@ export class SsoController {
|
||||
})
|
||||
}
|
||||
|
||||
getPublicUrls = async (req: Request, res: Response) => {
|
||||
const base = PublicApiUrl.base(req)
|
||||
return res.tvJson({
|
||||
apiBaseUrl: base,
|
||||
callbackUrlTemplate: `${base}/module/sso/callback/{id}`,
|
||||
scimEndpointUrl: `${base}/scim/v2`,
|
||||
apiPublicUrlConfigured: PublicApiUrl.configured() !== null,
|
||||
})
|
||||
}
|
||||
|
||||
listConfigs = async (req: Request, res: Response) => {
|
||||
const orgId = Number(req.query.organizationId)
|
||||
if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' })
|
||||
@@ -154,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' })
|
||||
@@ -175,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) => {
|
||||
@@ -202,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,12 +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)
|
||||
|
||||
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)
|
||||
const canManageSso = RequireTokenPermission(GoalPermissions.SSO_CAN_MANAGE)
|
||||
|
||||
this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user