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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ CLERK_SECRET_KEY=
CLERK_WEBHOOK_SECRET=
NEXT_PUBLIC_COOKIE_DOMAIN=localhost

# -----------------------------------------------------------------------------
# OAuth Credentials (for Desktop App direct auth)
# -----------------------------------------------------------------------------
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GH_CLIENT_ID=
GH_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# Blob Storage
# -----------------------------------------------------------------------------
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ jobs:
CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: |
vercel pull --yes --environment=preview --token=$VERCEL_TOKEN
vercel build --token=$VERCEL_TOKEN
Expand All @@ -139,7 +143,11 @@ jobs:
--env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET)
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \
--env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \
--env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \
--env GH_CLIENT_ID=$GH_CLIENT_ID \
--env GH_CLIENT_SECRET=$GH_CLIENT_SECRET)
vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN
echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT

Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ name: Deploy Production
on:
push:
branches: [main]
paths-ignore:
- "apps/desktop/**"
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -79,6 +77,10 @@ jobs:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
DESKTOP_AUTH_SECRET: ${{ secrets.DESKTOP_AUTH_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }}
run: |
vercel pull --yes --environment=production --token=$VERCEL_TOKEN
vercel build --prod --token=$VERCEL_TOKEN
Expand All @@ -91,7 +93,11 @@ jobs:
--env NEXT_PUBLIC_WEB_URL=$NEXT_PUBLIC_WEB_URL \
--env NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
--env NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY \
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET
--env DESKTOP_AUTH_SECRET=$DESKTOP_AUTH_SECRET \
--env GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID \
--env GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET \
--env GH_CLIENT_ID=$GH_CLIENT_ID \
--env GH_CLIENT_SECRET=$GH_CLIENT_SECRET

deploy-web:
name: Deploy Web to Vercel
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ jobs:
env:
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }}
NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }}
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
run: bun run compile:app

# Build the Electron app for macOS
Expand Down
207 changes: 207 additions & 0 deletions apps/api/src/app/api/auth/desktop/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { clerkClient } from "@clerk/nextjs/server";
import { env } from "@/env";
import { generateTokens } from "../tokens";

/**
* GitHub OAuth token response
*/
interface GitHubTokenResponse {
access_token: string;
token_type: string;
scope: string;
}

/**
* GitHub user response
*/
interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
}

/**
* GitHub email response
*/
interface GitHubEmail {
email: string;
primary: boolean;
verified: boolean;
visibility: string | null;
}

/**
* Exchange GitHub auth code for tokens and create desktop session
*
* POST /api/auth/desktop/github
* Body: { code: string, redirectUri: string }
* Returns: { accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt }
*/
export async function POST(request: Request) {
try {
const body = await request.json();
const { code, redirectUri } = body as {
code: string;
redirectUri: string;
};

if (!code || !redirectUri) {
return Response.json(
{ error: "Missing code or redirectUri" },
{ status: 400 },
);
}

// Exchange code for access token with GitHub
const tokenResponse = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: env.GH_CLIENT_ID,
client_secret: env.GH_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
}),
},
);

if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
console.error("[auth/github] Token exchange failed:", errorData);
return Response.json({ error: "Token exchange failed" }, { status: 400 });
}

const tokenData: GitHubTokenResponse = await tokenResponse.json();

if (!tokenData.access_token) {
console.error("[auth/github] No access token in response:", tokenData);
return Response.json(
{ error: "No access token received" },
{ status: 400 },
);
}

// Fetch user info from GitHub
const userResponse = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});

if (!userResponse.ok) {
console.error("[auth/github] Failed to fetch user info");
return Response.json(
{ error: "Failed to fetch user info" },
{ status: 400 },
);
}

const githubUser: GitHubUser = await userResponse.json();

// Always fetch verified email from /user/emails endpoint
// Never trust githubUser.email as it could be unverified
const emailsResponse = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});

if (!emailsResponse.ok) {
console.error("[auth/github] Failed to fetch user emails");
return Response.json(
{ error: "Failed to fetch user emails" },
{ status: 400 },
);
}

const emails: GitHubEmail[] = await emailsResponse.json();
// Only trust verified emails - prefer primary+verified, fallback to any verified
const primaryVerifiedEmail = emails.find((e) => e.primary && e.verified);
const anyVerifiedEmail = emails.find((e) => e.verified);
const email =
primaryVerifiedEmail?.email || anyVerifiedEmail?.email || null;

if (!email) {
return Response.json(
{ error: "No verified email found on GitHub account" },
{ status: 400 },
);
}

// Parse name into first/last
const nameParts = (githubUser.name || "").split(" ");
const firstName = nameParts[0] || undefined;
const lastName = nameParts.slice(1).join(" ") || undefined;

// Find or create user in Clerk
const clerk = await clerkClient();
const existingUsers = await clerk.users.getUserList({
emailAddress: [email],
});

let userId: string;
const existingUser = existingUsers.data[0];

if (existingUser) {
userId = existingUser.id;
console.log("[auth/github] Found existing user:", userId);
} else {
// Create new user
try {
const newUser = await clerk.users.createUser({
emailAddress: [email],
firstName,
lastName,
skipPasswordRequirement: true,
});
userId = newUser.id;
console.log("[auth/github] Created new user:", userId);

// Mark the email as verified since GitHub already verified it
const emailId = newUser.emailAddresses[0]?.id;
if (emailId) {
await clerk.emailAddresses.updateEmailAddress(emailId, {
verified: true,
});
console.log("[auth/github] Marked email as verified");
}
} catch (clerkError: unknown) {
// Log and return detailed Clerk error
const errorDetails =
clerkError && typeof clerkError === "object" && "errors" in clerkError
? (clerkError as { errors: unknown[] }).errors
: clerkError;
console.error(
"[auth/github] Clerk createUser failed:",
JSON.stringify(errorDetails, null, 2),
);
return Response.json(
{
error: "Failed to create user account",
details: errorDetails,
},
{ status: 400 },
);
}
}

// Generate access and refresh tokens
const tokens = await generateTokens(userId, email);

return Response.json(tokens);
} catch (error) {
console.error("[auth/github] Error:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
Loading
Loading