Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.vscode/launch.json
/test*.sh
/test*.json
/test*.mjs
61 changes: 0 additions & 61 deletions app/callback/route.ts

This file was deleted.

52 changes: 52 additions & 0 deletions app/login/vercel/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use server";

import { cookies as getCookies, headers as getHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { createAuthorizationUrl, OIDC_ISSUER } from "./issuer";

export async function maybeStartAuthorizationAuto(
params: Record<string, string>
) {
// See https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
const { iss, login_hint, target_link_uri, v_deeplink } = params;
if (iss !== OIDC_ISSUER || !login_hint) {
return;
}

const headers = getHeaders();
const cookies = getCookies();

const host = headers.get("host");

const protocol = host?.startsWith("localhost") ? "http" : "https";
const callbackUrl = `${protocol}://${host}/login/vercel/callback`;

const { redirectTo, state } = await createAuthorizationUrl({
callbackUrl,
login_hint,
v_deeplink,
});

cookies.set("vercel-oidc-state", state, { httpOnly: true });
return redirect(redirectTo);
}

export async function startAuthorization(formData: FormData) {
console.log("startAuthorization:", Object.fromEntries(formData));
const headers = getHeaders();
const cookies = getCookies();

const host = headers.get("host");

const protocol = host?.startsWith("localhost") ? "http" : "https";
const callbackUrl = `${protocol}://${host}/login/vercel/callback`;
const { redirectTo, state } = await createAuthorizationUrl({
callbackUrl,
});
console.log("Redirecting to authorization URL:", {
redirectTo,
state,
});
cookies.set("vercel-oidc-state", state, { httpOnly: true });
redirect(redirectTo);
}
57 changes: 57 additions & 0 deletions app/login/vercel/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { cookies as getCookies } from "next/headers";
import { getTokens } from "../issuer";
import { createSession } from "@/app/dashboard/auth";
import { redirect } from "next/navigation";

export async function GET(req: Request) {
const url = req.url;

const params = Object.fromEntries(new URL(url).searchParams);
const { v_deeplink } = params;

const cookies = await getCookies();
const expectedState = cookies.get("vercel-oidc-state")?.value || undefined;
console.log("Callback:", { url, expectedState });

const { id_token, claims } = (await getTokens(url, expectedState)) || {};
console.log("OIDC Callback:", { id_token, claims, v_deeplink });

if (id_token) {
createSession(id_token);
}

const deepLinkParams = new URLSearchParams(v_deeplink || "");

const resourceId = deepLinkParams.get("resource_id");
const projectId = deepLinkParams.get("project_id");
const invoiceId = deepLinkParams.get("invoice_id");
const checkId = deepLinkParams.get("check_id");

if (invoiceId) {
return redirect(`/dashboard/invoices?id=${invoiceId}`);
}

if (deepLinkParams.get("support")) {
return redirect(
`/dashboard/support${resourceId ? "?resource_id=" + resourceId : ""}`
);
}

if (resourceId) {
if (projectId) {
if (checkId) {
return redirect(
`/dashboard/resources/${resourceId}/projects/${projectId}?checkId=${encodeURIComponent(checkId)}`
);
}

return redirect(
`/dashboard/resources/${resourceId}/projects/${projectId}`
);
}

return redirect(`/dashboard/resources/${resourceId}`);
}

redirect("/dashboard");
}
140 changes: 140 additions & 0 deletions app/login/vercel/issuer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {
allowInsecureRequests,
authorizationCodeGrant,
buildAuthorizationUrl,
Configuration,
discovery,
IDToken,
randomState,
} from "openid-client";
import { createRemoteJWKSet, jwtVerify } from "jose";

export const OIDC_ISSUER =
process.env.OIDC_ISSUER || "https://marketplace.vercel.com";

let clientPromise: Promise<Configuration> | undefined;
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;

export async function getOidcConfiguration(): Promise<Configuration> {
if (clientPromise) {
return clientPromise;
}

clientPromise = (async () => {
try {
const oidcClientId = process.env.INTEGRATION_CLIENT_ID;
if (!oidcClientId) {
throw new Error("Missing INTEGRATION_CLIENT_ID environment variable");
}
const oidcClientSecret = process.env.INTEGRATION_CLIENT_SECRET;
if (!oidcClientSecret) {
throw new Error(
"Missing INTEGRATION_CLIENT_SECRET environment variable"
);
}
const configuration = await discovery(
new URL(OIDC_ISSUER),
oidcClientId,
{
client_id: oidcClientId,
client_secret: oidcClientSecret,
},
undefined,
{
algorithm: "oidc",
execute: [allowInsecureRequests],
}
);
console.log("Discovered configuration: ", configuration.serverMetadata());
return configuration;
} catch (error) {
console.error(
"Error discovering OIDC issuer or initializing client:",
error
);
throw error;
}
})();
return clientPromise;
}

export async function createAuthorizationUrl({
callbackUrl,
login_hint,
v_deeplink,
explicit = true,
}: {
callbackUrl: string;
login_hint?: string;
v_deeplink?: string;
explicit?: boolean;
}): Promise<{
redirectTo: string;
state: string;
}> {
const config = await getOidcConfiguration();

const state = randomState();

const redirectTo = buildAuthorizationUrl(config, {
redirect_uri: callbackUrl,
scope: "openid",
state,
response_type: explicit ? "code" : "id_token",
...(login_hint ? { login_hint } : null),
...(v_deeplink ? { v_deeplink } : null),
});

return {
redirectTo: redirectTo.toString(),
state,
};
}

async function getJwks() {
if (!jwks) {
const config = await getOidcConfiguration();
const serverMetadata = config.serverMetadata();
if (!serverMetadata.jwks_uri) {
throw new Error("JWKS URI not found in server metadata.");
}
console.log("Creating JWKS from server metadata:", serverMetadata.jwks_uri);
jwks = createRemoteJWKSet(new URL(serverMetadata.jwks_uri));
}
return jwks;
}

async function validateIdToken(id_token: string): Promise<IDToken> {
const jwks = await getJwks();
const token = await jwtVerify<IDToken>(id_token, jwks);
console.log("ID Token claims:", token.payload);
return token.payload;
}

export async function getTokens(
currentUrl: string,
expectedState: string | undefined
): Promise<{ id_token: string; claims: IDToken } | null> {
const config = await getOidcConfiguration();

const tokens = await authorizationCodeGrant(config, new URL(currentUrl), {
expectedState,
idTokenExpected: true,
});

console.log("Token Endpoint Response", tokens);

const id_token = tokens.id_token;
if (!id_token) {
console.warn("No ID token received from the token endpoint.");
return null;
}

const claims2 = tokens.claims();
console.log("Claims2", claims2);

const claims = await validateIdToken(id_token);
console.log("Token Endpoint Response claims", claims);

return { id_token, claims };
}
22 changes: 22 additions & 0 deletions app/login/vercel/prompt/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { startAuthorization } from "../actions";

export default async function LoginVercelPage() {
return (
<div className="bg-gray-100 h-screen flex items-center justify-center">
<div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm">
<h1 className="block text-gray-700 text-xl font-bold mb-6 text-center">
Login via Vercel Marketplace
</h1>
<form method="POST" className="flex items-center justify-between gap-4">
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
formAction={startAuthorization}
>
Login with explicit flow
</button>
</form>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions app/login/vercel/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { maybeStartAuthorizationAuto } from "./actions";

export async function GET(req: Request) {
const params = Object.fromEntries(new URL(req.url).searchParams);
await maybeStartAuthorizationAuto(params);

return Response.redirect(new URL("/login/vercel/prompt", req.url), 307);
}
Loading