Skip to content

Commit e481708

Browse files
committed
OIDC Client implementation
1 parent 8e19b4f commit e481708

File tree

10 files changed

+2408
-4206
lines changed

10 files changed

+2408
-4206
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,8 @@ yarn-error.log*
3434
# typescript
3535
*.tsbuildinfo
3636
next-env.d.ts
37+
38+
.vscode/launch.json
39+
/test*.sh
40+
/test*.json
41+
/test*.mjs

app/callback/route.ts

Lines changed: 0 additions & 61 deletions
This file was deleted.

app/login/vercel/actions.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use server";
2+
3+
import { cookies as getCookies, headers as getHeaders } from "next/headers";
4+
import { redirect } from "next/navigation";
5+
import { createAuthorizationUrl, OIDC_ISSUER } from "./issuer";
6+
7+
export async function maybeStartAuthorizationAuto(
8+
params: Record<string, string>
9+
) {
10+
// See https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
11+
const { iss, login_hint, target_link_uri, v_deeplink } = params;
12+
if (iss !== OIDC_ISSUER || !login_hint) {
13+
return;
14+
}
15+
16+
const headers = getHeaders();
17+
const cookies = getCookies();
18+
19+
const host = headers.get("host");
20+
21+
const protocol = host?.startsWith("localhost") ? "http" : "https";
22+
const callbackUrl = `${protocol}://${host}/login/vercel/callback`;
23+
24+
const { redirectTo, state } = await createAuthorizationUrl({
25+
callbackUrl,
26+
login_hint,
27+
v_deeplink,
28+
});
29+
30+
cookies.set("vercel-oidc-state", state, { httpOnly: true });
31+
return redirect(redirectTo);
32+
}
33+
34+
export async function startAuthorization(formData: FormData) {
35+
console.log("startAuthorization:", Object.fromEntries(formData));
36+
const headers = getHeaders();
37+
const cookies = getCookies();
38+
39+
const host = headers.get("host");
40+
41+
const protocol = host?.startsWith("localhost") ? "http" : "https";
42+
const callbackUrl = `${protocol}://${host}/login/vercel/callback`;
43+
const { redirectTo, state } = await createAuthorizationUrl({
44+
callbackUrl,
45+
});
46+
console.log("Redirecting to authorization URL:", {
47+
redirectTo,
48+
state,
49+
});
50+
cookies.set("vercel-oidc-state", state, { httpOnly: true });
51+
redirect(redirectTo);
52+
}

app/login/vercel/callback/route.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { cookies as getCookies } from "next/headers";
2+
import { getTokens } from "../issuer";
3+
import { createSession } from "@/app/dashboard/auth";
4+
import { redirect } from "next/navigation";
5+
6+
export async function GET(req: Request) {
7+
const url = req.url;
8+
9+
const params = Object.fromEntries(new URL(url).searchParams);
10+
const { v_deeplink } = params;
11+
12+
const cookies = await getCookies();
13+
const expectedState = cookies.get("vercel-oidc-state")?.value || undefined;
14+
console.log("Callback:", { url, expectedState });
15+
16+
const { id_token, claims } = (await getTokens(url, expectedState)) || {};
17+
console.log("OIDC Callback:", { id_token, claims, v_deeplink });
18+
19+
if (id_token) {
20+
createSession(id_token);
21+
}
22+
23+
const deepLinkParams = new URLSearchParams(v_deeplink || "");
24+
25+
const resourceId = deepLinkParams.get("resource_id");
26+
const projectId = deepLinkParams.get("project_id");
27+
const invoiceId = deepLinkParams.get("invoice_id");
28+
const checkId = deepLinkParams.get("check_id");
29+
30+
if (invoiceId) {
31+
return redirect(`/dashboard/invoices?id=${invoiceId}`);
32+
}
33+
34+
if (deepLinkParams.get("support")) {
35+
return redirect(
36+
`/dashboard/support${resourceId ? "?resource_id=" + resourceId : ""}`
37+
);
38+
}
39+
40+
if (resourceId) {
41+
if (projectId) {
42+
if (checkId) {
43+
return redirect(
44+
`/dashboard/resources/${resourceId}/projects/${projectId}?checkId=${encodeURIComponent(checkId)}`
45+
);
46+
}
47+
48+
return redirect(
49+
`/dashboard/resources/${resourceId}/projects/${projectId}`
50+
);
51+
}
52+
53+
return redirect(`/dashboard/resources/${resourceId}`);
54+
}
55+
56+
redirect("/dashboard");
57+
}

