major: Migration from better-auth to keycloak
This commit is contained in:
@@ -1,14 +1,32 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { joinURL } from 'ufo'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
|
||||
export default defineEventHandler((event: H3Event) => {
|
||||
export default defineEventHandler(async (event: H3Event) => {
|
||||
const { serverApiBaseUrl, clientProxyBasePath } = useRuntimeConfig().public
|
||||
const escapedClientProxyBasePath = clientProxyBasePath.replace(/^\//, '\\/')
|
||||
// Use the escaped value in the regex
|
||||
const path = event.path.replace(new RegExp(`^${escapedClientProxyBasePath}`), '')
|
||||
const target = joinURL(serverApiBaseUrl, path)
|
||||
|
||||
const session = await getUserSession(event)
|
||||
const accessToken = session?.jwt?.accessToken
|
||||
|
||||
console.log('🔍 PROXY: proxying request, found access token:', accessToken)
|
||||
console.log('🔍 PROXY: Expiration:', new Date(jwtDecode(accessToken).exp! * 1000).toISOString())
|
||||
|
||||
if (!accessToken) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Not authenticated'
|
||||
})
|
||||
}
|
||||
|
||||
console.log('🔀 proxying request to', target)
|
||||
|
||||
return proxyRequest(event, target)
|
||||
return proxyRequest(event, target, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { auth } from '../../utils/auth'
|
||||
import type { H3Event } from 'h3'
|
||||
|
||||
export default defineEventHandler((event: H3Event) => {
|
||||
return auth.handler(toWebRequest(event))
|
||||
})
|
||||
46
legalconsenthub/server/api/jwt/refresh.post.ts
Normal file
46
legalconsenthub/server/api/jwt/refresh.post.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { OAuthTokenResponse } from '~/types/oauth'
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const session = await getUserSession(event)
|
||||
if (!session.jwt?.accessToken && !session.jwt?.refreshToken) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { access_token, refresh_token } = await $fetch<OAuthTokenResponse>(
|
||||
`http://localhost:7080/realms/legalconsenthub/protocol/openid-connect/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.oauth.keycloak.clientId,
|
||||
client_secret: config.oauth.keycloak.clientSecret,
|
||||
refresh_token: session.jwt.refreshToken
|
||||
}).toString()
|
||||
}
|
||||
)
|
||||
|
||||
await setUserSession(event, {
|
||||
jwt: {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token || session.jwt.refreshToken
|
||||
},
|
||||
loggedInAt: Date.now()
|
||||
})
|
||||
|
||||
return {
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token || session.jwt.refreshToken
|
||||
}
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: 'refresh token is invalid'
|
||||
})
|
||||
}
|
||||
})
|
||||
56
legalconsenthub/server/routes/auth/keycloak.get.ts
Normal file
56
legalconsenthub/server/routes/auth/keycloak.get.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import type { KeycloakTokenPayload, Organization } from '~/types/keycloak'
|
||||
|
||||
export default defineOAuthKeycloakEventHandler({
|
||||
async onSuccess(event, { user, tokens }) {
|
||||
const rawAccessToken = tokens?.access_token
|
||||
let decodedJwt: KeycloakTokenPayload | null = null
|
||||
|
||||
try {
|
||||
decodedJwt = jwtDecode<KeycloakTokenPayload>(rawAccessToken!)
|
||||
} catch (err) {
|
||||
console.warn('[auth] Failed to decode access token:', err)
|
||||
}
|
||||
|
||||
const organizations = decodedJwt ? extractOrganizations(decodedJwt) : []
|
||||
|
||||
await setUserSession(event, {
|
||||
user: {
|
||||
keycloakId: user.sub,
|
||||
name: user.preferred_username,
|
||||
organizations
|
||||
},
|
||||
jwt: {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in
|
||||
},
|
||||
loggedInAt: Date.now()
|
||||
})
|
||||
|
||||
return sendRedirect(event, '/')
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
console.log('error during keycloak authentication', event)
|
||||
return sendRedirect(event, '/login')
|
||||
}
|
||||
})
|
||||
|
||||
function extractOrganizations(decoded: KeycloakTokenPayload): Organization[] {
|
||||
const organizations: Organization[] = []
|
||||
const orgClaim = decoded?.organization ?? null
|
||||
|
||||
if (orgClaim && typeof orgClaim === 'object') {
|
||||
Object.entries(orgClaim).forEach(([name, meta]) => {
|
||||
if (!name || !meta?.id) return
|
||||
|
||||
organizations.push({
|
||||
name: name,
|
||||
id: meta.id
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return organizations
|
||||
}
|
||||
12
legalconsenthub/server/routes/auth/logout.get.ts
Normal file
12
legalconsenthub/server/routes/auth/logout.get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const cleared = await clearUserSession(event)
|
||||
if (!cleared) {
|
||||
console.warn('Failed to clear user session')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing user session:', error)
|
||||
}
|
||||
|
||||
return sendRedirect(event, '/login', 200)
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { betterAuth } from 'better-auth'
|
||||
import Database from 'better-sqlite3'
|
||||
import { organization, jwt } from 'better-auth/plugins'
|
||||
import { resend } from './mail'
|
||||
import {
|
||||
accessControl,
|
||||
employerRole,
|
||||
worksCouncilMemberRole,
|
||||
employeeRole,
|
||||
adminRole,
|
||||
ownerRole,
|
||||
ROLES,
|
||||
type LegalRole
|
||||
} from './permissions'
|
||||
|
||||
const db = new Database('./sqlite.db')
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: db,
|
||||
onAPIError: { throw: true },
|
||||
emailAndPassword: { enabled: true, autoSignIn: false, minPasswordLength: 1 },
|
||||
trustedOrigins: ['http://localhost:3001'],
|
||||
plugins: [
|
||||
jwt({
|
||||
jwt: {
|
||||
issuer: 'http://192.168.178.114:3001',
|
||||
expirationTime: '1yr',
|
||||
definePayload: ({ user, session }) => {
|
||||
let userRoles: string[] = []
|
||||
|
||||
if (session.activeOrganizationId) {
|
||||
try {
|
||||
const roleQuery = db.prepare(`
|
||||
SELECT role
|
||||
FROM member
|
||||
WHERE userId = ? AND organizationId = ?
|
||||
`)
|
||||
const memberRole = roleQuery.get(user.id, session.activeOrganizationId) as { role: string } | undefined
|
||||
|
||||
if (memberRole?.role) {
|
||||
userRoles = [memberRole.role]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error querying user role:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
roles: userRoles,
|
||||
organizationId: session.activeOrganizationId
|
||||
}
|
||||
}
|
||||
},
|
||||
jwks: {
|
||||
keyPairConfig: {
|
||||
// Supported by NimbusJwtDecoder
|
||||
alg: 'ES512'
|
||||
}
|
||||
}
|
||||
}),
|
||||
organization({
|
||||
// Pass the access control instance and roles
|
||||
ac: accessControl,
|
||||
roles: {
|
||||
[ROLES.EMPLOYER]: employerRole,
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: worksCouncilMemberRole,
|
||||
[ROLES.EMPLOYEE]: employeeRole,
|
||||
[ROLES.ADMIN]: adminRole,
|
||||
[ROLES.OWNER]: ownerRole
|
||||
},
|
||||
creatorRole: ROLES.ADMIN, // OWNER fixen here!
|
||||
|
||||
async sendInvitationEmail(data) {
|
||||
console.log('Sending invitation email', data)
|
||||
const inviteLink = `http://192.168.178.114:3001/accept-invitation/${data.id}`
|
||||
|
||||
const roleDisplayNames = {
|
||||
[ROLES.EMPLOYER]: 'Arbeitgeber',
|
||||
[ROLES.EMPLOYEE]: 'Arbeitnehmer',
|
||||
[ROLES.WORKS_COUNCIL_MEMBER]: 'Betriebsrat',
|
||||
[ROLES.ADMIN]: 'Administrator',
|
||||
[ROLES.OWNER]: 'Eigentümer'
|
||||
}
|
||||
|
||||
const roleDisplayName = roleDisplayNames[data.role as LegalRole] || data.role
|
||||
|
||||
try {
|
||||
const result = await resend.emails.send({
|
||||
from: 'Acme <onboarding@resend.dev>',
|
||||
to: data.email,
|
||||
subject: `Einladung als ${roleDisplayName} - ${data.organization.name}`,
|
||||
html: `
|
||||
<h2>Einladung zur Organisation ${data.organization.name}</h2>
|
||||
<p>Sie wurden als <strong>${roleDisplayName}</strong> eingeladen.</p>
|
||||
<p><a href="${inviteLink}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Einladung annehmen</a></p>
|
||||
<p>Diese Einladung läuft ab am: ${new Date(data.invitation.expiresAt).toLocaleDateString('de-DE')}</p>
|
||||
`
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Email sending failed: ${result.error.message || result.error.name || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
console.log('Email invite link:', inviteLink)
|
||||
console.log('Invitation email sent successfully to:', data.email, 'with ID:', result.data?.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invitation email:', error)
|
||||
|
||||
// Log specific error details for debugging
|
||||
const errorObj = error as { response?: { status: number; data: unknown }; message?: string }
|
||||
if (errorObj.response) {
|
||||
console.error('HTTP Status:', errorObj.response.status)
|
||||
console.error('Response data:', errorObj.response.data)
|
||||
}
|
||||
|
||||
// Re-throw the error so BetterAuth knows the email failed
|
||||
const message = errorObj.message || String(error)
|
||||
throw new Error(`Email sending failed: ${message}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
export { ROLES }
|
||||
export type { LegalRole }
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Resend } from 'resend'
|
||||
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
@@ -1,87 +0,0 @@
|
||||
import { createAccessControl } from 'better-auth/plugins/access'
|
||||
import { defaultStatements, adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
||||
import { defu } from 'defu'
|
||||
|
||||
const customStatements = {
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
} as const
|
||||
|
||||
export const statement = {
|
||||
...customStatements,
|
||||
...defaultStatements
|
||||
} as const
|
||||
|
||||
export const accessControl = createAccessControl(statement)
|
||||
|
||||
export const employerRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'approve', 'reject'],
|
||||
agreement: ['create', 'read', 'sign', 'approve'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const worksCouncilMemberRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'submit'],
|
||||
agreement: ['read', 'sign', 'approve'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'download', 'upload']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const employeeRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['read'],
|
||||
agreement: ['read'],
|
||||
comment: ['create', 'read'],
|
||||
document: ['read', 'download']
|
||||
},
|
||||
memberAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const adminRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
adminAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const ownerRole = accessControl.newRole(
|
||||
defu(
|
||||
{
|
||||
application_form: ['create', 'read', 'update', 'delete', 'approve', 'reject', 'submit'],
|
||||
agreement: ['create', 'read', 'update', 'sign', 'approve', 'reject'],
|
||||
comment: ['create', 'read', 'update', 'delete'],
|
||||
document: ['create', 'read', 'update', 'delete', 'download', 'upload']
|
||||
},
|
||||
ownerAc.statements
|
||||
) as Parameters<typeof accessControl.newRole>[0]
|
||||
)
|
||||
|
||||
export const ROLES = {
|
||||
EMPLOYER: 'employer',
|
||||
WORKS_COUNCIL_MEMBER: 'works_council_member',
|
||||
EMPLOYEE: 'employee',
|
||||
ADMIN: 'admin',
|
||||
OWNER: 'owner'
|
||||
} as const
|
||||
|
||||
export type LegalRole = (typeof ROLES)[keyof typeof ROLES]
|
||||
Reference in New Issue
Block a user