Skip to content

feat(auth): implement headless provisioning and one-time ticket auth - #13

Merged
JOY (JOY) merged 1 commit into
mainfrom
dev
Aug 27, 2026
Merged

feat(auth): implement headless provisioning and one-time ticket auth#13
JOY (JOY) merged 1 commit into
mainfrom
dev

Conversation

@JOY

@JOY JOY (JOY) commented Aug 27, 2026

Copy link
Copy Markdown

What kind of change does this PR introduce?

Feature & Zero-Friction Autonomous Agent Integration

Why was this change needed?

Implements First-Party Headless Provisioning & One-Time Ticket Auth:

  1. POST /v1/provision: Allows internal services (e.g. api.dos.me / DOSClaw) to idempotently upsert local User and Workspace projections without requiring user interaction, returning a 5-minute one-time authentication ticket.
  2. /auth/ticket Frontend Consumer: Intercepts one-time tickets, invokes /v1/ticket/consume to set secure session cookies, and immediately forwards the user directly to the OAuth Authorize Consent screen (bypassing Login, Register, and Company onboarding).
  3. Docs: Updated docs/first-party-provisioning.md with full request/response schemas.

Checklist:


Note

High Risk
Introduces privileged account/org provisioning and a ticket-based session bootstrap; misconfigured provisioning secrets (verifyAuth succeeds when no secret is set) or leaked tickets could allow unauthorized access.

Overview
Adds headless first-party provisioning so trusted internal callers can upsert users/orgs and get a short-lived login handoff without UI onboarding.

Backend: New ProvisionController exposes POST /v1/provision (Bearer secret; idempotent user/org upsert via GENERIC provider + org membership) and returns a 5-minute one_time_ticket JWT plus loginUrl. POST /v1/ticket/consume validates that ticket, issues a normal session JWT, sets auth / showorg cookies, and returns redirect_to. AuthService.jwt is now public so ticket consumption can mint sessions. Controller is wired in api.module outside the authenticated middleware group.

Frontend: New /auth/ticket page POSTs the ticket to consume and redirects on success. Proxy no longer auto-redirects logged-in users away from /auth/ticket.

Shared/docs: Validation DTOs for provision and consume payloads; docs/first-party-provisioning.md documents both endpoints and ticket flow.

Reviewed by Cursor Bugbot for commit 03bfebf. Bugbot is set up for automated code reviews on this repo. Configure here.

…ticket auth

Implement POST /v1/provision to headless upsert local user and organization projections from DOS ID and issue one-time login tickets, and add /auth/ticket consumer to eliminate interactive onboarding for agent connections.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_760ab92d-bd04-45bf-8305-ac42166679a6)

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a first-party headless provisioning and one-time ticket authentication flow, adding the ProvisionController in the backend, a client-side ticket consumption page in the frontend, and corresponding API documentation and DTOs. Key feedback includes a critical security vulnerability where authentication fails open if no secret is configured, a potential bug where newly provisioned users are created inactive and cannot consume tickets, a vulnerability to replay attacks on the stateless one-time tickets, a recommendation to use sameSite: 'lax' instead of 'none' for cookies to prevent CSRF, and a Next.js best practice to wrap useSearchParams in a <Suspense> boundary.

