Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions apps/backend/src/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
* Flow:
* 1. Validate state cookie (CSRF guard)
* 2. Exchange code for an access token via GitHub's token endpoint
* 3. Fetch the authenticated user's GitHub login
* 4. Encrypt the token with AES-256-GCM and persist github_connected,
* 3. Validate OAuth scopes — token must grant `repo` and `read:user`
* 4. Fetch the authenticated user's GitHub login
* 5. Encrypt the token with AES-256-GCM and persist github_connected,
* github_username, and github_token_encrypted on the profile row
* 5. Clear the state cookie and redirect to /app?github=connected
* 6. Clear the state cookie and redirect to /app?github=connected
*
* Error redirects:
* /app?github=error&reason=<reason>
Expand All @@ -26,17 +27,24 @@
* missing_code — no `code` param in the callback URL
* state_mismatch — CSRF state check failed
* token_exchange — GitHub rejected the code exchange
* insufficient_scopes — token is missing required repository scopes
* user_fetch — could not retrieve GitHub user info
* unauthenticated — no active Craft session
* db_error — profile update failed
*
* Required GitHub OAuth scopes:
* repo — full repository access (create, push, webhooks)
* read:user — read authenticated user profile
*
* Feature: github-oauth-callback
* Issue branch: issue-083-create-the-github-oauth-callback-route
* Scope validation: issue-122-github-oauth-scope-validation
*/

import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { encryptToken } from '@/lib/github/token-encryption';
import { fetchAndValidateScopes } from '@/lib/github/scope-validator';

const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';
Expand Down Expand Up @@ -93,7 +101,17 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
return redirectError(origin, 'token_exchange');
}

// ── 4. Fetch GitHub username ──────────────────────────────────────────────
// ── 4. Validate OAuth scopes ──────────────────────────────────────────────
// The token must grant `repo` and `read:user` before any deployment work
// is attempted. Missing scopes redirect to re-authorization rather than
// surfacing a mid-deployment failure.
const scopeResult = await fetchAndValidateScopes(accessToken);
if (!scopeResult.valid) {
const missing = scopeResult.missingScopes.join(',');
return redirectError(origin, `insufficient_scopes&missing=${encodeURIComponent(missing)}`);
}

