Skip to content
Open
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
38 changes: 38 additions & 0 deletions src/app/api/auth/jira/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export async function GET(request: NextRequest) {
const clientId = process.env.JIRA_CLIENT_ID;
const redirectUri = process.env.JIRA_REDIRECT_URI;

if (!clientId || !redirectUri) {
return new NextResponse("Jira client ID or redirect URI is not set", {
status: 500,
});
}

const state = crypto.randomBytes(16).toString("hex");
const codeVerifier = crypto.randomBytes(64).toString("hex");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");

const authorizationUrl =
`https://auth.atlassian.com/authorize` +
`?audience=api.atlassian.com` +
`&client_id=${encodeURIComponent(clientId)}` +
`&scope=${encodeURIComponent("read:jira-user read:jira-work")}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${encodeURIComponent(state)}` +
`&response_type=code` +
`&prompt=consent` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
`&code_challenge_method=S256`;

const response = NextResponse.redirect(authorizationUrl);
response.cookies.set("jira_code_verifier", codeVerifier, { httpOnly: true });
response.cookies.set("jira_state", state, { httpOnly: true });

return response;
}
70 changes: 70 additions & 0 deletions src/app/api/auth/jira/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/server/db/db";
import { getServerAuthSession } from "@/server/auth";

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");

const jiraState = request.cookies.get("jira_state")?.value;
const codeVerifier = request.cookies.get("jira_code_verifier")?.value;

if (!code || !state || !jiraState || !codeVerifier) {
return new NextResponse("Invalid request", { status: 400 });
}

if (state !== jiraState) {
return new NextResponse("State mismatch", { status: 400 });
}

const clientId = process.env.JIRA_CLIENT_ID ?? "";
const clientSecret = process.env.JIRA_CLIENT_SECRET ?? "";
const redirectUri = process.env.JIRA_REDIRECT_URI ?? "";

const body = {
grant_type: "authorization_code",
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
};

const tokenResponse = await fetch("https://auth.atlassian.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});

if (!tokenResponse.ok) {
return new NextResponse("Failed to exchange code for token", {
status: 500,
});
}

const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;

if (!accessToken) {
return new NextResponse("Invalid token response", { status: 500 });
}

const session = await getServerAuthSession();

if (!session?.user) {
return new NextResponse("Not authenticated", { status: 401 });
}

const userId = parseInt(session.user.id, 10);

await db.users.find(userId).update({
jiraToken: accessToken,
});

const response = NextResponse.redirect("/dashboard");
response.cookies.delete("jira_state");
response.cookies.delete("jira_code_verifier");

return response;
}
34 changes: 34 additions & 0 deletions src/app/dashboard/[org]/[repo]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import React from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";

interface SettingsPageProps {
params: {
org: string;
repo: string;
};
}

export default function SettingsPage({ params }: SettingsPageProps) {
const router = useRouter();
const { data: session } = useSession();

const handleLoginWithJira = () => {
router.push("/api/auth/jira/authorize");
};

return (
<div>
<h1>
Settings for {params.org}/{params.repo}
</h1>
{!session?.user?.jiraToken ? (
<button onClick={handleLoginWithJira}>Log in with Jira</button>
) : (
<p>Connected to Jira</p>
)}
</div>
);
}
16 changes: 5 additions & 11 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ declare module "next-auth/adapters" {
interface AdapterUser {
login?: string;
role?: UserRole;
jiraToken?: string;
}
}

Expand All @@ -34,8 +35,8 @@ declare module "next-auth" {
id: string;
login: string;
role?: UserRole;
jiraToken?: string;
expires?: string; // ISO DateString
// ...other properties
} & DefaultSession["user"];
accessToken: string;
}
Expand All @@ -45,9 +46,9 @@ declare module "next-auth" {
}

interface User {
// ...other properties
role: UserRole;
login: string;
jiraToken?: string;
expires?: string;
}
}
Expand Down Expand Up @@ -118,6 +119,7 @@ export const authOptions: NextAuthOptions = {
id: userId,
role: user.role,
login: user.login,
jiraToken: user.jiraToken,
expires: session.expires,
},
};
Expand Down Expand Up @@ -150,15 +152,7 @@ export const authOptions: NextAuthOptions = {
};
},
}),
/**
* ...add more providers here.
*
* Most other providers require a bit more work than the Discord provider. For example, the
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
*
* @see https://next-auth.js.org/providers/github
*/
// ...add more providers here.
],
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { change } from "../dbScript";

change(async (db) => {
await db.changeTable("users", (t) => ({
add: {
jiraToken: t.text().nullable(),
},
}));
});
1 change: 1 addition & 0 deletions src/server/db/tables/users.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class UsersTable extends BaseTable {
onboardingStatus: t
.enum("onboarding_status", ONBOARDING_STATUS_VALUES)
.default(OnboardingStatus.NONE),
jiraToken: t.text().nullable(),
...t.timestamps(),
}));
}