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:
15
.env.example
15
.env.example
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
13
app/middleware/auth.global.ts
Normal file
13
app/middleware/auth.global.ts
Normal 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
32
app/pages/login.vue
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
142
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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
15
server/middleware/auth.ts
Normal 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' })
|
||||
}
|
||||
})
|
||||
25
server/routes/auth/google/callback.get.ts
Normal file
25
server/routes/auth/google/callback.get.ts
Normal 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')
|
||||
},
|
||||
})
|
||||
21
server/utils/allowedGoogleUser.ts
Normal file
21
server/utils/allowedGoogleUser.ts
Normal 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
12
server/utils/authPaths.ts
Normal 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))
|
||||
}
|
||||
@@ -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
12
shared/auth.d.ts
vendored
Normal 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 {}
|
||||
@@ -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
52
tests/auth.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user