diff --git a/.env.example b/.env.example index d9e1267..864f0e4 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ NUXT_ADMIN_USERNAME=admin # Default from nuxt.config.ts: 123 NUXT_ADMIN_PASSWORD=123 +# Optional static bearer token for software admin authentication. +# Leave empty to disable this auth path. Only one exact token is accepted. +NUXT_ADMIN_TOKEN= + # Private internal port for the embedded Drizzle Studio worker. # Default from nuxt.config.ts: 64983 NUXT_DRIZZLE_STUDIO_INTERNAL_PORT=64983 diff --git a/docs/api.md b/docs/api.md index d8da5c8..b82d9fe 100755 --- a/docs/api.md +++ b/docs/api.md @@ -8,6 +8,8 @@ REST API endpoints documentation. Login as administrator. +This endpoint is for browser-style admin login with username and password. It sets the session cookie used by the admin UI. + **Request:** ```json @@ -18,7 +20,7 @@ Login as administrator. ``` **Response:** -Sets `admin_token` HTTP-only cookie and returns JWT token. +Sets `admin_token` HTTP-only cookie and returns success. ### POST `/api/auth/logout` @@ -41,17 +43,30 @@ Verify authentication token (admin only). - Cookie: `admin_token` (set automatically by login) - Or `Authorization: Bearer ` +`Authorization: Bearer ` accepts two admin auth modes: + +- A valid admin JWT only when a client already has that token. `POST /api/auth/login` does not return the JWT in the response body; it sets the `admin_token` HTTP-only cookie for browser admin sessions. +- The exact static token configured in `NUXT_ADMIN_TOKEN` for software-to-software admin access. External API clients should use this static token path. + **Response:** ```json { "valid": true, "user": { - /* decoded JWT payload */ + /* decoded JWT payload or static-token admin payload */ } } ``` +**Static token example:** + +```bash +curl \ + -H "Authorization: Bearer $NUXT_ADMIN_TOKEN" \ + http://localhost:3000/api/auth/verify +``` + ## Database Admin ### GET `/api/admin/drizzle-studio/app` diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md index 7b53780..b98d81a 100644 --- a/docs/deployment-docker.md +++ b/docs/deployment-docker.md @@ -48,9 +48,12 @@ Set a strong admin password as well: # In .env: NUXT_ADMIN_USERNAME=admin NUXT_ADMIN_PASSWORD= +NUXT_ADMIN_TOKEN= NUXT_JWT_SECRET= ``` +Set `NUXT_ADMIN_TOKEN` only if external software needs direct admin API access with `Authorization: Bearer `. Leave it empty to disable that path. Only one exact token is accepted. + Optional override for the embedded Drizzle Studio worker port: ```bash diff --git a/docs/setup-production.md b/docs/setup-production.md index 3ce365d..9b896b3 100755 --- a/docs/setup-production.md +++ b/docs/setup-production.md @@ -55,9 +55,12 @@ Set production values for: - `NUXT_ADMIN_USERNAME` - `NUXT_ADMIN_PASSWORD` +- `NUXT_ADMIN_TOKEN` when external software needs direct admin bearer-token access - `NUXT_JWT_SECRET` - `NUXT_DRIZZLE_STUDIO_INTERNAL_PORT` when you need a non-default private Studio worker port +Leave `NUXT_ADMIN_TOKEN` empty if you do not want static admin-token authentication. If you set it, keep it secret and rotate it by updating the environment and redeploying. + ## Data Persistence - Quiz data is stored in SQLite at `.data/db/stage-flow-tools.sqlite3`. diff --git a/nuxt.config.ts b/nuxt.config.ts index cca2b92..f5451cf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -22,6 +22,7 @@ const configBase: InputConfig = { runtimeConfig: { // Private keys (only available server-side) adminPassword: '123', + adminToken: '', adminUsername: 'admin', drizzleStudioInternalPort: '64983', jwtSecret: 'tryUJ0zQbstPbTOrezme+Fv+KndzDNRx5lmSeelr2ial2/2yV8HqLeQ2felJafqf', diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 9679744..54af9e0 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,6 +1,12 @@ import { jwtVerify } from 'jose' import type { H3Event } from 'h3' +type VerifiedAdminPayload = { + authMethod?: 'static-token' + isAdmin: true + username: string +} + /** Sets the admin_token cookie with protocol-aware security attributes. */ export function setAdminCookie(event: H3Event, value: string, maxAge: number) { const isSecure = getRequestProtocol(event) === 'https' @@ -14,12 +20,24 @@ export function setAdminCookie(event: H3Event, value: string, maxAge: number) { } /** - * Extracts the JWT from cookies or headers. + * Extracts the admin auth token from cookies or headers. * @param event The H3 event object. * @returns The token string or undefined. */ export function getToken(event: H3Event): string | undefined { - return getCookie(event, 'admin_token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + return getHeader(event, 'authorization')?.replace('Bearer ', '') || getCookie(event, 'admin_token') +} + +function getStaticAdminPayload(token: string, configuredToken: string): VerifiedAdminPayload | null { + if (!configuredToken || token !== configuredToken) { + return null + } + + return { + authMethod: 'static-token', + isAdmin: true, + username: 'admin-token', + } } /** @@ -38,6 +56,12 @@ export async function verifyAdmin(event: H3Event) { }) } + const staticAdminPayload = getStaticAdminPayload(token, config.adminToken) + + if (staticAdminPayload) { + return staticAdminPayload + } + try { const secret = new TextEncoder().encode(config.jwtSecret) const { payload } = await jwtVerify(token, secret, { algorithms: [