mirror of
https://github.com/MAGICGrants/campaign-site.git
synced 2026-01-09 12:27:59 -05:00
feat: implement refresh token rotation using keycloak
This commit is contained in:
17
auth.d.ts
vendored
Normal file
17
auth.d.ts
vendored
Normal 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'
|
||||
}
|
||||
}
|
||||
@@ -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>{`
|
||||
|
||||
@@ -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*'] }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
33
server/utils/auth.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user