Ένας OAuth2 driver για το Keycloak που λειτουργεί με το AdonisJS Ally v7.
npm install git@github.com:GeospatialEnablingTechnologies/adonis-js-keycloak-driver.git
npm uninstall adonis-ally-keycloak
Πρόσθεσε τα παρακάτω στο .env αρχείο σου:
KEYCLOAK_SERVER_URL=https://your-keycloak-server.com
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=your-client-id
KEYCLOAK_CLIENT_SECRET=your-client-secret
KEYCLOAK_CALLBACK_URL=http://localhost:3333/auth/keycloak/callbackΆνοιξε το config/ally.ts και πρόσθεσε τον Keycloak driver:
import env from '#start/env'
import { defineConfig } from '@adonisjs/ally'
import type { InferSocialProviders } from '@adonisjs/ally/types'
import { KeycloakService } from 'adonis-ally-keycloak'
const allyConfig = defineConfig({
keycloak: KeycloakService({
serverUrl: env.get('KEYCLOAK_SERVER_URL'),
realm: env.get('KEYCLOAK_REALM'),
clientId: env.get('KEYCLOAK_CLIENT_ID'),
clientSecret: env.get('KEYCLOAK_CLIENT_SECRET'),
callbackUrl: env.get('KEYCLOAK_CALLBACK_URL'),
}),
})
export default allyConfig
declare module '@adonisjs/ally/types' {
interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}
}import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
/**
* Redirect to Keycloak for authentication
* GET /auth/keycloak
*/
async redirect({ ally, response }: HttpContext) {
const keycloak = ally.use('keycloak')
const url = await keycloak.redirectUrl()
return response.redirect(url)
}
/**
* Handle Keycloak callback
* GET /auth/keycloak/callback
*/
async callback({ ally, session, request, response }: HttpContext) {
const keycloak = ally.use('keycloak')
// Check state
if (keycloak.stateMisMatch()) {
const cookieState = request.cookie('keycloak_oauth_state')
const queryState = request.qs().state
return response.status(403).json({
error: 'State mismatch',
debug: {
cookieState,
queryState,
sessionId: session.sessionId,
}
})
}
// Check if user denied access
if (keycloak.accessDenied()) {
return response.status(401).json({
error: 'Access denied',
message: 'User denied access to the application',
})
}
// Check for state mismatch (CSRF protection)
if (keycloak.stateMisMatch()) {
return response.status(403).json({
error: 'State mismatch',
message: 'Invalid state parameter',
})
}
// Check for any other errors
if (keycloak.hasError()) {
return response.status(500).json({
error: 'Authentication error',
message: keycloak.getError(),
})
}
try {
// Get user from Keycloak
const keycloakUser = await keycloak.user()
// Store user in session
session.put('user', {
id: keycloakUser.id,
email: keycloakUser.email,
name: keycloakUser.name,
emailVerified: keycloakUser.emailVerificationState === 'verified',
avatarUrl: keycloakUser.avatarUrl,
})
// Store access token (optional - για API calls)
session.put('keycloak_token', keycloakUser.token.token)
if (keycloakUser.token.refreshToken) {
session.put('keycloak_refresh_token', keycloakUser.token.refreshToken)
}
// Redirect to dashboard ή home page
return response.redirect('/')
} catch (error) {
console.error('Keycloak authentication failed:', error)
return response.status(500).json({
error: 'Authentication failed',
message: 'Failed to authenticate with Keycloak',
})
}
}
/**
* Get current authenticated user
* GET /auth/me
*/
async me({ session, response }: HttpContext) {
const user = session.get('user')
if (!user) {
return response.status(401).json({
error: 'Not authenticated',
message: 'User is not logged in',
})
}
return response.json({
user,
})
}
/**
* Logout from Keycloak and application
* GET /auth/logout
*/
async logout({ ally, session, response }: HttpContext) {
const keycloak = ally.use('keycloak')
// Clear local session
await session.clear()
// Get Keycloak logout URL
const logoutUrl = keycloak.getLogoutUrl('http://localhost:3333')
// Redirect to Keycloak logout (this logs out from Keycloak SSO)
return response.redirect(logoutUrl)
}
/**
* Simple local logout (doesn't logout from Keycloak SSO)
* POST /auth/logout/local
*/
async logoutLocal({ session, response }: HttpContext) {
await session.clear()
return response.json({
message: 'Logged out successfully',
})
}
}Μπορείς να ορίσεις scopes με δύο τρόπους:
1. Στο configuration (default για όλα τα redirects):
// config/ally.ts
export default defineConfig({
keycloak: KeycloakService({
serverUrl: env.get('KEYCLOAK_SERVER_URL'),
realm: env.get('KEYCLOAK_REALM'),
clientId: env.get('KEYCLOAK_CLIENT_ID'),
clientSecret: env.get('KEYCLOAK_CLIENT_SECRET'),
callbackUrl: env.get('KEYCLOAK_CALLBACK_URL'),
scopes: ['openid', 'profile', 'email', 'roles'], // Default scopes
}),
})
2. Κατά το redirect (override τα default):
router.get('/auth/keycloak', async ({ ally }: HttpContext) => {
return ally
.use('keycloak')
.scopes(['openid', 'profile', 'email', 'roles'])
.redirect()
})
Αν δεν ορίσεις scopes, θα χρησιμοποιηθούν τα default: ['openid', 'profile', 'email']
router.get('/auth/keycloak', async ({ ally }: HttpContext) => {
return ally
.use('keycloak')
.redirect((request) => {
// Προσθήκη προσαρμοσμένων παραμέτρων
request.param('kc_idp_hint', 'google')
// Ή για login hint
request.param('login_hint', 'user@example.com')
})
})router.get('/logout', async ({ ally, response, session }: HttpContext) => {
const keycloak = ally.use('keycloak')
// Καθαρισμός local session
await session.clear()
// Λήψη Keycloak logout URL
const logoutUrl = keycloak.getLogoutUrl('http://localhost:3333')
// Redirect στο Keycloak για logout
return response.redirect(logoutUrl)
})Ο driver επιστρέφει ένα αντικείμενο χρήστη που υλοποιεί το AllyUserContract:
{
id: string // Keycloak user ID (sub)
nickName: string // Preferred username ή name
name: string // Full name
email: string // Email address
emailVerificationState: 'verified' | 'unverified'
avatarUrl: string | null // Profile picture URL (αν υπάρχει)
token: { // Access token information
token: string // The access token
type: 'bearer' // Token type
refreshToken?: string // Refresh token (αν υπάρχει)
expiresIn?: number // Expiration time in seconds
expiresAt?: Date // Expiration timestamp
}
original: { // Πλήρες Keycloak user object
sub: string
email_verified: boolean
name: string
preferred_username: string
given_name?: string
family_name?: string
email: string
picture?: string
// ... όλα τα πεδία από το Keycloak
}
}Για να λειτουργήσει ο driver, πρέπει να ρυθμίσεις έναν OAuth2 client στο Keycloak:
- Άνοιξε το Keycloak Admin Console
- Επίλεξε το realm σου
- Πήγαινε στο Clients > Create Client
- Ρύθμισε:
- Client ID: Το client ID που θα χρησιμοποιήσεις
- Client Protocol: openid-connect
- Access Type: confidential
- Valid Redirect URIs:
http://localhost:3333/auth/keycloak/callback(ή το δικό σου URL) - Web Origins:
http://localhost:3333(ή το δικό σου domain)
- Αντίγραψε το Client Secret από το Credentials tab
Τα πιο κοινά Keycloak scopes:
openid- Βασικό OpenID Connect scope (απαραίτητο)profile- Βασικά στοιχεία προφίλ (name, username, κτλ.)email- Email address και email verification statusaddress- Διεύθυνση χρήστηphone- Τηλέφωνο χρήστηoffline_access- Refresh token για offline πρόσβασηroles- Keycloak roles- Custom scopes που έχεις ορίσει στο Keycloak
router.get('/auth/keycloak/callback', async ({ ally, response }: HttpContext) => {
const keycloak = ally.use('keycloak')
if (keycloak.accessDenied()) {
return response.status(401).send('Ο χρήστης αρνήθηκε την πρόσβαση')
}
if (keycloak.stateMisMatch()) {
return response.status(403).send('State mismatch error')
}
if (keycloak.hasError()) {
const error = keycloak.getError()
return response.status(500).send(`Authentication error: ${error}`)
}
try {
const user = await keycloak.user()
// ...
} catch (error) {
return response.status(500).send('Failed to fetch user')
}
})// app/middleware/auth_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class AuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
// Έλεγχος αν ο χρήστης είναι authenticated
// ...
await next()
}
}router.get('/auth/keycloak/callback', async ({ ally, session, response }: HttpContext) => {
const keycloak = ally.use('keycloak')
// Λήψη access token
const accessToken = await keycloak.accessToken()
// Αποθήκευση στο session
session.put('keycloak_token', accessToken.token)
if (accessToken.refreshToken) {
session.put('keycloak_refresh_token', accessToken.refreshToken)
}
// Λήψη user
const user = await keycloak.user()
// ...
})MIT
Για issues και questions, παρακαλώ άνοιξε ένα issue στο GitHub repository.