app/login/vercel/issuer.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import {
2+
allowInsecureRequests,
3+
authorizationCodeGrant,
4+
buildAuthorizationUrl,
5+
Configuration,
6+
discovery,
7+
IDToken,
8+
randomState,
9+
} from "openid-client";
10+
import { createRemoteJWKSet, jwtVerify } from "jose";
11+
12+
export const OIDC_ISSUER =
13+
process.env.OIDC_ISSUER || "https://marketplace.vercel.com";
14+
15+
let clientPromise: Promise<Configuration> | undefined;
16+
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
17+
18+
export async function getOidcConfiguration(): Promise<Configuration> {
19+
if (clientPromise) {
20+
return clientPromise;
21+
}
22+
23+
clientPromise = (async () => {
24+
try {
25+
const oidcClientId = process.env.INTEGRATION_CLIENT_ID;
26+
if (!oidcClientId) {
27+
throw new Error("Missing INTEGRATION_CLIENT_ID environment variable");
28+
}
29+
const oidcClientSecret = process.env.INTEGRATION_CLIENT_SECRET;
30+
if (!oidcClientSecret) {
31+
throw new Error(
32+
"Missing INTEGRATION_CLIENT_SECRET environment variable"
33+
);
34+
}
35+
const configuration = await discovery(
36+
new URL(OIDC_ISSUER),
37+
oidcClientId,
38+
{
39+
client_id: oidcClientId,
40+
client_secret: oidcClientSecret,
41+
},
42+
undefined,
43+
{
44+
algorithm: "oidc",
45+
execute: [allowInsecureRequests],
46+
}
47+
);
48+
console.log("Discovered configuration: ", configuration.serverMetadata());
49+
return configuration;
50+
} catch (error) {
51+
console.error(
52+
"Error discovering OIDC issuer or initializing client:",
53+
error
54+
);
55+
throw error;
56+
}
57+
})();
58+
return clientPromise;
59+
}
60+
61+
export async function createAuthorizationUrl({
62+
callbackUrl,
63+
login_hint,
64+
v_deeplink,
65+
explicit = true,
66+
}: {
67+
callbackUrl: string;
68+
login_hint?: string;
69+
v_deeplink?: string;
70+
explicit?: boolean;
71+
}): Promise<{
72+
redirectTo: string;
73+
state: string;
74+
}> {
75+
const config = await getOidcConfiguration();
76+
77+
const state = randomState();
78+
79+
const redirectTo = buildAuthorizationUrl(config, {
80+
redirect_uri: callbackUrl,
81+
scope: "openid",
82+
state,
83+
response_type: explicit ? "code" : "id_token",
84+
...(login_hint ? { login_hint } : null),
85+
...(v_deeplink ? { v_deeplink } : null),
86+
});
87+
88+
return {
89+
redirectTo: redirectTo.toString(),
90+
state,
91+
};
92+
}
93+
94+
async function getJwks() {
95+
if (!jwks) {
96+
const config = await getOidcConfiguration();
97+
const serverMetadata = config.serverMetadata();
98+
if (!serverMetadata.jwks_uri) {
99+
throw new Error("JWKS URI not found in server metadata.");
100+
}
101+
console.log("Creating JWKS from server metadata:", serverMetadata.jwks_uri);
102+
jwks = createRemoteJWKSet(new URL(serverMetadata.jwks_uri));
103+
}
104+
return jwks;
105+
}
106+
107+
async function validateIdToken(id_token: string): Promise<IDToken> {
108+
const jwks = await getJwks();
109+
const token = await jwtVerify<IDToken>(id_token, jwks);
110+
console.log("ID Token claims:", token.payload);
111+
return token.payload;
112+
}
113+
114+
export async function getTokens(
115+
currentUrl: string,
116+
expectedState: string | undefined
117+
): Promise<{ id_token: string; claims: IDToken } | null> {
118+
const config = await getOidcConfiguration();
119+
120+
const tokens = await authorizationCodeGrant(config, new URL(currentUrl), {
121+
expectedState,
122+
idTokenExpected: true,
123+
});
124+
125+
console.log("Token Endpoint Response", tokens);
126+
127+
const id_token = tokens.id_token;
128+
if (!id_token) {
129+
console.warn("No ID token received from the token endpoint.");
130+
return null;
131+
}
132+
133+
const claims2 = tokens.claims();
134+
console.log("Claims2", claims2);
135+
136+
const claims = await validateIdToken(id_token);
137+
console.log("Token Endpoint Response claims", claims);
138+
139+
return { id_token, claims };
140+
}

app/login/vercel/prompt/page.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { startAuthorization } from "../actions";
2+
3+
export default async function LoginVercelPage() {
4+
return (
5+
<div className="bg-gray-100 h-screen flex items-center justify-center">
6+
<div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm">
7+
<h1 className="block text-gray-700 text-xl font-bold mb-6 text-center">
8+
Login via Vercel Marketplace
9+
</h1>
10+
<form method="POST" className="flex items-center justify-between gap-4">
11+
<button
12+
type="submit"
13+
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
14+
formAction={startAuthorization}
15+
>
16+
Login with explicit flow
17+
</button>
18+
</form>
19+
</div>
20+
</div>
21+
);
22+
}

app/login/vercel/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { maybeStartAuthorizationAuto } from "./actions";
2+
3+
export async function GET(req: Request) {
4+
const params = Object.fromEntries(new URL(req.url).searchParams);
5+
await maybeStartAuthorizationAuto(params);
6+
7+
return Response.redirect(new URL("/login/vercel/prompt", req.url), 307);
8+
}

0 commit comments

Comments
 (0)