feat(auth): Google OAuth, sealed session and default-deny gating (T9)

Gate the app behind Google sign-in restricted to verified @theodo.com
identities, and record the real reporter on filed defects (F9).

- nuxt-auth-utils for sealed cookie sessions + the Google OAuth handler,
  mounted at /auth/google/callback to match the registered redirect URI.
- isAllowedGoogleUser re-derives the domain from Google's verified email;
  the spoofable `hd` claim is deliberately ignored (DESIGN T9).
- Default-deny: server middleware 401s unauthenticated /api calls (health
  and the session endpoint excepted); a global route middleware redirects
  unauthenticated page navigations to /login.
- getReporter() now reads the session email instead of the dev stub.
- Env contract moves to nuxt-auth-utils names (NUXT_SESSION_PASSWORD,
  NUXT_OAUTH_GOOGLE_*); .env.example, compose and README updated.

Unit-tested: domain check (incl. hd-spoof + look-alike) and public-path
matching. Live OAuth round-trip pending manual verification.
This commit is contained in:
Julien Calixte
2026-05-28 01:09:48 +02:00
parent 73e4c56a9e
commit 82ac540909
18 changed files with 368 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
# App
NUXT_SESSION_SECRET=change-me
# App — session sealing key for nuxt-auth-utils (must be ≥32 chars)
NUXT_SESSION_PASSWORD=change-me-to-a-long-random-string-min-32-chars
# Database (Postgres) — Coolify-managed in prod, docker-compose in dev
DATABASE_URL=postgres://andon:andon@localhost:5432/andon
@@ -9,10 +9,13 @@ POSTGRES_USER=andon
POSTGRES_PASSWORD=andon
POSTGRES_DB=andon
# Google OAuth (Task 9) — restricted to the theodo.com hosted domain
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
# Google OAuth (Task 9) — nuxt-auth-utils reads these exact names. Access is
# restricted server-side to verified @theodo.com emails (the `hd` claim is not
# trusted on its own). REDIRECT_URL must match an Authorized redirect URI in the
# Google Cloud console (defaults to <origin>/auth/google/callback if unset).
NUXT_OAUTH_GOOGLE_CLIENT_ID=
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=
NUXT_OAUTH_GOOGLE_REDIRECT_URL=
# Web Push / VAPID (Task 10)
VAPID_PUBLIC_KEY=

View File

@@ -61,8 +61,10 @@ migrate step is needed.
1. Point Coolify at this repo; it builds the production `Dockerfile`.
2. Create a **managed Postgres** in Coolify (automatic backups — [ADR 0003](./docs/adr/0003-use-coolify-managed-postgres-over-sqlite.md))
and set `DATABASE_URL` to it. _(Alternatively deploy `docker-compose.yml`, which bundles a Postgres service.)_
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_SECRET`,
Google OAuth, VAPID) and route `andon.apoena.dev` to the service.
3. Set the env vars from [`.env.example`](./.env.example) (`NUXT_SESSION_PASSWORD`,
`NUXT_OAUTH_GOOGLE_*`, VAPID) and route `andon.apoena.dev` to the service. Add
`https://andon.apoena.dev/auth/google/callback` as an Authorized redirect URI
in the Google Cloud OAuth client.
4. **Enable Coolify's autodeploy** so it builds and deploys on every push to
`main`. Protect `main` with required PR review + CI so only vetted commits
reach production.

View File

@@ -1,6 +1,12 @@
<script setup lang="ts">
// Single domain (ADR 0004): report at `/`, view all defects at `/defects`,
// with a persistent header to move between them.
const { loggedIn, user, clear } = useUserSession()
async function signOut() {
await clear()
await navigateTo('/login')
}
</script>
<template>
@@ -25,6 +31,12 @@
</NuxtLink>
</li>
</ul>
<div v-if="loggedIn" class="flex items-center gap-3 pl-2">
<span class="hidden text-sm opacity-70 sm:inline">{{ user?.email }}</span>
<button type="button" class="btn btn-ghost btn-sm" @click="signOut">
Sign out
</button>
</div>
</nav>
</header>

View File

@@ -0,0 +1,13 @@
// Redirect unauthenticated visitors to the login page, and bounce already
// logged-in users away from it (F9). Runs on both SSR and client navigations;
// the server data API is gated independently in server/middleware/auth.ts.
export default defineNuxtRouteMiddleware((to) => {
const { loggedIn } = useUserSession()
if (!loggedIn.value && to.path !== '/login') {
return navigateTo('/login')
}
if (loggedIn.value && to.path === '/login') {
return navigateTo('/')
}
})

