feat: implement refresh token rotation using keycloak

This commit is contained in:
Artur
2024-10-08 16:11:58 -03:00
parent a242857c14
commit b426004dc7
9 changed files with 132 additions and 40 deletions

17
auth.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { Session } from 'next-auth'
import { DefaultJWT, JWT } from 'next-auth/jwt'
declare module 'next-auth' {
interface Session extends Session {
error?: 'RefreshAccessTokenError'
}
}
declare module 'next-auth/jwt' {
interface JWT extends DefaultJWT {
accessToken: string
accessTokenExpiresAt: number
refreshToken: string
error?: 'RefreshAccessTokenError'
}
}

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react'
import { ReactNode, useEffect } from 'react'
import { signOut, useSession } from 'next-auth/react'
import { Inter } from 'next/font/google'
import SectionContainer from './SectionContainer'
@@ -12,6 +13,14 @@ interface Props {
const inter = Inter({ subsets: ['latin'] })
const LayoutWrapper = ({ children }: Props) => {
const { data: session } = useSession()
useEffect(() => {
if (session?.error === 'RefreshAccessTokenError') {
signOut()
}
}, [session])
return (
<>
<style jsx global>{`

View File

@@ -1,9 +1,27 @@
import { withAuth } from 'next-auth/middleware'
import { refreshToken } from './server/utils/auth'
export default withAuth({
pages: {
signIn: '/',
},
callbacks: {
async authorized({ token }) {
if (!token) return false
if (Date.now() < token.accessTokenExpiresAt && !token.error) {
return true
}
const newToken = await refreshToken(token)
if (Date.now() < newToken.accessTokenExpiresAt && !newToken.error) {
return true
}
return false
},
},
})
export const config = { matcher: ['/:path/account/:path*'] }

View File

@@ -5,9 +5,42 @@ import axios from 'axios'
import { env } from '../../../env.mjs'
import { KeycloakJwtPayload } from '../../../server/types'
import { refreshToken } from '../../../server/utils/auth'
export const authOptions: AuthOptions = {
session: { strategy: 'jwt' },
callbacks: {
jwt: async ({ token, user, account }) => {
// On sign in
if (user && account) {
const keycloakToken = (user as any).keycloakToken
return {
sub: user.id,
email: user.email,
accessToken: keycloakToken.access_token,
accessTokenExpiresAt: Date.now() + (keycloakToken.expires_in as number) * 1000,
refreshToken: keycloakToken.refresh_token,
}
}
// Return previous token if the access token has not expired yet
if (Date.now() < token.accessTokenExpiresAt) {
return token
}
// Refresh access token
return refreshToken(token)
},
session: ({ session, token }) => {
return {
user: {
sub: token.sub,
email: token.email,
},
error: token.error,
expires: session.expires,
}
},
},
providers: [
CredentialsProvider({
name: 'Credentials',
@@ -17,26 +50,26 @@ export const authOptions: AuthOptions = {
},
authorize: async (credentials) => {
try {
const { data } = await axios.post(
const { data: keycloakToken } = await axios.post(
`${env.KEYCLOAK_URL}/realms/${env.KEYCLOAK_REALM_NAME}/protocol/openid-connect/token`,
new URLSearchParams({
grant_type: 'password',
client_id: env.KEYCLOAK_CLIENT_ID,
client_secret: env.KEYCLOAK_CLIENT_SECRET,
grant_type: 'password',
username: credentials?.email || '',
password: credentials?.password || '',
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
)
const keycloakJwtPayload: KeycloakJwtPayload = jwtDecode(data.access_token)
const keycloakTokenPayload: KeycloakJwtPayload = jwtDecode(keycloakToken.access_token)
return {
id: keycloakJwtPayload.sub,
email: keycloakJwtPayload.email,
id: keycloakTokenPayload.sub,
email: keycloakTokenPayload.email,
keycloakToken,
}
} catch (error) {
console.log(error)
const errorMessage = (error as any).response.data.error
if (errorMessage === 'invalid_grant') {
throw new Error('INVALID_CREDENTIALS')

View File

@@ -6,7 +6,7 @@ import { useSession } from 'next-auth/react'
import { getProjects } from '../../utils/md'
import CustomLink from '../../components/CustomLink'
import { Button } from '../../components/ui/button'
import { Dialog, DialogContent, DialogTrigger } from '../../components/ui/dialog'
import { Dialog, DialogContent } from '../../components/ui/dialog'
import DonationFormModal from '../../components/DonationFormModal'
import MembershipFormModal from '../../components/MembershipFormModal'
import ProjectList from '../../components/ProjectList'

View File

@@ -3,20 +3,10 @@ import { CreateNextContextOptions } from '@trpc/server/adapters/next'
import { getServerSession } from 'next-auth/next'
import superjson from 'superjson'
import util from 'util'
import { authOptions } from '../pages/api/auth/[...nextauth]'
export const createContext = async (opts: CreateNextContextOptions) => {
const session = await getServerSession(opts.req, opts.res, {
callbacks: {
session({ session, token }) {
if (token.sub) {
session.user.sub = token.sub
}
return session
},
},
})
const session = await getServerSession(opts.req, opts.res, authOptions)
return { session }
}
@@ -56,7 +46,7 @@ export const publicProcedure = t.procedure.use((opts) => {
})
export const protectedProcedure = t.procedure.use((opts) => {
if (!opts.ctx.session?.user) {
if (!opts.ctx.session?.user || opts.ctx.session.error) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
@@ -66,7 +56,7 @@ export const protectedProcedure = t.procedure.use((opts) => {
...opts.ctx,
session: {
...opts.ctx.session,
user: opts.ctx.session?.user!,
user: opts.ctx.session.user,
},
},
})

33
server/utils/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import axios from 'axios'
import { JWT } from 'next-auth/jwt'
import { jwtDecode } from 'jwt-decode'
import { env } from '../../env.mjs'
import { KeycloakJwtPayload } from '../types'
export async function refreshToken(token: JWT): Promise<JWT> {
try {
const { data: newToken } = await axios.post(
`${env.KEYCLOAK_URL}/realms/${env.KEYCLOAK_REALM_NAME}/protocol/openid-connect/token`,
new URLSearchParams({
client_id: env.KEYCLOAK_CLIENT_ID,
client_secret: env.KEYCLOAK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: token.refreshToken as string,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
)
const jwtPayload: KeycloakJwtPayload = jwtDecode(newToken.access_token)
return {
sub: jwtPayload.sub,
email: jwtPayload.email,
accessToken: newToken.access_token,
accessTokenExpiresAt: Date.now() + (newToken.expires_in as number) * 1000,
refreshToken: newToken.refresh_token,
}
} catch (error) {
return { ...token, error: 'RefreshAccessTokenError' }
}
}

View File

@@ -1,9 +1,12 @@
import { JWT } from 'next-auth/jwt'
import axios from 'axios'
import { keycloak } from '../services'
import { env } from '../../env.mjs'
export const authenticateKeycloakClient = () =>
keycloak.auth({
clientId: env.KEYCLOAK_CLIENT_ID,
clientSecret: env.KEYCLOAK_CLIENT_SECRET,
clientId: env.KEYCLOAK_CLIENT_ID as string,
clientSecret: env.KEYCLOAK_CLIENT_SECRET as string,
grantType: 'client_credentials',
})

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"ES2023"
],
"lib": ["dom", "dom.iterable", "ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
@@ -21,13 +17,6 @@
"jsx": "preserve",
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"env.mjs"
],
"exclude": [
"node_modules"
]
}
"include": ["next-env.d.ts", "auth.d.ts", "**/*.ts", "**/*.tsx", "env.mjs"],
"exclude": ["node_modules"]
}