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