32
app/pages/login.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
// Login gate (F9). Standalone (no app header — its nav points at gated routes).
// The button is a real anchor so it hits the server OAuth route directly.
definePageMeta({ layout: false })
const route = useRoute()
const error = computed(() => {
if (route.query.error === 'domain') return 'Sign in with your @theodo.com Google account.'
if (route.query.error === 'oauth') return 'Sign-in failed — please try again.'
return null
})
</script>
<template>
<main class="flex min-h-screen items-center justify-center p-6">
<div class="card bg-base-100 border-base-300 w-full max-w-sm border shadow-sm">
<div class="card-body items-center text-center gap-4">
<h1 class="flex items-center gap-2 text-2xl font-semibold">
<span class="size-2.5 rounded-full bg-[#e11d48]" aria-hidden="true" />
Project Board Andon
</h1>
<p class="opacity-70">Sign in to report and view board defects.</p>
<p v-if="error" role="alert" class="alert alert-error text-sm">{{ error }}</p>
<a href="/auth/google/callback" class="btn btn-primary w-full">
Sign in with Google
</a>
</div>
</div>
</main>
</template>

View File

@@ -13,10 +13,10 @@ services:
- "3000:3000"
environment:
DATABASE_URL: ${DATABASE_URL:-postgres://andon:andon@db:5432/andon}
NUXT_SESSION_SECRET: ${NUXT_SESSION_SECRET:-change-me}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-}
NUXT_SESSION_PASSWORD: ${NUXT_SESSION_PASSWORD:-}
NUXT_OAUTH_GOOGLE_CLIENT_ID: ${NUXT_OAUTH_GOOGLE_CLIENT_ID:-}
NUXT_OAUTH_GOOGLE_CLIENT_SECRET: ${NUXT_OAUTH_GOOGLE_CLIENT_SECRET:-}
NUXT_OAUTH_GOOGLE_REDIRECT_URL: ${NUXT_OAUTH_GOOGLE_REDIRECT_URL:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:owner@theodo.com}

View File