// ── 5. Fetch GitHub username ──────────────────────────────────────────────
let githubUsername: string;
try {
const userRes = await fetch(GITHUB_USER_URL, {
Expand All @@ -117,15 +135,15 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
return redirectError(origin, 'user_fetch');
}

// ── 5. Require an active Craft session ────────────────────────────────────
// ── 6. Require an active Craft session ────────────────────────────────────
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();

if (!user) {
return redirectError(origin, 'unauthenticated');
}

// ── 6. Persist connection on the profile ──────────────────────────────────
// ── 7. Persist connection on the profile ──────────────────────────────────
const { error: dbError } = await supabase
.from('profiles')
.update({
Expand All @@ -140,7 +158,7 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
return redirectError(origin, 'db_error');
}

// ── 7. Clear state cookie and redirect to success ─────────────────────────
// ── 8. Clear state cookie and redirect to success ─────────────────────────
const response = NextResponse.redirect(`${origin}/app?github=connected`);
response.cookies.set(STATE_COOKIE, '', { maxAge: 0, path: '/' });
return response;
Expand Down
72 changes: 72 additions & 0 deletions apps/backend/src/app/api/preview/access/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* POST /api/preview/access
*
* Issues a time-limited Vercel protection bypass token for a preview deployment.
* Only authenticated users may request a token.
*
* Request body:
* { "deploymentId": "<vercel-deployment-id>" }
*
* Response (200):
* {
* "token": "<bypass-token>",
* "expiresAt": <unix-seconds>,
* "previewUrl": "<deployment-url>?x-vercel-protection-bypass=<token>"
* }
*
* Error responses:
* 400 — missing or invalid deploymentId
* 401 — not authenticated
* 503 — bypass secret not configured
*
* Feature: vercel-preview-protection-rules
* Issue: #656
*/

import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '@/lib/api/with-auth';
import { issueBypassToken } from '@/lib/vercel/preview-protection';

export const POST = withAuth(async (req: NextRequest) => {
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}

const { deploymentId, deploymentUrl } = (body ?? {}) as {
deploymentId?: string;
deploymentUrl?: string;
};

if (!deploymentId || typeof deploymentId !== 'string') {
return NextResponse.json(
{ error: 'deploymentId is required' },
{ status: 400 },
);
}

try {
const result = issueBypassToken(deploymentId);

const previewUrl = deploymentUrl
? `${deploymentUrl}?${result.queryParam}`
: undefined;

return NextResponse.json({
token: result.token,
expiresAt: result.expiresAt,
...(previewUrl ? { previewUrl } : {}),
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Internal error';
if (message.includes('VERCEL_PROTECTION_BYPASS_SECRET')) {
return NextResponse.json(
{ error: 'Preview protection is not configured on this environment' },
{ status: 503 },
);
}
return NextResponse.json({ error: message }, { status: 500 });
}
});
144 changes: 144 additions & 0 deletions apps/backend/src/lib/github/scope-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* GitHub OAuth scope validation for deployment operations.
*
* Validates that a GitHub access token grants all scopes required for CRAFT
* deployment operations before any repository work is attempted.
*
* Required scopes
* ───────────────
* repo — Full read/write access to public and private repositories.
* Required to create repos, push code, and configure webhooks.
* read:user — Read the authenticated user's profile data (login, email).
* Required during OAuth callback to persist the GitHub username.
*
* GitHub returns the granted scopes in the `X-OAuth-Scopes` response header on
* any authenticated API call. This module inspects that header and compares it
* against the required scope list.
*
* Scope hierarchy
* ───────────────
* GitHub scopes are hierarchical: `repo` covers `public_repo`, `repo:status`,
* `repo:deployment`, etc. The validator resolves this — if `repo` is granted,
* narrower `repo:*` sub-scopes are satisfied automatically.
*
* Feature: github-oauth-scope-validation
* Issue: #658
*/

const GITHUB_USER_URL = 'https://api.github.com/user';

/** All scopes CRAFT requires for deployment operations. */
export const REQUIRED_SCOPES = ['repo', 'read:user'] as const;

export type RequiredScope = (typeof REQUIRED_SCOPES)[number];

export interface ScopeValidationResult {
valid: boolean;
grantedScopes: string[];
missingScopes: RequiredScope[];
}

/**
* Broad-scope parents that implicitly satisfy narrower child scopes.
* e.g. "repo" satisfies "public_repo", "repo:status", "repo:deployment".
*/
const SCOPE_PARENTS: Record<string, string> = {
'public_repo': 'repo',
'repo:status': 'repo',
'repo:deployment': 'repo',
'repo:invite': 'repo',
'repo:hooks': 'repo',
'read:user': 'user',
'user:email': 'user',
'user:follow': 'user',
};

/**
* Returns true if `granted` satisfies `required`, accounting for scope
* hierarchy (a parent scope satisfies all its children).
*/
function scopeSatisfied(required: string, granted: Set<string>): boolean {
if (granted.has(required)) return true;
const parent = SCOPE_PARENTS[required];
return parent !== undefined && granted.has(parent);
}

/**
* Parse the comma-separated `X-OAuth-Scopes` header value into an array.
* Returns an empty array when the header is absent or empty.
*/
export function parseGrantedScopes(header: string | null): string[] {
if (!header) return [];
return header.split(',').map((s) => s.trim()).filter(Boolean);
}

/**
* Validate that `grantedScopes` covers all REQUIRED_SCOPES.
*/
export function validateScopes(grantedScopes: string[]): ScopeValidationResult {
const granted = new Set(grantedScopes);
const missingScopes = REQUIRED_SCOPES.filter(
(s) => !scopeSatisfied(s, granted),
) as RequiredScope[];

return {
valid: missingScopes.length === 0,
grantedScopes,
missingScopes,
};
}

/**
* Fetch the X-OAuth-Scopes header from GitHub by making an authenticated
* request to GET /user and reading the response headers.
*
* Returns a ScopeValidationResult. Never throws — all error paths return
* { valid: false } so callers can surface actionable messages.
*/
export async function fetchAndValidateScopes(
accessToken: string,
): Promise<ScopeValidationResult & { fetchError?: string }> {
let res: Response;
try {
res = await fetch(GITHUB_USER_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Network error';
return {
valid: false,
grantedScopes: [],
missingScopes: [...REQUIRED_SCOPES],
fetchError: message,
};
}

if (!res.ok) {
return {
valid: false,
grantedScopes: [],
missingScopes: [...REQUIRED_SCOPES],
fetchError: `GitHub API returned ${res.status}`,
};
}

const scopeHeader = res.headers.get('X-OAuth-Scopes');
const grantedScopes = parseGrantedScopes(scopeHeader);
return validateScopes(grantedScopes);
}

/**
* Build a human-readable error message listing the missing scopes.
* Used to surface re-authorization instructions to the user.
*/
export function buildMissingScopeMessage(missingScopes: RequiredScope[]): string {
const list = missingScopes.map((s) => `\`${s}\``).join(', ');
return (
`The GitHub token is missing required scopes: ${list}. ` +
'Please disconnect and reconnect your GitHub account to grant the required permissions.'
);
}
72 changes: 72 additions & 0 deletions apps/backend/src/lib/stripe/tax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Stripe Tax configuration helpers for regional subscription pricing compliance.
*
* Stripe Tax automatically calculates and applies the correct tax rates based
* on the customer's billing address. This module configures automatic tax
* collection and handles tax-exempt customers.
*
* Requirements
* ────────────
* - STRIPE_TAX_ENABLED env var must be "true" to activate tax collection.
* - Tax-exempt status is stored per-customer on the Stripe Customer object
* (tax_exempt: "none" | "exempt" | "reverse").
* - Tax-inclusive pricing is enabled in regions where required (e.g. EU VAT).
*
* Supported exemption types
* ─────────────────────────
* none : Regular taxable customer (default).
* exempt : Tax-exempt organisations (e.g. non-profits, governments).
* reverse : B2B customers in eligible regions (VAT reverse charge).
*
* Feature: stripe-tax-rate-configuration
* Issue: #655
*/

export type TaxExemptStatus = 'none' | 'exempt' | 'reverse';

export interface TaxConfiguration {
/** Whether Stripe Tax automatic calculation is enabled. */
enabled: boolean;
/** Whether to collect the customer's tax ID at checkout. */
collectTaxId: boolean;
}

/** Returns the current Stripe Tax configuration from environment variables. */
export function getTaxConfiguration(): TaxConfiguration {
return {
enabled: process.env.STRIPE_TAX_ENABLED === 'true',
collectTaxId: process.env.STRIPE_TAX_COLLECT_ID === 'true',
};
}

/**
* Returns Stripe checkout session params for automatic tax calculation.
* Call this and spread the result into the checkout session `create` call.
*/
export function buildCheckoutTaxParams(config: TaxConfiguration): {
automatic_tax?: { enabled: boolean };
tax_id_collection?: { enabled: boolean };
} {
if (!config.enabled) return {};

return {
automatic_tax: { enabled: true },
...(config.collectTaxId ? { tax_id_collection: { enabled: true } } : {}),
};
}

/**
* Returns the Stripe Customer update payload to apply a tax exemption status.
* Pass the result to `stripe.customers.update(customerId, payload)`.
*/
export function buildTaxExemptUpdate(status: TaxExemptStatus): { tax_exempt: TaxExemptStatus } {
return { tax_exempt: status };
}

/**
* Returns whether a customer's exemption status means they should not be
* charged tax.
*/
export function isTaxExempt(status: TaxExemptStatus): boolean {
return status === 'exempt' || status === 'reverse';
}
Loading
Loading