first commit
This commit is contained in:
commit
9b5f083aec
19 changed files with 3754 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
client/.vite
|
||||||
|
client/dist
|
||||||
15
README.md
Normal file
15
README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Keycloak OIDC + Vite + Express
|
||||||
|
|
||||||
|
Progetto full-stack con login tramite OIDC/PKCE su Keycloak, usando `openid-client`.
|
||||||
|
Serve il frontend Vite attraverso Express (middleware in dev, static build in prod).
|
||||||
|
|
||||||
|
## Requisiti
|
||||||
|
- Node 18+
|
||||||
|
- Keycloak con realm e client configurati:
|
||||||
|
- Client type: Public (PKCE)
|
||||||
|
- Valid Redirect URIs: https://my.patachina2.casacam.net/auth/callback
|
||||||
|
- Web Origins: https://my.patachina2.casacam.net
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. Copia `.env` e inserisci i tuoi valori (issuer discovery, client id, redirect).
|
||||||
|
2. Installa:
|
||||||
15
README1.md
Normal file
15
README1.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
├── @types/cookie-parser@1.4.10
|
||||||
|
├── @types/express@4.17.25
|
||||||
|
├── @types/react-dom@19.2.3
|
||||||
|
├── @types/react@19.2.7
|
||||||
|
├── @vitejs/plugin-react@4.7.0
|
||||||
|
├── cookie-parser@1.4.7
|
||||||
|
├── cross-env@7.0.3
|
||||||
|
├── dotenv@17.2.3
|
||||||
|
├── express@4.21.2
|
||||||
|
├── openid-client@5.7.1
|
||||||
|
├── react-dom@19.2.0
|
||||||
|
├── react@19.2.0
|
||||||
|
├── ts-node-dev@2.0.0
|
||||||
|
├── typescript@5.9.3
|
||||||
|
└── vite@5.4.21
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Keycloak OIDC Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
client/src/App.tsx
Normal file
28
client/src/App.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getUserinfo } from './api';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [user, setUser] = useState<any>(null);
|
||||||
|
|
||||||
|
const login = () => window.location.href = '/auth/login';
|
||||||
|
const logout = () => fetch('/auth/logout', { method: 'POST' }).then(() => setUser(null));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getUserinfo().then(setUser).catch(() => setUser(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<h1>Demo OIDC con Keycloak</h1>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<p>Utente: {user.name || user.preferred_username}</p>
|
||||||
|
<pre>{JSON.stringify(user, null, 2)}</pre>
|
||||||
|
<button onClick={logout}>Logout</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={login}>Login con Keycloak</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
client/src/api.ts
Normal file
5
client/src/api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export async function getUserinfo() {
|
||||||
|
const r = await fetch('/api/userinfo', { credentials: 'include' });
|
||||||
|
if (!r.ok) throw new Error('userinfo failed');
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
9
client/src/main.tsx
Normal file
9
client/src/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
17
client/tsconfig.json
Normal file
17
client/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"server/**/*.ts",
|
||||||
|
"client/src/**/*.ts",
|
||||||
|
"client/src/**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
client/vite.config.ts
Normal file
13
client/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
hmr: false,
|
||||||
|
host: '192.168.1.3',
|
||||||
|
allowedHosts: [
|
||||||
|
'my.patachina2.casacam.net' // 👈 aggiungi qui il tuo host
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
13
client/vite.config.ts.ok
Normal file
13
client/vite.config.ts.ok
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
hmr: false,
|
||||||
|
host: '192.168.1.3',
|
||||||
|
allowedHosts: [
|
||||||
|
'my.patachina2.casacam.net' // 👈 aggiungi qui il tuo host
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
38
make-zip.sh
Executable file
38
make-zip.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT="keycloak-oidc-vite-express"
|
||||||
|
ZIP="$PROJECT.zip"
|
||||||
|
|
||||||
|
echo "[+] Validazione file principali..."
|
||||||
|
for f in package.json tsconfig.json README.md .env \
|
||||||
|
server/index.ts server/auth.ts server/env.ts \
|
||||||
|
client/index.html client/vite.config.ts \
|
||||||
|
client/src/main.tsx client/src/App.tsx client/src/api.ts
|
||||||
|
do
|
||||||
|
if [[ ! -f "$f" ]]; then
|
||||||
|
echo "[-] Manca il file: $f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[+] Pulizia dist..."
|
||||||
|
rm -rf dist client/dist
|
||||||
|
|
||||||
|
echo "[+] Installazione dipendenze (se necessario)..."
|
||||||
|
if [[ ! -d node_modules ]]; then
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[+] Build client e server..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "[+] Creazione archivio $ZIP..."
|
||||||
|
cd ..
|
||||||
|
rm -f "$ZIP"
|
||||||
|
zip -r "$ZIP" "$PROJECT" \
|
||||||
|
-x "$PROJECT/node_modules/*" \
|
||||||
|
-x "$PROJECT/.env" \
|
||||||
|
-x "$PROJECT/.git/*"
|
||||||
|
|
||||||
|
echo "[+] Fatto. Trovi lo zip in: $(pwd)/$ZIP"
|
||||||
3391
package-lock.json
generated
Normal file
3391
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "keycloak-oidc-vite-express",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env NODE_ENV=development tsx server/index.ts",
|
||||||
|
"build:client": "vite build --config client/vite.config.ts",
|
||||||
|
"build:server": "tsc",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
|
"start": "node dist/server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"glob": "^9.3.5",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"rimraf": "^4.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
package.json.old
Normal file
33
package.json.old
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "keycloak-oidc-vite-express",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cross-env NODE_ENV=development ts-node-dev --respawn --transpile-only server/index.ts",
|
||||||
|
"build:client": "vite build --config client/vite.config.ts",
|
||||||
|
"build:server": "tsc",
|
||||||
|
"build": "npm run build:client && npm run build:server",
|
||||||
|
"start": "node dist/server/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"glob": "^9.3.5",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"rimraf": "^4.4.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/auth.ts
Normal file
37
server/auth.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { Client } from 'openid-client';
|
||||||
|
import { generators } from 'openid-client';
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
export function setupAuthRoutes(app: any, client: Client, redirectUri: string, scope: string, cookieOptionsBase: any) {
|
||||||
|
app.get('/auth/login', (req: Request, res: Response) => {
|
||||||
|
const code_verifier = generators.codeVerifier();
|
||||||
|
const code_challenge = generators.codeChallenge(code_verifier);
|
||||||
|
res.cookie('pkce_verifier', code_verifier, { ...cookieOptionsBase, maxAge: 600000 });
|
||||||
|
const authUrl = client.authorizationUrl({ scope, code_challenge, code_challenge_method: 'S256', redirect_uri: redirectUri });
|
||||||
|
res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/auth/callback', async (req: Request, res: Response) => {
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
const verifier = req.signedCookies['pkce_verifier'];
|
||||||
|
const tokenSet = await client.callback(redirectUri, params, { code_verifier: verifier });
|
||||||
|
res.clearCookie('pkce_verifier');
|
||||||
|
res.cookie('access_token', tokenSet.access_token, { ...cookieOptionsBase });
|
||||||
|
res.cookie('id_token', tokenSet.id_token, { ...cookieOptionsBase });
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/userinfo', async (req: Request, res: Response) => {
|
||||||
|
const access = req.signedCookies['access_token'];
|
||||||
|
if (!access) return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
const userinfo = await client.userinfo(access);
|
||||||
|
res.json(userinfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/auth/logout', (_req: Request, res: Response) => {
|
||||||
|
res.clearCookie('access_token');
|
||||||
|
res.clearCookie('id_token');
|
||||||
|
res.clearCookie('refresh_token');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
12
server/env.ts
Normal file
12
server/env.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export const cfg = {
|
||||||
|
issuerDiscoveryUrl: process.env.KEYCLOAK_ISSUER_DISCOVERY!,
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
redirectUri: process.env.REDIRECT_URI!,
|
||||||
|
cookieSecret: process.env.COOKIE_SECRET || 'dev-secret',
|
||||||
|
scope: process.env.SCOPE || 'openid profile email',
|
||||||
|
isProd: process.env.NODE_ENV === 'production',
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
};
|
||||||
54
server/index.ts
Normal file
54
server/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import path from 'path';
|
||||||
|
import { cfg } from './env';
|
||||||
|
import { Issuer } from 'openid-client';
|
||||||
|
import { setupAuthRoutes } from './auth';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = express();
|
||||||
|
app.use(cookieParser(cfg.cookieSecret));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// OIDC discovery e client
|
||||||
|
const issuer = await Issuer.discover(cfg.issuerDiscoveryUrl);
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: cfg.clientId,
|
||||||
|
client_secret: cfg.clientSecret,
|
||||||
|
redirect_uris: [cfg.redirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
tokenendpointauthmethod: 'clientsecret_basic'
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieOptionsBase = {
|
||||||
|
httpOnly: true,
|
||||||
|
signed: true,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
secure: cfg.isProd
|
||||||
|
};
|
||||||
|
|
||||||
|
setupAuthRoutes(app, client, cfg.redirectUri, cfg.scope, cookieOptionsBase);
|
||||||
|
|
||||||
|
// Vite middleware in dev, static in prod
|
||||||
|
if (!cfg.isProd) {
|
||||||
|
const vite = await (await import('vite')).createServer({
|
||||||
|
root: path.join(process.cwd(), 'client'),
|
||||||
|
server: { middlewareMode: true, hmr: false, host: '0.0.0.0' },
|
||||||
|
plugins: [(await import('@vitejs/plugin-react')).default()]
|
||||||
|
});
|
||||||
|
app.use(vite.middlewares);
|
||||||
|
} else {
|
||||||
|
const dist = path.join(process.cwd(), 'client', 'dist');
|
||||||
|
app.use(express.static(dist));
|
||||||
|
app.get('*', (_req, res) => res.sendFile(path.join(dist, 'index.html')));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(cfg.port, '192.168.1.3', () =>
|
||||||
|
console.log(`Server running on http://192.168.1.3:${cfg.port}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((err) => {
|
||||||
|
console.error('Bootstrap failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"rootDir": ".",
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"server/**/*.ts",
|
||||||
|
"client/src/**/*.ts",
|
||||||
|
"client/src/**/*.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
8
tsconfig.node.json
Normal file
8
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["client/vite.config.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue