Skip to content

Commit b5824bc

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

File tree

9 files changed

+2442
-4115
lines changed

9 files changed

+2442
-4115
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/login/vercel/actions.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use server";
2+
3+
import { cookies as getCookies, headers as getHeaders } from "next/headers";
4+
import { redirect } from "next/navigation";
5+
import { createAuthorizationUrl } from "./issuer";
6+
7+
export async function startAuthorization(formData: FormData) {
8+
console.log("startAuthorization:", Object.fromEntries(formData));
9+
const headers = getHeaders();
10+
const cookies = getCookies();
11+
12+
const host = headers.get("host");
13+
14+
const protocol = host?.startsWith("localhost") ? "http" : "https";
15+
const callbackUrl = `${protocol}://${host}/login/vercel/callback`;
16+
const { redirectTo, state } = await createAuthorizationUrl({
17+
callbackUrl,
18+
});
19+
console.log("Redirecting to authorization URL:", {
20+
redirectTo,
21+
state,
22+
});
23+
cookies.set("vercel-oidc-state", state, { httpOnly: true });
24+
redirect(redirectTo);
25+
}
26+
27+
export async function startImplicitAuthorization(formData: FormData) {
28+
console.log("startImplicitAuthorization:", Object.fromEntries(formData));
29+
const headers = getHeaders();
30+
const cookies = getCookies();
31+
32+
const host = headers.get("host");
33+
34+
const protocol = host?.startsWith("localhost") ? "http" : "https";
35+
const callbackUrl = `${protocol}://${host}/login/vercel/callback-implicit`;
36+
const { redirectTo, state } = await createAuthorizationUrl({
37+
callbackUrl,
38+
explicit: false,
39+
});
40+
console.log("Redirecting to authorization URL:", {
41+
redirectTo,
42+
state,
43+
});
44+
cookies.set("vercel-oidc-state", state, { httpOnly: true });
45+
redirect(redirectTo);
46+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use server";
2+
3+
import { cookies as getCookies } from "next/headers";
4+
import { validateImplicitAuthorization } from "../issuer";
5+
6+
export async function validateImplicitAuthorizationAction(
7+
oidcParams: Record<string, string>
8+
) {
9+
const { id_token, state } = oidcParams;
10+
11+
const cookies = await getCookies();
12+
const expectedState = cookies.get("vercel-oidc-state")?.value || undefined;
13+
14+
const claims = await validateImplicitAuthorization({
15+
id_token,
16+
state,
17+
expectedState,
18+
});
19+
20+
return { claims };
21+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import { useEffect, useState, useTransition } from "react";
4+
import { validateImplicitAuthorizationAction } from "./actions";
5+
import { set } from "lodash";
6+
7+
export default function ImplicitOidcCallbackPage() {
8+
const [oidcParams, setOidcParams] = useState<Record<string, string>>({});
9+
10+
useEffect(() => {
11+
const hash = window.location.hash;
12+
if (hash) {
13+
const params = new URLSearchParams(hash.replace("#", "?"));
14+
setOidcParams(Object.fromEntries(params));
15+
}
16+
}, []);
17+
18+
const [isPending, startTransition] = useTransition();
19+
const [error, setError] = useState<string | null>(null);
20+
const [claims, setClaims] = useState<Record<string, any> | null>(null);
21+
22+
const handleValidate = () => {
23+
setError(null);
24+
startTransition(async () => {
25+
try {
26+
const response = await validateImplicitAuthorizationAction(oidcParams);
27+
setClaims(response.claims);
28+
} catch {
29+
setError("Failed to validate OIDC authorization");
30+
}
31+
});
32+
};
33+
34+
return (
35+
<div className="bg-gray-100 h-screen flex items-center justify-center">
36+
<div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 w-full max-w-sm flex flex-col gap-4">
37+
<h1 className="block text-gray-700 text-xl font-bold mb-6 text-center">
38+
Implicit OIDC Callback
39+
</h1>
40+
<pre>{JSON.stringify(oidcParams, null, 2)}</pre>
41+
<form method="POST" action={handleValidate}>
42+
<button
43+
type="submit"
44+
disabled={isPending || !oidcParams.id_token}
45+
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
46+
>
47+
Validate response
48+
</button>
49+
</form>
50+
<div>
51+
{isPending ? (
52+
<p>Validating...</p>
53+
) : claims ? (
54+
<pre>{JSON.stringify(claims, null, 2)}</pre>
55+
) : (
56+
<div />
57+
)}
58+
</div>
59+
{error ? <div className="bg-red-600">{error}</div> : null}
60+
</div>
61+
</div>
62+
);
63+
}

app/login/vercel/callback/route.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { cookies as getCookies } from "next/headers";
2+
import { getTokens } from "../issuer";
3+
4+
export async function GET(req: Request) {
5+
const url = req.url;
6+
7+
const cookies = await getCookies();
8+
const expectedState = cookies.get("vercel-oidc-state")?.value || undefined;
9+
console.log("Callback:", { url, expectedState });
10+
11+
const { id_token, claims } = (await getTokens(url, expectedState)) || {};
12+
13+
if (id_token) {
14+
cookies.set("id-token", id_token);
15+
}
16+
17+
// TODO: redirect to the /dashboard
18+
return Response.json({ id_token, claims });
19+
}

app/login/vercel/issuer.ts

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

app/login/vercel/page.tsx

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"lodash": "^4.17.21",
1515
"nanoid": "^5.0.7",
1616
"next": "14.2.6",
17+
"openid-client": "^6.6.2",
1718
"react": "^18",
1819
"react-dom": "^18",
1920
"zod": "^3.22.4"

0 commit comments

Comments
 (0)