@@ -4,7 +4,7 @@ import tailwindcss from '@tailwindcss/vite'
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxt/test-utils/module'],
modules: ['@nuxt/eslint', '@nuxt/test-utils/module', 'nuxt-auth-utils'],
css: ['~/assets/css/main.css'],
// Tailwind v4 integrates as a Vite plugin (no @nuxtjs/tailwindcss module).
vite: {

View File

@@ -25,6 +25,7 @@
"arktype": "^2.2.0",
"drizzle-orm": "^0.45.2",
"nuxt": "^4.4.6",
"nuxt-auth-utils": "^0.5.29",
"pg": "^8.21.0",
"vue": "^3.5.34",
"vue-router": "^5.0.7"

142
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
nuxt:
specifier: ^4.4.6
version: 4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0)
nuxt-auth-utils:
specifier: ^0.5.29
version: 0.5.29(magicast@0.5.3)
pg:
specifier: ^8.21.0
version: 8.21.0
@@ -69,6 +72,18 @@ importers:
packages:
'@adonisjs/hash@9.1.1':
resolution: {integrity: sha512-ZkRguwjAp4skKvKDdRAfdJ2oqQ0N7p9l3sioyXO1E8o0WcsyDgEpsTQtuVNoIdMiw4sn4gJlmL3nyF4BcK1ZDQ==}
engines: {node: '>=20.6.0'}
peerDependencies:
argon2: ^0.31.2 || ^0.41.0 || ^0.43.0
bcrypt: ^5.1.1 || ^6.0.0
peerDependenciesMeta:
argon2:
optional: true
bcrypt:
optional: true
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -1667,6 +1682,10 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1683,6 +1702,17 @@ packages:
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@poppinss/object-builder@1.1.0':
resolution: {integrity: sha512-FOrOq52l7u8goR5yncX14+k+Ewi5djnrt1JwXeS/FvnwAPOiveFhiczCDuvXdssAwamtrV2hp5Rw9v+n2T7hQg==}
engines: {node: '>=20.6.0'}
'@poppinss/string@1.7.2':
resolution: {integrity: sha512-A182GLDfi36iDCbhDrHB0xzrPM1fO3GHnhCDIdadf8C6eycgct4m7zusbLwEh6GPaj2Pz5BVos7XK16w7tZ7wQ==}
'@poppinss/utils@6.10.1':
resolution: {integrity: sha512-da+MMyeXhBaKtxQiWPfy7+056wk3lVIhioJnXHXkJ2/OHDaZfFcyKHNl1R06sdYO8lIRXcXdoZ6LO2ARmkAREA==}
engines: {node: '>=18.16.0'}
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
@@ -2050,6 +2080,9 @@ packages:
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/pluralize@0.0.33':
resolution: {integrity: sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==}
'@types/resolve@1.20.2':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
@@ -2611,6 +2644,10 @@ packages:
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
case-anything@3.1.2:
resolution: {integrity: sha512-wljhAjDDIv/hM2FzgJnYQg90AWmZMNtESCjTeLH680qTzdo0nErlCxOmgzgX4ZsZAtIvqHyD87ES8QyriXB+BQ==}
engines: {node: '>=18'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
@@ -3332,6 +3369,10 @@ packages:
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
flattie@1.1.1:
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -3596,6 +3637,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
js-beautify@1.15.4:
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
engines: {node: '>=14'}
@@ -3947,6 +3991,23 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
nuxt-auth-utils@0.5.29:
resolution: {integrity: sha512-aQ9oD8QR51jUCe2BFEsO/G/E0K+XUy8Skjn3hYcNLiqZ9XE4Y3uT/ozBCmAQ+TR3WW6prj8vUR0wQes8W1N0PA==}
peerDependencies:
'@atproto/api': ^0.13.15
'@atproto/oauth-client-node': ^0.2.0
'@simplewebauthn/browser': ^11.0.0
'@simplewebauthn/server': ^11.0.0
peerDependenciesMeta:
'@atproto/api':
optional: true
'@atproto/oauth-client-node':
optional: true
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nuxt@4.4.6:
resolution: {integrity: sha512-QAApJpAx3yGf3pYudALkInuBfv0WkHCiol6ntTvh/lwKwYrcU/MRI1nLNGt0QNyUCgBWdOQukd3z67VJ2xGd0Q==}
engines: {node: ^22.12.0 || ^24.11.0 || >=26.0.0}
@@ -3965,6 +4026,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
oauth4webapi@3.8.6:
resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
object-deep-merge@2.0.1:
resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==}
@@ -4000,6 +4064,9 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
openid-client@6.8.4:
resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -4480,6 +4547,10 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
@@ -4491,6 +4562,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -4558,6 +4632,10 @@ packages:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
slugify@1.6.9:
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
engines: {node: '>=8.0.0'}
smob@1.6.2:
resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==}
engines: {node: '>=20.0.0'}
@@ -5234,6 +5312,11 @@ packages:
snapshots:
'@adonisjs/hash@9.1.1':
dependencies:
'@phc/format': 1.0.0
'@poppinss/utils': 6.10.1
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
@@ -6654,6 +6737,8 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@phc/format@1.0.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -6671,6 +6756,24 @@ snapshots:
'@poppinss/exception@1.2.3': {}
'@poppinss/object-builder@1.1.0': {}
'@poppinss/string@1.7.2':
dependencies:
'@types/pluralize': 0.0.33
case-anything: 3.1.2
pluralize: 8.0.0
slugify: 1.6.9
'@poppinss/utils@6.10.1':
dependencies:
'@poppinss/exception': 1.2.3
'@poppinss/object-builder': 1.1.0
'@poppinss/string': 1.7.2
flattie: 1.1.1
safe-stable-stringify: 2.5.0
secure-json-parse: 4.1.0
'@rolldown/pluginutils@1.0.1': {}
'@rollup/plugin-alias@6.0.0(rollup@4.60.4)':
@@ -6937,6 +7040,8 @@ snapshots:
pg-protocol: 1.14.0
pg-types: 2.2.0
'@types/pluralize@0.0.33': {}
'@types/resolve@1.20.2': {}
'@types/whatwg-mimetype@3.0.2': {}
@@ -7540,6 +7645,8 @@ snapshots:
caniuse-lite@1.0.30001793: {}
case-anything@3.1.2: {}
chai@6.2.2: {}
change-case@5.4.4: {}
@@ -8260,6 +8367,8 @@ snapshots:
flatted@3.4.2: {}
flattie@1.1.1: {}
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -8503,6 +8612,8 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.3: {}
js-beautify@1.15.4:
dependencies:
config-chain: 1.1.13
@@ -8895,6 +9006,24 @@ snapshots:
dependencies:
boolbase: 1.0.0
nuxt-auth-utils@0.5.29(magicast@0.5.3):
dependencies:
'@adonisjs/hash': 9.1.1
'@nuxt/kit': 4.4.6(magicast@0.5.3)
defu: 6.1.7
h3: 1.15.11
hookable: 6.1.1
jose: 6.2.3
ofetch: 1.5.1
openid-client: 6.8.4
pathe: 2.0.3
scule: 1.3.0
uncrypto: 0.1.3
transitivePeerDependencies:
- argon2
- bcrypt
- magicast
nuxt@4.4.6(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.9.1)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0)))(drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0))(eslint@10.4.0(jiti@2.7.0))(ioredis@5.11.0)(lightningcss@1.32.0)(magicast@0.5.3)(optionator@0.9.4)(rollup-plugin-visualizer@7.0.1(rollup@4.60.4))(rollup@4.60.4)(srvx@0.11.16)(terser@5.48.0)(tsx@4.22.3)(typescript@6.0.3)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(yaml@2.9.0):
dependencies:
'@dxup/nuxt': 0.4.1(magicast@0.5.3)(typescript@6.0.3)
@@ -9031,6 +9160,8 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.2.2
oauth4webapi@3.8.6: {}
object-deep-merge@2.0.1: {}
obug@2.1.1: {}
@@ -9071,6 +9202,11 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openid-client@6.8.4:
dependencies:
jose: 6.2.3
oauth4webapi: 3.8.6
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -9587,6 +9723,8 @@ snapshots:
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
sax@1.6.0: {}
scslre@0.3.0:
@@ -9597,6 +9735,8 @@ snapshots:
scule@1.3.0: {}
secure-json-parse@4.1.0: {}
semver@6.3.1: {}
semver@7.8.1: {}
@@ -9670,6 +9810,8 @@ snapshots:
slash@5.1.0: {}
slugify@1.6.9: {}
smob@1.6.2: {}
source-map-js@1.2.1: {}