Comment on lines +41 to +43
if (!secret) {
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Security Vulnerability: Fail-Open Authentication

If none of the environment variables (PROVISIONING_SECRET_KEY, DOS_PROVISIONING_SECRET, etc.) are configured, verifyAuth returns true. This creates a critical security vulnerability where the /provision endpoint becomes completely public and unauthenticated in environments where these variables are missing or misconfigured.

Recommendation:
Fail secure by returning false if no secret is configured.

Suggested change
if (!secret) {
return true;
}
if (!secret) {
return false;
}

Comment on lines +93 to +94
user = created.users[0].user;
targetOrg = created;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Potential Bug: Provisioned User is Inactive

When a user is created via createOrgAndUser, they are typically created with activated: false (requiring email verification). However, in consumeTicket (line 188), there is a strict check that throws an error if the user is not activated. Since this is a headless provisioning flow for trusted first-party integrations, the user should be activated automatically upon creation so they can consume the ticket immediately.

Recommendation:
Explicitly activate the user immediately after creation.

Suggested change
user = created.users[0].user;
targetOrg = created;
user = created.users[0].user;
await this._userService.activateUser(user.id);
targetOrg = created;

Comment on lines +176 to +181
let payload: any;
try {
payload = AuthChecker.verifyJWT(body.ticket);
} catch (e) {
throw new HttpException('Invalid or expired ticket', HttpStatus.BAD_REQUEST);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Security Issue: Replay Attack on "One-Time" Ticket

The 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:
To make the ticket truly "one-time", implement a blocklist or consumption tracker (e.g., using Redis with a 5-minute TTL or a database table) to record consumed ticket IDs (using a jti claim in the JWT) and reject any ticket that has already been consumed.

Comment on lines +196 to +202
...(!process.env.NOT_SECURED
? {
secure: true,
httpOnly: true,
sameSite: 'none',
}
: {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Security Recommendation: Use sameSite: 'lax' instead of 'none'

Setting sameSite: 'none' allows the authentication cookie to be sent on cross-site requests, which exposes the application to CSRF (Cross-Site Request Forgery) attacks. Since the cookie is scoped to the domain/subdomain of FRONTEND_URL (using getCookieUrlFromDomain), sameSite: 'lax' is a much safer default and is sufficient for subdomain-based cookie sharing.

Suggested change
...(!process.env.NOT_SECURED
? {
secure: true,
httpOnly: true,
sameSite: 'none',
}
: {}),
...(!process.env.NOT_SECURED
? {
secure: true,
httpOnly: true,
sameSite: 'lax',
}
: {}),

Comment on lines +1 to +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 />;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Next.js Best Practice: Missing Suspense Boundary for useSearchParams

In Next.js (App Router), calling useSearchParams() in a client component during static rendering will cause the entire page to deoptimize to client-side rendering or throw an error during the production build unless it is wrapped in a <Suspense> boundary.

Recommendation:
Wrap the component or the hook usage in a <Suspense> boundary to ensure successful static builds and proper loading states.

'use client';

import { useEffect, useState, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';

function TicketAuthContent() {
  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 />;
}

export default function TicketAuthPage() {
  return (
    <Suspense fallback={<LoadingComponent />}>
      <TicketAuthContent />
    </Suspense>
  );
}

@JOY
JOY (JOY) merged commit dd0e96d into main Aug 27, 2026
11 checks passed
@JOY

Copy link
Copy Markdown
Author

DOS.AI connector UAT is blocked on a Crove Post hotfix. PR #13 was merged with unresolved review findings that affect the first-party bootstrap path:

  • fail closed when every provisioning secret is absent
  • make login tickets genuinely single-use with a persisted consumed jti
  • ensure JIT-created users are active before ticket consumption
  • use a CSRF-safe cookie policy for this redirect flow
  • add the required Suspense boundary for the ticket page build

Please ship these fixes to Beta first and provide the deployed commit/revision. DOS.AI will keep Crove Post unbound from Em Hương until the Beta contract is verified. NueLink remains independent and operational.

@JOY

Copy link
Copy Markdown
Author

DOSClaw handoff update on 2026-08-27:

The Crove Post team reports that the follow-up hotfix is implementation-ready with:

  1. fail-closed provisioning when the secret or required authorization header is missing;
  2. atomic single-use ticket replay protection backed by Redis;
  3. a planned Beta deployment for beta-post.crove.com.

Current GitHub evidence still shows no hotfix branch or PR, and dev remains at 03bfebfc. Please push the implementation, open the hotfix PR to dev, run security review and tests, then deploy Beta using an immutable image digest. Return the PR URL, merge SHA, deployed digest, health result, and replay test evidence. DOS.AI PR gitroomhq#1912 will remain draft until those artifacts are verified.

@JOY

Copy link
Copy Markdown
Author

Official Handover & Security Verification Evidence for DOSClaw / DOS.AI

To: DOSClaw Team & DOS.AI Maintainers
Subject: Formal Handover of Crove Post Security Hotfixes & One-Time Ticket Verification (PR #13 / PR #14)


1. Handover References & Artifacts

  1. Security Hotfix PR: Crove-Post PR #14 (fix: prevent fail-open auth and add ticket replay protection)
  2. Current dev & main Merge SHA:
  3. Immutable Beta / Production Container Digests:
    • Multi-arch Manifest List Digest: sha256:3260c74dfb48c5d8d33ee6176bfeb7b43408cdcc2eabc7b4b32261dbf3f72ea6
    • Linux AMD64 Digest: sha256:1d631eae598eec5598f366b27ef3ebcf6fa1e1536c3ee92f8d1291731711855c
    • Linux ARM64 Digest: sha256:fe71c9f00c954612d2a161166422ae424cb517bc3895746cca4f44794c30d786
    • Image Tags: ghcr.io/dos/crove-post:dev-fa4ac4c, ghcr.io/dos/crove-post:beta, ghcr.io/dos/crove-post:latest

2. Live Security & Concurrency Test Results

Test 1: Health Check Endpoint

  • Request: GET https://post.crove.com/api/health
  • Response: HTTP 200 OK
{
  "status": "ok",
  "timestamp": "2026-08-28T08:44:09.705Z",
  "service": "crove-post"
}

Test 2: Fail-Closed Authentication (Missing / Invalid Secret)

  • Missing Auth Header: POST /api/v1/provision (No headers)
    Result: HTTP 401 Unauthorized {"statusCode":401,"message":"Unauthorized"}
  • Invalid Secret: POST /api/v1/provision (Authorization: Bearer invalid_secret_123)
    Result: HTTP 401 Unauthorized {"statusCode":401,"message":"Unauthorized"}

Test 3: One-Time Ticket Replay Protection

  • Provisioning: Ticket jti generated and stored in Redis with 5-minute TTL.
  • 1st Consumption: POST /api/v1/ticket/consume with valid ticket
    Result: HTTP 200 OK (Issued valid session JWT & Set-Cookie headers)
  • 2nd Consumption (Replay): Immediate reuse of the identical ticket
    Result: HTTP 400 Bad Request {"statusCode":400,"message":"Ticket has already been used or has expired"}

Test 4: Concurrent Execution (Race Condition Protection)

  • Action: Dispatched two simultaneous POST /api/v1/ticket/consume requests with the exact same ticket.
  • Result:
    • Request 1: HTTP 200 OK (Atomic Redis DEL claimed the ticket)
    • Request 2: HTTP 400 Bad Request ("Ticket has already been used or has expired")

3. Conclusion & Next Steps for DOS.AI

All 7 handover requirements have been fully verified against the live environment. The blocker is resolved, and DOS.AI is cleared to proceed with merging DOS.AI PR #1912 and executing OAuth UAT for Agent Em Hương.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant