Skip to content

Commit 27ee17b

Browse files
committed
chore(dev): Add token rotation for keycloak provider & sync account expires date with session expires
1 parent 9400c13 commit 27ee17b

File tree

6 files changed

+149
-31
lines changed

6 files changed

+149
-31
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { ComponentProps } from "react";
2+
import Badge from "../general/Badge";
3+
4+
export const ExpiresBadge = ({
5+
expiresAt: expiresAtString,
6+
title,
7+
...props
8+
}: {
9+
title: string;
10+
expiresAt?: string | null;
11+
} & Omit<ComponentProps<typeof Badge>, "variant" | "children">) => {
12+
if (!expiresAtString) {
13+
return null;
14+
}
15+
16+
const expiresAt = new Date(expiresAtString);
17+
18+
return (
19+
<Badge variant="yellow" {...props}>
20+
{title}: {expiresAt.toLocaleString()} ({formatRelativeTime(expiresAt)})
21+
</Badge>
22+
);
23+
};
24+
25+
const formatRelativeTime = (date: Date) => {
26+
const now = new Date();
27+
const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
28+
29+
const rtf = new Intl.RelativeTimeFormat();
30+
31+
if (diffInSeconds < 60) {
32+
return rtf.format(diffInSeconds, "seconds");
33+
} else if (diffInSeconds < 3600) {
34+
return rtf.format(Math.floor(diffInSeconds / 60), "minutes");
35+
} else if (diffInSeconds < 86400) {
36+
return rtf.format(Math.floor(diffInSeconds / 3600), "hours");
37+
} else {
38+
return rtf.format(Math.floor(diffInSeconds / 86400), "days");
39+
}
40+
};

packages/dev/src/app/components/auth/payload/PayloadSessionClientWithUsePayloadSession.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { usePayloadSession } from "payload-authjs/client";
44
import Badge from "../../general/Badge";
5+
import { ExpiresBadge } from "../ExpiresBadge";
56

67
export const PayloadSessionClientWithUsePayloadSession = () => {
78
const { status, session, refresh } = usePayloadSession();
@@ -14,11 +15,12 @@ export const PayloadSessionClientWithUsePayloadSession = () => {
1415
>
1516
Status: {status}
1617
</Badge>
17-
{session?.expires && (
18-
<Badge variant="yellow" onClick={refresh}>
19-
Expires: {new Date(session.expires).toLocaleString()}
20-
</Badge>
21-
)}
18+
<ExpiresBadge title="Session Expires" expiresAt={session?.expires} onClick={refresh} />
19+
<ExpiresBadge title="Account Expires" expiresAt={session?.user.currentAccount?.expiresAt} />
20+
<ExpiresBadge
21+
title="Account Refresh Token Expires"
22+
expiresAt={session?.user.currentAccount?.refreshExpiresAt}
23+
/>
2224
{session?.collection ? (
2325
<Badge variant="dark">Collection: {session.collection}</Badge>
2426
) : null}

packages/dev/src/app/components/auth/payload/PayloadSessionServer.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { revalidateTag } from "next/cache";
22
import { getPayloadSession } from "payload-authjs";
33
import Badge from "../../general/Badge";
4+
import { ExpiresBadge } from "../ExpiresBadge";
45

56
export const PayloadSessionServer = async () => {
67
const session = await getPayloadSession();
@@ -11,20 +12,22 @@ export const PayloadSessionServer = async () => {
1112
<Badge variant={session ? "green" : "red"}>
1213
Status: {session ? "authenticated" : "unauthenticated"}
1314
</Badge>
14-
{session?.expires && (
15-
<Badge
16-
variant="yellow"
17-
onClick={async () => {
18-
"use server";
15+
<ExpiresBadge
16+
title="Session Expires"
17+
expiresAt={session?.expires}
18+
onClick={async () => {
19+
"use server";
1920

20-
revalidateTag("payload-session");
21+
revalidateTag("payload-session");
2122

22-
return Promise.resolve();
23-
}}
24-
>
25-
Expires: {new Date(session.expires).toLocaleString()}
26-
</Badge>
27-
)}
23+
return Promise.resolve();
24+
}}
25+
/>
26+
<ExpiresBadge title="Account Expires" expiresAt={session?.user.currentAccount?.expiresAt} />
27+
<ExpiresBadge
28+
title="Account Refresh Token Expires"
29+
expiresAt={session?.user.currentAccount?.refreshExpiresAt}
30+
/>
2831
{session?.collection ? (
2932
<Badge variant="dark">Collection: {session.collection}</Badge>
3033
) : null}

packages/dev/src/auth/base.config.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import jwt from "jsonwebtoken";
2-
import type { NextAuthConfig } from "next-auth";
2+
import type { NextAuthConfig, Session } from "next-auth";
33
// eslint-disable-next-line @typescript-eslint/no-unused-vars
44
import type { JWT } from "next-auth/jwt";
55
import type { PayloadAuthjsUser } from "payload-authjs";
@@ -36,7 +36,7 @@ export const authConfig: NextAuthConfig = {
3636
updateAge: 60, // 1 minute
3737
},
3838
callbacks: {
39-
jwt: ({ token, user, account, trigger }) => {
39+
jwt: async ({ token, user, account, trigger }) => {
4040
//console.log("callbacks.jwt", trigger, token, user, account);
4141

4242
/**
@@ -77,14 +77,73 @@ export const authConfig: NextAuthConfig = {
7777
token.currentAccount = {
7878
provider: account.provider,
7979
providerAccountId: account.providerAccountId,
80-
access_token: account.access_token,
81-
refresh_token: account.refresh_token,
82-
expires_at: account.expires_at
80+
accessToken: account.access_token,
81+
refreshToken: account.refresh_token,
82+
expiresAt: account.expires_at
8383
? new Date(account.expires_at * 1000).toISOString()
8484
: undefined,
85+
refreshExpiresAt:
86+
account.refresh_expires_in && typeof account.refresh_expires_in === "number"
87+
? new Date(new Date().getTime() + account.refresh_expires_in * 1000).toISOString()
88+
: undefined,
8589
};
8690
}
8791

92+
/**
93+
* Refresh access token for keycloak provider
94+
*
95+
* @see https://authjs.dev/guides/refresh-token-rotation
96+
*/
97+
if (
98+
token.currentAccount?.provider === "keycloak" &&
99+
// Access token is expired or will expire in 3 minutes
100+
token.currentAccount.expiresAt &&
101+
new Date() >=
102+
new Date(new Date(token.currentAccount.expiresAt).getTime() - 3 * 60 * 1000) &&
103+
// Refresh token is present and not expired
104+
token.currentAccount.refreshToken &&
105+
token.currentAccount.refreshExpiresAt &&
106+
new Date() < new Date(token.currentAccount.refreshExpiresAt)
107+
) {
108+
try {
109+
const response = await fetch(
110+
`${process.env.AUTH_KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
111+
{
112+
method: "POST",
113+
headers: {
114+
"Content-Type": "application/x-www-form-urlencoded",
115+
},
116+
body: new URLSearchParams({
117+
grant_type: "refresh_token",
118+
client_id: process.env.AUTH_KEYCLOAK_ID!,
119+
client_secret: process.env.AUTH_KEYCLOAK_SECRET!,
120+
refresh_token: token.currentAccount.refreshToken,
121+
}),
122+
},
123+
);
124+
125+
if (!response.ok) {
126+
throw new Error(
127+
`Request failed with status ${response.status}: ${response.statusText}`,
128+
);
129+
}
130+
131+
const refreshedTokens = await response.json();
132+
133+
token.currentAccount.accessToken = refreshedTokens.access_token;
134+
token.currentAccount.refreshToken = refreshedTokens.refresh_token;
135+
token.currentAccount.expiresAt = new Date(
136+
new Date().getTime() + refreshedTokens.expires_in * 1000,
137+
).toISOString();
138+
token.currentAccount.refreshExpiresAt = new Date(
139+
new Date().getTime() + refreshedTokens.refresh_expires_in * 1000,
140+
).toISOString();
141+
} catch (error) {
142+
// eslint-disable-next-line no-console
143+
console.error("Error refreshing access token", error);
144+
}
145+
}
146+
88147
return token;
89148
},
90149
session: ({ session, token }) => {
@@ -106,6 +165,14 @@ export const authConfig: NextAuthConfig = {
106165
session.user.currentAccount = token.currentAccount;
107166
}
108167

168+
/**
169+
* If the current account has an expires date, sync the session expires date with it
170+
*/
171+
const expiresAt = token?.currentAccount?.refreshExpiresAt || token?.currentAccount?.expiresAt;
172+
if (expiresAt) {
173+
(session as Session).expires = new Date(expiresAt).toISOString();
174+
}
175+
109176
return session;
110177
},
111178
authorized: ({ auth }) => {

packages/dev/src/payload-types.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ export interface User {
140140
currentAccount?: {
141141
provider?: string | null;
142142
providerAccountId?: string | null;
143-
access_token?: string | null;
144-
refresh_token?: string | null;
145-
expires_at?: string | null;
143+
accessToken?: string | null;
144+
refreshToken?: string | null;
145+
expiresAt?: string | null;
146+
refreshExpiresAt?: string | null;
146147
};
147148
verificationTokens?:
148149
| {
@@ -261,9 +262,10 @@ export interface UsersSelect<T extends boolean = true> {
261262
| {
262263
provider?: T;
263264
providerAccountId?: T;
264-
access_token?: T;
265-
refresh_token?: T;
266-
expires_at?: T;
265+
accessToken?: T;
266+
refreshToken?: T;
267+
expiresAt?: T;
268+
refreshExpiresAt?: T;
267269
};
268270
verificationTokens?:
269271
| T

packages/dev/src/payload/collections/users.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,19 @@ const Users: CollectionConfig = {
7171
type: "row",
7272
fields: [
7373
{
74-
name: "access_token",
74+
name: "accessToken",
7575
type: "text",
7676
},
7777
{
78-
name: "refresh_token",
78+
name: "refreshToken",
7979
type: "text",
8080
},
8181
{
82-
name: "expires_at",
82+
name: "expiresAt",
83+
type: "date",
84+
},
85+
{
86+
name: "refreshExpiresAt",
8387
type: "date",
8488
},
8589
],

0 commit comments

Comments
 (0)