View File

@@ -11,5 +11,5 @@ export default defineEventHandler(async (event) => {
if (input instanceof type.errors) {
throw createError({ statusCode: 400, statusMessage: input.summary })
}
return createDefect(getDb(), { ...input, reporterEmail: getReporter(event) })
return createDefect(getDb(), { ...input, reporterEmail: await getReporter(event) })
})

15
server/middleware/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import { isPublicApiPath } from '../utils/authPaths'
// Default-deny gate for the data API (F9). Every `/api` route requires an
// authenticated session except the explicitly public ones (health probe, the
// session endpoint). Pages are guarded separately by app/middleware/auth.global
// so static assets and the OAuth route stay reachable while unauthenticated.
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/')) return
if (isPublicApiPath(event.path)) return
const { user } = await getUserSession(event)
if (!user) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
})

View File

@@ -0,0 +1,25 @@
import { isAllowedGoogleUser, type GoogleUserInfo } from '../../../utils/allowedGoogleUser'
// Google OAuth entry + callback (F9). nuxt-auth-utils uses one handler for
// both legs: with no `?code` it redirects to Google; on return it exchanges the
// code and hands us the verified userinfo. We then re-check the email domain
// server-side (the `hd` claim is not trusted) before minting a session.
export default defineOAuthGoogleEventHandler({
config: {},
async onSuccess(event, { user }) {
const info = user as GoogleUserInfo
if (!isAllowedGoogleUser(info)) {
await clearUserSession(event)
return sendRedirect(event, '/login?error=domain')
}
await setUserSession(event, {
// isAllowedGoogleUser guarantees a present email; narrow for the compiler.
user: { email: info.email!, name: user.name, picture: user.picture },
})
return sendRedirect(event, '/')
},
onError(event, error) {
console.error('Google OAuth error:', error)
return sendRedirect(event, '/login?error=oauth')
},
})

View File

@@ -0,0 +1,21 @@
// Authorisation gate for Google identities (F9, DESIGN T9).
//
// Access is restricted to the company's verified email domain. We deliberately
// ignore Google's `hd` (hosted-domain) claim as the source of truth: a personal
// account can be made to present an `hd`, so we re-derive the domain from the
// email Google itself reports as verified.
const ALLOWED_EMAIL_DOMAIN = 'theodo.com'
export interface GoogleUserInfo {
email?: string
email_verified?: boolean | string
// `hd` is intentionally not consulted — see the module comment.
hd?: string
}
export function isAllowedGoogleUser(user: GoogleUserInfo): boolean {
const verified = user.email_verified === true || user.email_verified === 'true'
if (!verified || !user.email) return false
const domain = user.email.split('@')[1]?.toLowerCase()
return domain === ALLOWED_EMAIL_DOMAIN
}

12
server/utils/authPaths.ts Normal file
View File

@@ -0,0 +1,12 @@
// Which `/api` routes bypass the default-deny auth middleware (F9).
//
// `/api/health` must answer for the orchestrator's liveness probe before any
// session exists; `/api/_auth/*` is nuxt-auth-utils' own session endpoint that
// the client uses to read/clear the session. Everything else under `/api`
// requires an authenticated session.
const PUBLIC_API_PREFIXES = ['/api/health', '/api/_auth/']
export function isPublicApiPath(path: string): boolean {
const pathname = path.split('?')[0]!
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix))
}

