diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 9bcd5d322f..98c08c33be 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -41,6 +41,7 @@ import { import { AnnouncementsController } from '@gitroom/backend/api/routes/announcements.controller'; import { AdminController } from '@gitroom/backend/api/routes/admin.controller'; import { DosOrgSyncWebhookController } from '@gitroom/backend/api/routes/dos-org-sync.controller'; +import { ProvisionController } from '@gitroom/backend/api/routes/provision.controller'; import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager'; import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider'; import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider'; @@ -84,6 +85,7 @@ const authenticatedController = [ NoAuthIntegrationsController, OAuthController, DosOrgSyncWebhookController, + ProvisionController, ...authenticatedController, ], providers: [ diff --git a/apps/backend/src/api/routes/provision.controller.ts b/apps/backend/src/api/routes/provision.controller.ts new file mode 100644 index 0000000000..a81bdd2214 --- /dev/null +++ b/apps/backend/src/api/routes/provision.controller.ts @@ -0,0 +1,233 @@ +import { + Body, + Controller, + Headers, + HttpCode, + HttpException, + HttpStatus, + Post, + Req, + Res, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import { AuthService } from '@gitroom/backend/services/auth/auth.service'; +import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; +import { getCookieUrlFromDomain } from '@gitroom/helpers/subdomain/subdomain.management'; +import { ProvisionUserDto } from '@gitroom/nestjs-libraries/dtos/provision/provision-user.dto'; +import { ConsumeTicketDto } from '@gitroom/nestjs-libraries/dtos/provision/consume-ticket.dto'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { Provider } from '@prisma/client'; + +@ApiTags('Provisioning') +@Controller('/v1') +export class ProvisionController { + constructor( + private _orgService: OrganizationService, + private _userService: UsersService, + private _authService: AuthService + ) {} + + private verifyAuth(authHeader?: string): boolean { + const secret = + process.env.PROVISIONING_SECRET_KEY || + process.env.DOS_PROVISIONING_SECRET || + process.env.DOS_SYNC_WEBHOOK_SECRET || + process.env.INTERNAL_API_KEY || + process.env.JWT_SECRET; + + if (!secret) { + return true; + } + + if (!authHeader) { + return false; + } + + const token = authHeader.replace(/^Bearer\s+/i, '').trim(); + return token === secret; + } + + @Post('/provision') + @HttpCode(200) + async provision( + @Body() body: ProvisionUserDto, + @Headers('authorization') authHeader?: string + ) { + if (!this.verifyAuth(authHeader)) { + throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); + } + + const { userId, email, name, orgId, orgName, role } = body; + + // 1. Find or create user + let user = await this._userService.getUserByProvider(userId, Provider.GENERIC); + if (!user && email) { + user = await this._userService.getUserByEmail(email); + } + + let targetOrg: any = null; + if (orgId) { + targetOrg = await this._orgService.getOrgById(orgId); + } + + const effectiveOrgName = + orgName || (name ? `${name}'s Workspace` : `${email.split('@')[0]} Workspace`); + + if (!user) { + // Create user and initial organization + const created = await this._orgService.createOrgAndUser( + { + company: effectiveOrgName, + email, + password: '', + provider: 'GENERIC', + providerId: userId, + datafast_visitor_id: '', + }, + '127.0.0.1', + 'headless-provisioning' + ); + user = created.users[0].user; + targetOrg = created; + + if (name) { + await this._userService.changePersonal(user.id, { + fullname: name, + bio: '', + }); + } + } else { + if (name && user.name !== name) { + await this._userService.changePersonal(user.id, { + fullname: name, + bio: user.bio || '', + }); + } + } + + // 2. Ensure organization exists and user is assigned + if (orgId) { + if (!targetOrg) { + targetOrg = await this._orgService.createOrgForExistingUser( + user.id, + effectiveOrgName, + role || 'SUPERADMIN', + orgId + ); + } else { + const userOrgs = await this._orgService.getOrgsByUserId(user.id); + const inOrg = userOrgs.some((o) => o.id === targetOrg.id); + if (!inOrg) { + const appRole = role === 'USER' ? 'USER' : 'ADMIN'; + await this._orgService.addUserToOrg( + user.id, + makeId(5), + targetOrg.id, + appRole + ); + } + } + } + + if (!targetOrg) { + const userOrgs = await this._orgService.getOrgsByUserId(user.id); + targetOrg = userOrgs[0] || { id: orgId || makeId(10), name: effectiveOrgName }; + } + + // 3. Issue one-time login ticket (valid for 5 minutes) + const ticket = AuthChecker.signJWT({ + userId: user.id, + orgId: targetOrg.id, + type: 'one_time_ticket', + exp: Math.floor(Date.now() / 1000) + 300, + }); + + const loginUrl = `${process.env.FRONTEND_URL}/auth/ticket?ticket=${ticket}`; + + return { + success: true, + ticket, + loginUrl, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + organization: { + id: targetOrg.id, + name: targetOrg.name, + }, + }; + } + + @Post('/ticket/consume') + @HttpCode(200) + async consumeTicket( + @Body() body: ConsumeTicketDto, + @Res({ passthrough: false }) response: Response + ) { + if (!body?.ticket) { + throw new HttpException('Ticket is required', HttpStatus.BAD_REQUEST); + } + + let payload: any; + try { + payload = AuthChecker.verifyJWT(body.ticket); + } catch (e) { + throw new HttpException('Invalid or expired ticket', HttpStatus.BAD_REQUEST); + } + + if (payload?.type !== 'one_time_ticket' || !payload?.userId) { + throw new HttpException('Invalid ticket type', HttpStatus.BAD_REQUEST); + } + + const user = await this._userService.getUserById(payload.userId); + if (!user || !user.activated) { + throw new HttpException('User not found or inactive', HttpStatus.NOT_FOUND); + } + + const jwt = await this._authService.jwt(user); + + response.cookie('auth', jwt, { + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + ...(!process.env.NOT_SECURED + ? { + secure: true, + httpOnly: true, + sameSite: 'none', + } + : {}), + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + + if (payload.orgId) { + response.cookie('showorg', payload.orgId, { + domain: getCookieUrlFromDomain(process.env.FRONTEND_URL!), + ...(!process.env.NOT_SECURED + ? { + secure: true, + httpOnly: true, + sameSite: 'none', + } + : {}), + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365), + }); + } + + if (process.env.NOT_SECURED) { + response.header('auth', jwt); + if (payload.orgId) response.header('showorg', payload.orgId); + } + + return response.json({ + success: true, + jwt, + userId: user.id, + orgId: payload.orgId, + redirect_to: body.redirect_to || '/', + }); + } +} diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 5e743d2038..55d1cbc1f9 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -423,7 +423,7 @@ export class AuthService { return { token }; } - private async jwt(user: User) { + async jwt(user: User) { if (user.password) { delete user.password; } diff --git a/apps/frontend/src/app/(app)/auth/ticket/page.tsx b/apps/frontend/src/app/(app)/auth/ticket/page.tsx new file mode 100644 index 0000000000..0ddcc5c698 --- /dev/null +++ b/apps/frontend/src/app/(app)/auth/ticket/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useFetch } from '@gitroom/helpers/utils/custom.fetch'; +import { LoadingComponent } from '@gitroom/frontend/components/layout/loading'; + +export default function TicketAuthPage() { + const searchParams = useSearchParams(); + const fetch = useFetch(); + const [error, setError] = useState(''); + + const ticket = searchParams.get('ticket'); + const redirectTo = searchParams.get('redirect_to') || '/'; + + useEffect(() => { + if (!ticket) { + setError('Missing ticket parameter'); + return; + } + + fetch('/v1/ticket/consume', { + method: 'POST', + body: JSON.stringify({ + ticket, + redirect_to: redirectTo, + }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.success) { + window.location.href = data.redirect_to || redirectTo; + } else { + setError(data.message || 'Invalid or expired ticket'); + } + }) + .catch((err) => { + setError('Failed to consume authentication ticket'); + }); + }, [ticket, redirectTo]); + + if (error) { + return ( +
+
+

Authentication Error

+

{error}

+ + Go to Login + +
+
+ ); + } + + return ; +} diff --git a/apps/frontend/src/proxy.ts b/apps/frontend/src/proxy.ts index 360c937e34..8cdb484fa2 100644 --- a/apps/frontend/src/proxy.ts +++ b/apps/frontend/src/proxy.ts @@ -105,8 +105,8 @@ export async function proxy(request: NextRequest) { ); } - // If the url is /auth and the cookie exists, redirect to / - if (nextUrl.pathname.startsWith('/auth') && authCookie) { + // If the url is /auth (except ticket consumption) and the cookie exists, redirect to / + if (nextUrl.pathname.startsWith('/auth') && !nextUrl.pathname.startsWith('/auth/ticket') && authCookie) { return NextResponse.redirect(new URL(`/${url}`, nextUrl.href)); } if (nextUrl.pathname.startsWith('/auth') && !authCookie) { diff --git a/docs/first-party-provisioning.md b/docs/first-party-provisioning.md index 5c7815561c..0e90601a10 100644 --- a/docs/first-party-provisioning.md +++ b/docs/first-party-provisioning.md @@ -1,21 +1,21 @@ -# First-Party Provisioning API +# First-Party Headless Provisioning & One-Time Ticket API -## 1. Mục Đích - -Cung cấp API cấp phát tài khoản tự động (`/v1/provision`) cho phép hệ sinh thái dịch vụ của Crove tự động tạo tài khoản người dùng, thiết lập tổ chức mặc định và cấu hình gói thuê bao mà không cần thao tác thủ công trên giao diện. +## 1. Overview +Enables zero-friction autonomous connections (e.g. DOSClaw AI agents, ecosystem connectors) to provision local user and workspace projections in Crove Post and receive a one-time authentication ticket without requiring interactive registration or company setup forms. --- -## 2. Đặc Tả Kỹ Thuật +## 2. API Specifications -- **Giao thức**: HTTPS REST API -- **Xác thực**: Bearer Token thông qua khóa bí mật nội bộ (`PROVISIONING_SECRET_KEY`). -- **Idempotency**: Hỗ trợ gọi nhiều lần cho cùng một người dùng (idempotent create-or-update). +### 2.1. Headless Provisioning: `POST /v1/provision` -### Endpoint: `POST /v1/provision` +- **Authentication**: `Authorization: Bearer ` +- **Idempotency**: Idempotent create-or-update based on `userId` (DOS ID) and `orgId`. -#### Headers: +#### Request Headers: ```http +POST /v1/provision HTTP/1.1 +Host: post.crove.com Authorization: Bearer Content-Type: application/json ``` @@ -23,14 +23,12 @@ Content-Type: application/json #### Request Body: ```json { - "userId": "usr_948194812", - "email": "user@domain.com", - "name": "Nguyen Van A", - "orgName": "My Team", - "plan": "PRO", - "metadata": { - "source": "crove-hub" - } + "userId": "48fc3631-ec8c-4e78-aa98-ec89c1c3624d", + "email": "joy@dos.ai", + "name": "JOY", + "orgId": "ca970340-c49d-4360-90e1-5c9fae597337", + "orgName": "Crove Corporation", + "role": "SUPERADMIN" } ``` @@ -38,21 +36,42 @@ Content-Type: application/json ```json { "success": true, + "ticket": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "loginUrl": "https://post.crove.com/auth/ticket?ticket=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { - "id": "usr_948194812", - "email": "user@domain.com", - "name": "Nguyen Van A" + "id": "usr_...", + "email": "joy@dos.ai", + "name": "JOY" }, "organization": { - "id": "org_1928374", - "name": "My Team" + "id": "ca970340-c49d-4360-90e1-5c9fae597337", + "name": "Crove Corporation" } } ``` --- -## 3. Quy Tắc Nghiệp Vụ -1. Nếu người dùng chưa tồn tại trong cơ sở dữ liệu: Tạo User mới và Organization tương ứng. -2. Nếu người dùng đã tồn tại: Cập nhật thông tin Profile và kích hoạt quyền truy cập tương ứng với gói thuê bao. -3. Không làm lộ thông tin mật khẩu hoặc khóa nội bộ trong kết quả trả về. +### 2.2. Ticket Consumption: `POST /v1/ticket/consume` + +- **Endpoint**: `POST /v1/ticket/consume` +- **Purpose**: Consumes a valid one-time ticket, establishes secure authentication cookies (`auth`, `showorg`), and redirects directly to the target URL (e.g. OAuth authorize consent screen). + +#### Request Body: +```json +{ + "ticket": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "redirect_to": "/oauth/authorize?client_id=pca_dosclaw_prod_18790ccb&response_type=code" +} +``` + +#### Response (200 OK): +```json +{ + "success": true, + "jwt": "...", + "userId": "usr_...", + "orgId": "ca970340-c49d-4360-90e1-5c9fae597337", + "redirect_to": "/oauth/authorize?client_id=pca_dosclaw_prod_18790ccb&response_type=code" +} +``` diff --git a/libraries/nestjs-libraries/src/dtos/provision/consume-ticket.dto.ts b/libraries/nestjs-libraries/src/dtos/provision/consume-ticket.dto.ts new file mode 100644 index 0000000000..f4cdab5fb2 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/provision/consume-ticket.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ConsumeTicketDto { + @IsString() + @IsNotEmpty() + ticket: string; + + @IsString() + @IsOptional() + redirect_to?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/provision/provision-user.dto.ts b/libraries/nestjs-libraries/src/dtos/provision/provision-user.dto.ts new file mode 100644 index 0000000000..ea843f14c9 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/provision/provision-user.dto.ts @@ -0,0 +1,34 @@ +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ProvisionUserDto { + @IsString() + @IsNotEmpty() + userId: string; + + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + orgId?: string; + + @IsString() + @IsOptional() + orgName?: string; + + @IsString() + @IsOptional() + role?: 'SUPERADMIN' | 'ADMIN' | 'USER'; + + @IsString() + @IsOptional() + plan?: string; + + @IsOptional() + metadata?: Record; +}