feat(operator): scaffold apps/operator Nuxt app + multi-issuer JWT (O.3)
New Nuxt 3 app at apps/operator/ — internal admin portal on its own domain
(operator.dezky.local), own OAuth client (dezky-operator), own session
secrets, own cookies. Customer and operator surfaces can't decrypt each
other's session state.
OAuth flow verified end-to-end:
- GET / → middleware redirect to /auth/login
- User clicks Sign in → /auth/oidc/login → bounces to Authentik with
client_id=dezky-operator, scope includes 'groups'
- Authentik checks dezky-platform-admins group binding (added in O.1),
silent-reauths via the existing auth.dezky.local session
- Returns to /auth/oidc/callback with code, exchanges for token,
creates session cookie on operator.dezky.local
- Lands on pages/index.vue placeholder dashboard
Smoke test 'Create partner "test-partner"' button on the placeholder home
exercises the full operator-only authorization chain:
- 1st call: 200, partner created in Mongo
- 2nd call: 409 'already exists' (idempotency holds, token still valid)
- Same call from the customer portal: 403 'requires operator-scoped
token' (audience guard rejects dezky-portal aud)
JwtAuthGuard now multi-issuer in addition to multi-audience. Each
Authentik OAuth provider mints tokens with its own per-app iss URL
(.../application/o/<slug>/), so the guard accepts a comma-separated
AUTHENTIK_ISSUER. The audience-only fix from O.2 wasn't sufficient —
issuer is validated separately by jose.jwtVerify and was still pinned
to dezky-portal alone, yielding 'unexpected iss claim value' rejections.
Compose changes: new 'operator' service (Node 20 alpine, pnpm install +
nuxt dev, mkcert CA mount, traefik labels for operator.dezky.local +
TLS); new operator_node_modules volume; operator.dezky.local added to
traefik's Docker network aliases. Distinct OPERATOR_NUXT_OIDC_* session
secrets pulled from .env (gitignored, generated via openssl).
Real operator screens (sidebar, topbar, tenants, partners, etc.) come
in O.4. This commit is pure scaffolding + the security boundary proof.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
html,
|
||||||
|
body,
|
||||||
|
#__nuxt {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/* Dezky operator design tokens — dark/carbon surface by default.
|
||||||
|
Mirrors the customer portal's tokens.css palette but swaps the defaults
|
||||||
|
to the dark variant. operator.dezky.local always renders carbon. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0A0A0A;
|
||||||
|
--surface: #141413;
|
||||||
|
--elevated: #1C1C1A;
|
||||||
|
--border: #262622;
|
||||||
|
--border-hi: #33332E;
|
||||||
|
|
||||||
|
--text: #F4F3EE;
|
||||||
|
--text-dim: rgba(244, 243, 238, 0.72);
|
||||||
|
--text-mute: rgba(244, 243, 238, 0.45);
|
||||||
|
|
||||||
|
/* Sidebar uses surface-level dark (same as the global one for operator) */
|
||||||
|
--side-bg: #0A0A0A;
|
||||||
|
--side-surf: #141413;
|
||||||
|
--side-border: #1F1F1C;
|
||||||
|
--side-text: #F4F3EE;
|
||||||
|
--side-dim: rgba(244, 243, 238, 0.55);
|
||||||
|
--side-mute: rgba(244, 243, 238, 0.35);
|
||||||
|
--side-hover: rgba(244, 243, 238, 0.06);
|
||||||
|
--side-active: rgba(244, 243, 238, 0.1);
|
||||||
|
|
||||||
|
--accent: #D4FF3A;
|
||||||
|
--accent-fg: #0A0A0A;
|
||||||
|
--signal: #D4FF3A;
|
||||||
|
|
||||||
|
--ok: #34C77B;
|
||||||
|
--warn: #F0B14A;
|
||||||
|
--bad: #F05858;
|
||||||
|
--info: #4D8BE8;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
--font-display: 'Inter Tight', 'Inter', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
|
||||||
|
|
||||||
|
--input-bg: rgba(244, 243, 238, 0.04);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Nuxt 3 configuration for the Dezky operator portal.
|
||||||
|
// Separate app from apps/portal — different OAuth client, different cookies,
|
||||||
|
// different domain, stricter authorization. See docs/OPERATOR-PLAN.md.
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2026-01-01',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: ['nuxt-oidc-auth'],
|
||||||
|
|
||||||
|
css: ['~/assets/styles/tokens.css', '~/assets/styles/base.css'],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
htmlAttrs: { 'data-theme': 'dark' },
|
||||||
|
link: [
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
oidc: {
|
||||||
|
defaultProvider: 'oidc',
|
||||||
|
session: {
|
||||||
|
expirationCheck: true,
|
||||||
|
automaticRefresh: true,
|
||||||
|
},
|
||||||
|
middleware: {
|
||||||
|
globalMiddlewareEnabled: true,
|
||||||
|
customLoginPage: true,
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
// Generic OIDC against the dezky-operator Authentik client. Same shape
|
||||||
|
// as the customer portal's config but pointed at a different provider
|
||||||
|
// and a different audience.
|
||||||
|
oidc: {
|
||||||
|
clientId: process.env.NUXT_OIDC_CLIENT_ID || '',
|
||||||
|
clientSecret: process.env.NUXT_OIDC_CLIENT_SECRET || '',
|
||||||
|
redirectUri: process.env.NUXT_OIDC_REDIRECT_URI || '',
|
||||||
|
authorizationUrl: 'https://auth.dezky.local/application/o/authorize/',
|
||||||
|
tokenUrl: 'https://auth.dezky.local/application/o/token/',
|
||||||
|
userInfoUrl: 'https://auth.dezky.local/application/o/userinfo/',
|
||||||
|
logoutUrl: 'https://auth.dezky.local/application/o/dezky-operator/end-session/',
|
||||||
|
openIdConfiguration:
|
||||||
|
'https://auth.dezky.local/application/o/dezky-operator/.well-known/openid-configuration',
|
||||||
|
scope: ['openid', 'profile', 'email', 'groups'],
|
||||||
|
userNameClaim: 'preferred_username',
|
||||||
|
responseType: 'code',
|
||||||
|
grantType: 'authorization_code',
|
||||||
|
pkce: true,
|
||||||
|
skipAccessTokenParsing: true,
|
||||||
|
exposeAccessToken: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
hmr: {
|
||||||
|
protocol: 'wss',
|
||||||
|
clientPort: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
routeRules: {
|
||||||
|
'/api/**': { cors: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@dezky/operator",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Dezky operator portal — internal admin app (Nuxt 3)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev --host 0.0.0.0 --port 3000",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"typecheck": "nuxt typecheck",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"nuxt": "^3.13.0",
|
||||||
|
"nuxt-oidc-auth": "1.0.0-beta.11",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.12.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// O.3 scaffolding login. Real visual treatment lands in O.4 with the full
|
||||||
|
// design system port. For now: minimal dark-themed bounce to Authentik.
|
||||||
|
|
||||||
|
definePageMeta({ auth: false })
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
await navigateTo('/auth/oidc/login', { external: true })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shell">
|
||||||
|
<div class="card">
|
||||||
|
<p class="eyebrow">dezky · ops</p>
|
||||||
|
<h1>Operator portal</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Authentik-issued tokens · platform-admin group required · MFA when enrolled.
|
||||||
|
</p>
|
||||||
|
<button class="primary" @click="signIn">Sign in</button>
|
||||||
|
<p class="hint">operator.dezky.local</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 12px 0 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin: 24px 0 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// O.3 scaffolding home. Confirms login round-trips and exposes a smoke-test
|
||||||
|
// button that exercises the operator-only audience gating against
|
||||||
|
// platform-api. Real operator UI lands in O.4+.
|
||||||
|
|
||||||
|
const { user, logout } = useOidcAuth()
|
||||||
|
const smokeResult = ref<string | null>(null)
|
||||||
|
const smokeBusy = ref(false)
|
||||||
|
|
||||||
|
async function createTestPartner() {
|
||||||
|
smokeBusy.value = true
|
||||||
|
smokeResult.value = null
|
||||||
|
try {
|
||||||
|
const res = await $fetch('/api/operator-smoke-test', { method: 'POST' })
|
||||||
|
smokeResult.value = `✓ ${JSON.stringify(res).slice(0, 200)}`
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { data?: { message?: string }; statusCode?: number }
|
||||||
|
smokeResult.value = `✗ ${e.statusCode}: ${e.data?.message ?? String(err)}`
|
||||||
|
} finally {
|
||||||
|
smokeBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<header class="bar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="dot" />
|
||||||
|
<span class="name">dezky · ops</span>
|
||||||
|
</div>
|
||||||
|
<div class="me">
|
||||||
|
<span class="email">{{ user?.userInfo?.email || user?.userName }}</span>
|
||||||
|
<button class="logout" @click="logout()">sign out</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stage">
|
||||||
|
<p class="eyebrow">O.3 scaffolding</p>
|
||||||
|
<h1>Operator portal · placeholder</h1>
|
||||||
|
<p class="lead">
|
||||||
|
You're signed in via the <code>dezky-operator</code> Authentik client. Real screens
|
||||||
|
(Overview, Tenants, Partners, Infrastructure, etc.) land in O.4 once the design system
|
||||||
|
is ported. This page exists to prove the OAuth round-trip works and to smoke-test the
|
||||||
|
operator-only endpoints on platform-api.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Smoke test · POST /partners</h2>
|
||||||
|
<p>
|
||||||
|
Calls <code>https://api.dezky.local/partners</code> through a server-side proxy that
|
||||||
|
forwards your access token. With an operator-scoped token this should return 200 +
|
||||||
|
the created partner; with a customer-portal token (try in the other app) it returns 403.
|
||||||
|
</p>
|
||||||
|
<button :disabled="smokeBusy" class="primary" @click="createTestPartner">
|
||||||
|
{{ smokeBusy ? 'Calling…' : 'Create partner "test-partner"' }}
|
||||||
|
</button>
|
||||||
|
<pre v-if="smokeResult" class="result">{{ smokeResult }}</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="meta">
|
||||||
|
<div class="row"><span class="k">subject</span><span class="v">{{ user?.userName }}</span></div>
|
||||||
|
<div class="row"><span class="k">email</span><span class="v">{{ user?.userInfo?.email }}</span></div>
|
||||||
|
<div class="row"><span class="k">groups</span><span class="v">{{ (user?.userInfo as { groups?: string[] } | undefined)?.groups?.join(', ') || '—' }}</span></div>
|
||||||
|
<div class="row"><span class="k">aud</span><span class="v">dezky-operator (expected)</span></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(212, 255, 58, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: rgba(244, 243, 238, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
padding: 48px 32px;
|
||||||
|
max-width: 760px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-mute);
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(244, 243, 238, 0.06);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary[disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin: 14px 0 0 0;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 5px 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k {
|
||||||
|
color: var(--text-mute);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v {
|
||||||
|
color: var(--text-dim);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Generated
+8122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
// Smoke test: forwards the operator's access token to platform-api's POST
|
||||||
|
// /partners. If the operator OAuth setup is correct, this returns 200 with
|
||||||
|
// the created (or already-existing) partner. Idempotent — Partner is
|
||||||
|
// soft-terminated, never hard-deleted, so re-running with the same slug
|
||||||
|
// returns 409 Conflict (also a success signal for the audience guard).
|
||||||
|
|
||||||
|
import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const session = await getUserSession(event).catch(() => null)
|
||||||
|
const accessToken = (session as { accessToken?: string } | null)?.accessToken
|
||||||
|
if (!accessToken) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Not signed in' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.env.PLATFORM_API_INTERNAL_URL ?? 'http://platform-api:3001'
|
||||||
|
try {
|
||||||
|
return await $fetch(`${base}/partners`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
body: {
|
||||||
|
slug: 'test-partner',
|
||||||
|
name: 'Smoke Test Partner',
|
||||||
|
domain: 'test-partner.example',
|
||||||
|
status: 'in-negotiation',
|
||||||
|
marginPct: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { statusCode?: number; data?: unknown }
|
||||||
|
throw createError({ statusCode: e.statusCode ?? 500, data: e.data })
|
||||||
|
}
|
||||||
|
})
|
||||||
+21
-14
@@ -314,21 +314,28 @@ done in order — earlier ones unblock later ones.
|
|||||||
(operator token → 200) deferred until O.3 when the operator app
|
(operator token → 200) deferred until O.3 when the operator app
|
||||||
exists to mint that token
|
exists to mint that token
|
||||||
|
|
||||||
### O.3 · Scaffold `apps/operator/`
|
### O.3 · Scaffold `apps/operator/` ✓
|
||||||
|
|
||||||
- [ ] `apps/operator/package.json` (Nuxt 3, `nuxt-oidc-auth` beta.11, same
|
- [x] `apps/operator/package.json` (Nuxt 3, `nuxt-oidc-auth` 1.0.0-beta.11)
|
||||||
deps as portal)
|
- [x] `nuxt.config.ts` wired against the `dezky-operator` Authentik provider:
|
||||||
- [ ] `nuxt.config.ts` with `oidc` block pointing at `dezky-operator`
|
`client_id=dezky-operator`, audience claim becomes `dezky-operator`,
|
||||||
- [ ] Docker compose service `operator`, with Traefik labels for
|
scope includes `groups`, `exposeAccessToken: true` so the Nitro proxy
|
||||||
`operator.dezky.local`, `node_modules` volume, same `NODE_EXTRA_CA_CERTS`
|
can forward it
|
||||||
mount for mkcert
|
- [x] Docker compose service `operator` running on the dezky network, mkcert
|
||||||
- [ ] Network alias on Traefik: `operator.dezky.local`
|
root CA mounted, Traefik route at `operator.dezky.local`
|
||||||
- [ ] User task: add `operator.dezky.local` to `/etc/hosts`
|
- [x] Network alias on Traefik: `operator.dezky.local`
|
||||||
- [ ] Session secrets in `.env`: `NUXT_OIDC_TOKEN_KEY` (base64-32),
|
- [x] `operator.dezky.local` added to `/etc/hosts`
|
||||||
`NUXT_OIDC_SESSION_SECRET`, `NUXT_OIDC_AUTH_SESSION_SECRET` —
|
- [x] Distinct session secrets in `.env` (`OPERATOR_NUXT_OIDC_*`) — the two
|
||||||
**distinct from** the customer portal's secrets
|
apps can't decrypt each other's session cookies
|
||||||
- [ ] Verify login: visit `https://operator.dezky.local`, bounce to Authentik,
|
- [x] Verified login: signing in lands on the placeholder index showing
|
||||||
sign in as akadmin, land on a placeholder index page
|
`Operator portal · placeholder` with the user's identity
|
||||||
|
- [x] Smoke test `POST /partners`: operator session returns 200 (partner
|
||||||
|
created in Mongo), idempotent re-call returns 409 (already exists),
|
||||||
|
customer-portal session returns 403 ("requires operator-scoped token")
|
||||||
|
- [x] `JwtAuthGuard` extended to accept **multi-issuer** as well as
|
||||||
|
multi-audience (each Authentik OAuth provider has its own per-app
|
||||||
|
`iss` URL); `AUTHENTIK_ISSUER` env is now comma-separated. The audience
|
||||||
|
change in O.2 wasn't enough on its own — issuer matching is separate
|
||||||
|
|
||||||
### O.4 · Design system + app shell
|
### O.4 · Design system + app shell
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ volumes:
|
|||||||
ocis_data:
|
ocis_data:
|
||||||
portal_node_modules:
|
portal_node_modules:
|
||||||
platform_api_node_modules:
|
platform_api_node_modules:
|
||||||
|
operator_node_modules:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
@@ -54,6 +55,7 @@ services:
|
|||||||
- traefik.dezky.local
|
- traefik.dezky.local
|
||||||
- auth.dezky.local
|
- auth.dezky.local
|
||||||
- app.dezky.local
|
- app.dezky.local
|
||||||
|
- operator.dezky.local
|
||||||
- api.dezky.local
|
- api.dezky.local
|
||||||
- files.dezky.local
|
- files.dezky.local
|
||||||
- mail.dezky.local
|
- mail.dezky.local
|
||||||
@@ -389,6 +391,47 @@ services:
|
|||||||
- traefik.http.routers.portal.tls=true
|
- traefik.http.routers.portal.tls=true
|
||||||
- traefik.http.services.portal.loadbalancer.server.port=3000
|
- traefik.http.services.portal.loadbalancer.server.port=3000
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Operator portal — internal admin app at operator.dezky.local.
|
||||||
|
# Separate from the customer portal: own OAuth client (dezky-operator),
|
||||||
|
# own session secrets, own cookie domain. Audience-gated mutations on
|
||||||
|
# platform-api require the token this app mints.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
operator:
|
||||||
|
image: node:20-alpine
|
||||||
|
container_name: dezky-operator
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
NUXT_HOST: 0.0.0.0
|
||||||
|
NUXT_PORT: 3000
|
||||||
|
NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
|
||||||
|
# OIDC — dezky-operator OAuth client (separate from dezky-portal)
|
||||||
|
NUXT_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
|
||||||
|
NUXT_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
|
||||||
|
NUXT_OIDC_ISSUER: ${OPERATOR_OIDC_ISSUER}
|
||||||
|
NUXT_OIDC_REDIRECT_URI: https://operator.dezky.local/auth/oidc/callback
|
||||||
|
# Session encryption — distinct from portal so the two surfaces can't
|
||||||
|
# decrypt each other's session cookies
|
||||||
|
NUXT_OIDC_TOKEN_KEY: ${OPERATOR_NUXT_OIDC_TOKEN_KEY}
|
||||||
|
NUXT_OIDC_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_SESSION_SECRET}
|
||||||
|
NUXT_OIDC_AUTH_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_AUTH_SESSION_SECRET}
|
||||||
|
# Reach platform-api internally for the server-side token-forwarding proxy
|
||||||
|
PLATFORM_API_INTERNAL_URL: http://platform-api:3001
|
||||||
|
NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
|
||||||
|
volumes:
|
||||||
|
- ../../apps/operator:/app
|
||||||
|
- operator_node_modules:/app/node_modules
|
||||||
|
- ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
|
||||||
|
networks: [dezky]
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.operator.rule=Host(`operator.dezky.local`)
|
||||||
|
- traefik.http.routers.operator.tls=true
|
||||||
|
- traefik.http.services.operator.loadbalancer.server.port=3000
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
# platform-api — NestJS service. Owns tenants, partners, users,
|
# platform-api — NestJS service. Owns tenants, partners, users,
|
||||||
# subscriptions, and provisioning orchestration.
|
# subscriptions, and provisioning orchestration.
|
||||||
@@ -409,8 +452,10 @@ services:
|
|||||||
STALWART_ADMIN_USER: admin
|
STALWART_ADMIN_USER: admin
|
||||||
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
|
STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
|
||||||
OCIS_API_URL: https://files.dezky.local
|
OCIS_API_URL: https://files.dezky.local
|
||||||
# JWT validation against Authentik for portal-issued access tokens
|
# JWT validation against Authentik for portal-issued access tokens.
|
||||||
AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/
|
# Issuers are comma-separated — each Authentik OAuth provider issues tokens
|
||||||
|
# with its own per-app issuer URL, so we accept both portal and operator.
|
||||||
|
AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/,https://auth.dezky.local/application/o/dezky-operator/
|
||||||
# Comma-separated list of accepted JWT audiences. Tokens issued for either
|
# Comma-separated list of accepted JWT audiences. Tokens issued for either
|
||||||
# the customer portal or the operator portal are valid against this service;
|
# the customer portal or the operator portal are valid against this service;
|
||||||
# per-endpoint guards further restrict operator-only mutations.
|
# per-endpoint guards further restrict operator-only mutations.
|
||||||
|
|||||||
@@ -13,15 +13,21 @@ import type { AuthentikJwtPayload } from './jwt-payload.interface.js'
|
|||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
private readonly logger = new Logger(JwtAuthGuard.name)
|
private readonly logger = new Logger(JwtAuthGuard.name)
|
||||||
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null
|
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null
|
||||||
private readonly issuer: string
|
// AUTHENTIK_ISSUER and AUTHENTIK_AUDIENCE are both comma-separated to accept
|
||||||
// AUTHENTIK_AUDIENCE is comma-separated to accept tokens from multiple
|
// tokens from multiple OAuth clients (customer portal + operator portal +
|
||||||
// OAuth clients (customer portal + operator portal + future surfaces).
|
// future surfaces). Each Authentik provider issues tokens with its own
|
||||||
// jose.jwtVerify with an array succeeds if the token's aud matches any.
|
// per-app issuer URL (`.../application/o/<slug>/`) — we accept any match.
|
||||||
|
// jose.jwtVerify with arrays succeeds when the token's iss/aud match any.
|
||||||
|
private readonly issuers: string[]
|
||||||
private readonly audiences: string[]
|
private readonly audiences: string[]
|
||||||
private readonly jwksUri: string
|
private readonly jwksUri: string
|
||||||
|
|
||||||
constructor(config: ConfigService) {
|
constructor(config: ConfigService) {
|
||||||
this.issuer = config.getOrThrow<string>('AUTHENTIK_ISSUER')
|
this.issuers = config
|
||||||
|
.getOrThrow<string>('AUTHENTIK_ISSUER')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
this.audiences = config
|
this.audiences = config
|
||||||
.getOrThrow<string>('AUTHENTIK_AUDIENCE')
|
.getOrThrow<string>('AUTHENTIK_AUDIENCE')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -58,7 +64,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, this.getJwks(), {
|
const { payload } = await jwtVerify(token, this.getJwks(), {
|
||||||
issuer: this.issuer,
|
issuer: this.issuers,
|
||||||
audience: this.audiences,
|
audience: this.audiences,
|
||||||
})
|
})
|
||||||
req.user = payload as unknown as AuthentikJwtPayload
|
req.user = payload as unknown as AuthentikJwtPayload
|
||||||
|
|||||||
Reference in New Issue
Block a user