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 ( +