feat(demo): apps reais (backend+SPA) + realm como import GitOps

This commit is contained in:
ATM Platform
2026-06-15 22:02:18 +00:00
parent 8cd3e0483f
commit eb5525b399
3 changed files with 168 additions and 25 deletions
+9 -1
View File
@@ -17,7 +17,7 @@ spec:
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.0
args: ["start-dev"]
args: ["start-dev", "--import-realm"]
env:
- name: KC_DB
value: postgres
@@ -64,6 +64,14 @@ spec:
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 40
volumeMounts:
- name: realm-import
mountPath: /opt/keycloak/data/import
readOnly: true
volumes:
- name: realm-import
configMap:
name: kc-realm-import
---
apiVersion: v1
kind: Service
+108 -24
View File
@@ -1,4 +1,58 @@
# Esqueletos (stubs) de Backend, BFF e Frontend do tenant demo + Ingress TLS
# Apps reais do tenant demo: backend Resource Server (Node) + SPA OIDC Code+PKCE
---
apiVersion: v1
kind: ConfigMap
metadata: { name: backend-code, namespace: demo-prod }
data:
server.js: |
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const ISSUER = process.env.ISSUER;
const JWKS_URL = process.env.JWKS_URL;
const ORIGIN = process.env.CORS_ORIGIN || '*';
let keys = {}, keysAt = 0;
function fetchJson(url) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
lib.get(url, (r) => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); }).on('error', reject);
});
}
async function key(kid) {
if (Date.now() - keysAt > 300000) { const j = await fetchJson(JWKS_URL); keys = {}; j.keys.forEach(k => keys[k.kid] = k); keysAt = Date.now(); }
return keys[kid];
}
async function verify(token) {
const [h, p, s] = token.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
const jwk = await key(header.kid);
if (!jwk) throw new Error('unknown kid');
const pub = crypto.createPublicKey({ key: jwk, format: 'jwk' });
const ok = crypto.verify('RSA-SHA256', Buffer.from(h + '.' + p), pub, Buffer.from(s, 'base64url'));
if (!ok) throw new Error('bad signature');
const c = JSON.parse(Buffer.from(p, 'base64url').toString());
if (c.iss !== ISSUER) throw new Error('bad issuer');
if (c.exp * 1000 < Date.now()) throw new Error('expired');
return c;
}
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', ORIGIN);
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
if (req.url === '/api/public/health') { res.end(JSON.stringify({ status: 'UP', service: 'athletic-map-backend', tenant: 'demo' })); return; }
if (req.url === '/api/me') {
const a = req.headers.authorization || '';
if (!a.startsWith('Bearer ')) { res.statusCode = 401; res.end(JSON.stringify({ error: 'unauthorized' })); return; }
try {
const c = await verify(a.slice(7));
res.end(JSON.stringify({ subject: c.sub, preferredUsername: c.preferred_username, email: c.email, roles: (c.realm_access || {}).roles || [], issuer: c.iss, validatedBy: 'athletic-map-backend (Node Resource Server)' }));
} catch (e) { res.statusCode = 401; res.end(JSON.stringify({ error: 'invalid_token', detail: String(e.message) })); }
return;
}
res.statusCode = 404; res.end(JSON.stringify({ error: 'not_found' }));
});
server.listen(8080, () => console.log('backend listening on 8080'));
---
apiVersion: apps/v1
kind: Deployment
@@ -10,17 +64,29 @@ spec:
metadata: { labels: { app: backend } }
spec:
containers:
- name: whoami
image: traefik/whoami:latest
args: ["--name", "athletic-map-backend demo (stub)"]
ports: [{ containerPort: 80 }]
- name: backend
image: node:20-alpine
command: ["node", "/app/server.js"]
env:
- { name: ISSUER, value: "https://auth-demo.187.77.37.184.nip.io/realms/athleticmap" }
- { name: JWKS_URL, value: "http://keycloak:8080/realms/athleticmap/protocol/openid-connect/certs" }
- { name: CORS_ORIGIN, value: "https://demo.187.77.37.184.nip.io" }
ports: [{ containerPort: 8080 }]
volumeMounts: [{ name: code, mountPath: /app }]
readinessProbe:
httpGet: { path: /api/public/health, port: 8080 }
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: code
configMap: { name: backend-code }
---
apiVersion: v1
kind: Service
metadata: { name: backend, namespace: demo-prod }
spec:
selector: { app: backend }
ports: [{ port: 80, targetPort: 80 }]
ports: [{ port: 80, targetPort: 8080 }]
---
apiVersion: apps/v1
kind: Deployment
@@ -50,13 +116,38 @@ metadata: { name: frontend-index, namespace: demo-prod }
data:
index.html: |
<!doctype html>
<html lang="pt-br"><head><meta charset="utf-8"><title>Athletic Map — Demo</title>
<style>body{font-family:system-ui,sans-serif;background:#155eef;color:#fff;display:flex;
min-height:100vh;align-items:center;justify-content:center;margin:0}
.c{text-align:center}h1{color:#ffd23f}</style></head>
<body><div class="c"><h1>Athletic Map</h1>
<p>Frontend (stub) — silo <b>demo</b> no k3s</p>
<p>tenant: <b>demo</b></p></div></body></html>
<html lang="pt-br"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Athletic Map — Demo</title>
<script src="https://auth-demo.187.77.37.184.nip.io/js/keycloak.js"></script>
<style>
body{font-family:system-ui,sans-serif;background:#155eef;color:#eaf2ff;margin:0;padding:2rem}
.card{max-width:760px;margin:2rem auto;background:#0e2f57;border-top:4px solid #ffd23f;border-radius:12px;padding:2rem;box-shadow:0 12px 40px rgba(0,0,0,.4)}
h1{color:#ffd23f;margin:0 0 .3rem}.muted{color:#bcd3f5}b{color:#fff}
pre{background:#06182e;padding:1rem;border-radius:8px;overflow:auto;color:#9fe8aa;font-size:.85rem}
button{background:#188bf6;color:#fff;border:0;border-radius:8px;padding:.6rem 1.2rem;cursor:pointer;font-weight:700}
.tag{display:inline-block;background:#0a2547;border-radius:20px;padding:.15rem .7rem;margin:.15rem;font-size:.8rem}
</style></head>
<body><div class="card" id="app"><p class="muted">Carregando…</p></div>
<script>
const kc = new Keycloak({ url: 'https://auth-demo.187.77.37.184.nip.io', realm: 'athleticmap', clientId: 'spa' });
kc.init({ onLoad: 'login-required', pkceMethod: 'S256', checkLoginIframe: false }).then(function (auth) {
if (!auth) { document.getElementById('app').innerHTML = '<p>Nao autenticado.</p>'; return; }
var t = kc.tokenParsed;
var roles = ((t.realm_access && t.realm_access.roles) || []).map(function (r) { return '<span class="tag">' + r + '</span>'; }).join('');
document.getElementById('app').innerHTML =
'<h1>Athletic Map</h1>' +
'<p class="muted">tenant <b>demo</b> · login OIDC (Authorization Code + PKCE)</p>' +
'<p>Bem-vindo, <b>' + t.preferred_username + '</b></p>' +
'<p>Roles no token: ' + roles + '</p>' +
'<h3>Backend <code>/api/me</code> (JWT validado no servidor):</h3>' +
'<pre id="me">chamando…</pre>' +
'<button onclick="kc.logout()">Sair</button>';
fetch('/api/me', { headers: { Authorization: 'Bearer ' + kc.token } })
.then(function (r) { return r.json(); })
.then(function (me) { document.getElementById('me').textContent = JSON.stringify(me, null, 2); })
.catch(function (e) { document.getElementById('me').textContent = 'erro: ' + e; });
}).catch(function (e) { document.getElementById('app').innerHTML = '<p>Erro ao iniciar Keycloak: ' + e + '</p>'; });
</script></body></html>
---
apiVersion: apps/v1
kind: Deployment
@@ -65,7 +156,9 @@ spec:
replicas: 1
selector: { matchLabels: { app: frontend } }
template:
metadata: { labels: { app: frontend } }
metadata:
labels: { app: frontend }
annotations: { configVersion: "spa-1" }
spec:
containers:
- name: nginx
@@ -97,23 +190,14 @@ spec:
- hosts:
- demo.187.77.37.184.nip.io
- auth-demo.187.77.37.184.nip.io
- api-demo.187.77.37.184.nip.io
- bff-demo.187.77.37.184.nip.io
secretName: demo-tls
rules:
- host: demo.187.77.37.184.nip.io
http:
paths:
- { path: /api, pathType: Prefix, backend: { service: { name: backend, port: { number: 80 } } } }
- { path: /, pathType: Prefix, backend: { service: { name: frontend, port: { number: 80 } } } }
- host: auth-demo.187.77.37.184.nip.io
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: keycloak, port: { number: 8080 } } } }
- host: api-demo.187.77.37.184.nip.io
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: backend, port: { number: 80 } } } }
- host: bff-demo.187.77.37.184.nip.io
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: bff, port: { number: 80 } } } }
+51
View File
@@ -0,0 +1,51 @@
apiVersion: v1
data:
athleticmap-realm.json: |
{
"realm": "athleticmap",
"enabled": true,
"displayName": "Athletic Map",
"loginWithEmailAllowed": true,
"roles": {
"realm": [
{ "name": "admin" },
{ "name": "atm_athlete" },
{ "name": "atm_trainer" },
{ "name": "atm_team_admin" },
{ "name": "atm_fed_admin" }
]
},
"clients": [
{
"clientId": "spa",
"name": "Athletic Map SPA",
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"redirectUris": ["https://demo.187.77.37.184.nip.io/*"],
"webOrigins": ["https://demo.187.77.37.184.nip.io"],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://demo.187.77.37.184.nip.io/*"
}
}
],
"users": [
{
"username": "atleta1",
"enabled": true,
"emailVerified": true,
"email": "atleta1@demo.local",
"firstName": "Atleta",
"lastName": "Um",
"credentials": [{ "type": "password", "value": "Teste@123", "temporary": false }],
"realmRoles": ["atm_athlete"]
}
]
}
kind: ConfigMap
metadata:
name: kc-realm-import
namespace: demo-prod