View File

@@ -2,9 +2,14 @@ import type { H3Event } from 'h3'
/**
* Resolves the reporter's email — the one seam between filing and auth (ADR
* 0002). For now it returns a fixed dev identity so the filing slice works
* before OAuth exists; Task 9 swaps this to read the verified session email.
* 0002). Backed by the authenticated Google session (Task 9) so defects record
* the true reporter. The auth middleware already gates the filing endpoint, so
* a missing session here is an unexpected 401, not a normal path.
*/
export function getReporter(_event: H3Event): string {
return process.env.DEV_REPORTER_EMAIL ?? 'dev@theodo.com'
export async function getReporter(event: H3Event): Promise<string> {
const { user } = await getUserSession(event)
if (!user?.email) {
throw createError({ statusCode: 401, statusMessage: 'Authentication required' })
}
return user.email
}

12
shared/auth.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
// Shape of the authenticated session for nuxt-auth-utils (Task 9, F9).
// Lives in shared/ so both the app and server TS projects pick up the
// #auth-utils augmentation.
declare module '#auth-utils' {
interface User {
email: string
name?: string
picture?: string
}
}
export {}

View File

@@ -19,10 +19,10 @@ review.
- [x] **T6** Defects read API: feed + per-section counts (C7) — F6 (M) — _deps: T3_
- [x] **T7** Dashboard DotMap (C5) — F4 (M) — _deps: T2, T6_
- [x] **T8** VerbatimModal + Feed (C6) — F5 (M) — _deps: T6, T7_
- [ ]**Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
- [x]**Checkpoint:** dashboard dot map + feed + modal work, ≤350ms @2k, human review
## Phase 4 — Authentication (G4)
- [ ] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
- [x] **T9** Google OAuth + session + domain-check middleware (C9) — F9 (M) — _deps: T1, retrofits T5_
- [ ]**Checkpoint:** both views gated to @theodo.com, reporters real, human review
## Phase 5 — Notifications (G3)
@@ -35,7 +35,7 @@ review.
- [ ]**Checkpoint:** data survives redeploy, both subdomains live, ready for review
## Blocked on open questions (see plan.md)
- [ ] Google OAuth client provisioning (blocks T9)
- [x] Google OAuth client provisioning (blocks T9) — client provided; redirect URI `…/auth/google/callback`
- [ ] Confirm pnpm + Vitest + Playwright vs Theodo template (affects T1)
- [ ] Coolify two-domain routing model (affects T12)
- [ ] "Owner only" vs any subscribed user for push (affects T10/T11)

52
tests/auth.spec.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { isAllowedGoogleUser } from '../server/utils/allowedGoogleUser'
import { isPublicApiPath } from '../server/utils/authPaths'
// F9: access is restricted to verified @theodo.com identities. The Google
// `hd` (hosted-domain) claim is NOT trusted on its own — we re-derive the
// domain from the verified email server-side (DESIGN T9).
describe('isAllowedGoogleUser', () => {
it('allows a verified @theodo.com email', () => {
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: true })).toBe(true)
})
it('denies a non-theodo verified email even when hd claims theodo.com', () => {
expect(
isAllowedGoogleUser({ email: 'attacker@gmail.com', email_verified: true, hd: 'theodo.com' }),
).toBe(false)
})
it('denies a theodo email that Google has not verified', () => {
expect(isAllowedGoogleUser({ email: 'jane@theodo.com', email_verified: false })).toBe(false)
})
it('matches the domain case-insensitively', () => {
expect(isAllowedGoogleUser({ email: 'jane@Theodo.COM', email_verified: true })).toBe(true)
})
it('denies when the email is missing', () => {
expect(isAllowedGoogleUser({ email_verified: true, hd: 'theodo.com' })).toBe(false)
})
it('denies a look-alike domain that merely ends in theodo.com', () => {
expect(isAllowedGoogleUser({ email: 'eve@nottheodo.com', email_verified: true })).toBe(false)
})
})
describe('isPublicApiPath', () => {
it('treats the health check as public', () => {
expect(isPublicApiPath('/api/health')).toBe(true)
})
it('treats the session endpoint as public', () => {
expect(isPublicApiPath('/api/_auth/session')).toBe(true)
})
it('gates the defects API', () => {
expect(isPublicApiPath('/api/defects')).toBe(false)
})
it('ignores the query string when matching', () => {
expect(isPublicApiPath('/api/health?probe=1')).toBe(true)
})
})