-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): implement headless provisioning and one-time ticket auth #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||||||||||||||||||||||||
|
Comment on lines
+93
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential Bug: Provisioned User is InactiveWhen a user is created via Recommendation:
Suggested change
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+176
to
+181
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security Issue: Replay Attack on "One-Time" TicketThe ticket issued is a stateless JWT. Although it has a short expiration (5 minutes), there is no mechanism to track whether a ticket has already been consumed. An attacker who intercepts the ticket can reuse the same ticket multiple times within its 5-minute validity window. Recommendation: |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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', | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| : {}), | ||||||||||||||||||||||||||||||
|
Comment on lines
+196
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security Recommendation: Use
|
||||||||||||||||||||||||||||||
| ...(!process.env.NOT_SECURED | |
| ? { | |
| secure: true, | |
| httpOnly: true, | |
| sameSite: 'none', | |
| } | |
| : {}), | |
| ...(!process.env.NOT_SECURED | |
| ? { | |
| secure: true, | |
| httpOnly: true, | |
| sameSite: 'lax', | |
| } | |
| : {}), |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="flex flex-1 items-center justify-center text-white"> | ||
| <div className="bg-third p-8 rounded-xl border border-tableBorder text-center max-w-md"> | ||
| <h2 className="text-xl font-bold text-red-500 mb-2">Authentication Error</h2> | ||
| <p className="text-gray-300 text-sm mb-4">{error}</p> | ||
| <a | ||
| href="/auth/login" | ||
| className="inline-block bg-btnPrimary px-4 py-2 rounded-lg text-sm font-medium" | ||
| > | ||
| Go to Login | ||
| </a> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return <LoadingComponent />; | ||
| } | ||
|
Comment on lines
+1
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Next.js Best Practice: Missing Suspense Boundary for
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Security Vulnerability: Fail-Open Authentication
If none of the environment variables (
PROVISIONING_SECRET_KEY,DOS_PROVISIONING_SECRET, etc.) are configured,verifyAuthreturnstrue. This creates a critical security vulnerability where the/provisionendpoint becomes completely public and unauthenticated in environments where these variables are missing or misconfigured.Recommendation:
Fail secure by returning
falseif no secret is configured.