Răsfoiți Sursa

Add OIDC_ENABLED kill-switch for dev / testing on local-admin only

OIDC_ENABLED=false (or 0/no/off, case-insensitive) makes
OidcClient::isConfigured() return false even when ENTRA_* are populated,
so dev / on-prem deployments can route everyone through LOCAL_ADMIN_*
without unsetting the Entra creds. /auth/login and /auth/callback both
land on the operator config-error page (503) with copy that distinguishes
"disabled by flag" from "not configured". Bootstrap also refuses to
serve in APP_ENV=production when neither OIDC nor local admin is
enabled, so a fully unreachable instance can't ship silently.

Pinned by tests/Auth/OidcClientTest.php (6 cases). Documented in SPEC §8
and .env.example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 2 zile în urmă
părinte
comite
60aeb55a73
7 a modificat fișierele cu 233 adăugiri și 8 ștergeri
  1. 10 0
      .env.example
  2. 25 0
      SPEC.md
  3. 22 0
      public/index.php
  4. 19 0
      src/Auth/OidcClient.php
  5. 20 7
      src/Controllers/AuthController.php
  6. 135 0
      tests/Auth/OidcClientTest.php
  7. 2 1
      views/home.twig

+ 10 - 0
.env.example

@@ -3,6 +3,16 @@ ENTRA_TENANT_ID=
 ENTRA_CLIENT_ID=
 ENTRA_CLIENT_SECRET=
 
+# Hard switch to disable OIDC even when ENTRA_* are populated. Useful for
+# dev / testing / on-prem deployments that want to keep the Entra creds in
+# .env but route everyone through the LOCAL_ADMIN_* fallback below. Accepted
+# disabling values: false / 0 / no / off (case-insensitive). Anything else,
+# including blank, leaves OIDC enabled.
+# In APP_ENV=production the bootstrap refuses to start when neither OIDC nor
+# LOCAL_ADMIN_* is enabled — so disabling OIDC in prod requires a working
+# local admin.
+OIDC_ENABLED=true
+
 # Base URL the app is reachable at (no trailing slash).
 # Used to build the OIDC redirect URI {APP_BASE_URL}/auth/callback
 APP_BASE_URL=http://localhost:8080

+ 25 - 0
SPEC.md

@@ -48,6 +48,9 @@ per-cell audit trail.
     deps vendored under `public/assets/js/vendor/`.
 - Auth: Microsoft Entra ID via OpenID Connect (Authorization Code + PKCE),
   plus an optional env-configured "local admin" fallback for dev / on-prem.
+  OIDC can be hard-disabled with `OIDC_ENABLED=false`, leaving the local
+  admin as the sole sign-in path; this is the supported dev / testing
+  configuration.
 - Composer deps: `twig/twig`, `jumbojett/openid-connect-php`,
   `vlucas/phpdotenv`, `phpoffice/phpspreadsheet` (Phase 20 — XLSX import
   wizard), `phpunit/phpunit` (dev).
@@ -298,6 +301,12 @@ DB_PATH=/var/www/data/app.sqlite
 SESSION_PATH=/var/www/data/sessions
 APP_ENV=production
 
+# Hard switch to disable OIDC even when ENTRA_* are populated. Accepted
+# disabling values: false / 0 / no / off (case-insensitive). Blank/unset
+# = enabled. Used by dev / testing / on-prem deployments to route all
+# sign-ins through LOCAL_ADMIN_* below.
+OIDC_ENABLED=true
+
 # Reverse-proxy trust (R01-N05 / R01-N07). Comma-separated CIDRs of the
 # proxies in front of the app. When `REMOTE_ADDR` matches one of these the
 # app walks `X-Forwarded-For` for the originating client IP (audit log,
@@ -339,6 +348,22 @@ First-admin bootstrap (R01-N03 hardening):
 The pre-R01-N03 behaviour (first user to sign in via any path becomes admin)
 is gone — see `src/Auth/BootstrapAdmin.php`.
 
+OIDC kill-switch (`OIDC_ENABLED=false`):
+
+- `OidcClient::isConfigured()` honours the flag — when set to a falsey value
+  (`false` / `0` / `no` / `off`, case-insensitive, leading/trailing whitespace
+  trimmed), it returns `false` even if `ENTRA_*` are populated. The home page
+  hides the "Sign in with Microsoft" button, `/auth/login` returns 503 with
+  the standard config-error page, and the OIDC callback path stays inert.
+- The flag is intended for dev / testing / on-prem setups that want to
+  authenticate through `LOCAL_ADMIN_*` only — typical pairing is
+  `APP_ENV=development` + `OIDC_ENABLED=false` +
+  `LOCAL_ADMIN_EMAIL` / `LOCAL_ADMIN_PASSWORD_HASH`.
+- Production guard (`public/index.php`): when `APP_ENV=production` AND OIDC is
+  not configured AND local admin is not enabled, the bootstrap refuses to
+  serve any request — 503 + `Retry-After: 30` + `error_log` line. This stops
+  a fully unreachable instance from shipping silently.
+
 ## 9. Build phases — status
 
 ### Shipped

+ 22 - 0
public/index.php

@@ -80,6 +80,28 @@ if ($appEnv !== 'production') {
     ini_set('display_errors', '0');
 }
 
+// Refuse to boot in production with no working sign-in method. `OIDC_ENABLED=
+// false` (or simply blank ENTRA_* vars) is fine in dev/testing on top of a
+// local-admin fallback, but in prod the operator almost certainly didn't mean
+// to ship a fully unreachable instance. Fail fast with a clear log line so the
+// misconfiguration surfaces at deploy time, not after the first user tries to
+// sign in. Same posture as the schema-pending check below: 503 + Retry-After.
+if ($appEnv === 'production'
+    && !OidcClient::isConfigured()
+    && !LocalAdmin::isEnabled()
+) {
+    error_log(
+        'refusing to start: APP_ENV=production but no sign-in method is '
+        . 'configured (set ENTRA_* with OIDC_ENABLED!=false, or '
+        . 'LOCAL_ADMIN_EMAIL + LOCAL_ADMIN_PASSWORD_HASH).'
+    );
+    http_response_code(503);
+    header('Content-Type: text/plain; charset=utf-8');
+    header('Retry-After: 30');
+    echo "Service Unavailable: no sign-in method configured.\n";
+    exit;
+}
+
 // ---------------------------------------------------------------------------
 // R01-N13: install the fatal-error safety net AS EARLY AS POSSIBLE — before
 // migrations, before service wiring. An uncaught throwable from anywhere

+ 19 - 0
src/Auth/OidcClient.php

@@ -48,6 +48,9 @@ final class OidcClient
 
     public static function isConfigured(): bool
     {
+        if (self::isExplicitlyDisabled()) {
+            return false;
+        }
         foreach (['ENTRA_TENANT_ID', 'ENTRA_CLIENT_ID', 'ENTRA_CLIENT_SECRET', 'APP_BASE_URL'] as $k) {
             $v = getenv($k);
             if (!is_string($v) || $v === '') {
@@ -57,6 +60,22 @@ final class OidcClient
         return true;
     }
 
+    /**
+     * `OIDC_ENABLED=false` (or `0` / `no` / `off`) turns OIDC off even when
+     * `ENTRA_*` are populated — for dev/testing on top of `LOCAL_ADMIN_*`,
+     * or for an on-prem deployment that wants to keep the Entra creds in
+     * .env but route everyone through the local admin fallback. Any other
+     * value (including blank/unset) leaves OIDC enabled.
+     */
+    public static function isExplicitlyDisabled(): bool
+    {
+        $v = getenv('OIDC_ENABLED');
+        if (!is_string($v)) {
+            return false;
+        }
+        return in_array(strtolower(trim($v)), ['false', '0', 'no', 'off'], true);
+    }
+
     private static function env(string $name): string
     {
         $v = getenv($name);

+ 20 - 7
src/Controllers/AuthController.php

@@ -70,6 +70,14 @@ final class AuthController
     {
         SessionGuard::start();
 
+        // Mirror the kill-switch from /auth/login: when OIDC is unconfigured
+        // or explicitly disabled (`OIDC_ENABLED=false`), the callback path is
+        // inert — render the same operator-facing config page instead of
+        // letting the OIDC library try to validate state we'll never accept.
+        if (!OidcClient::isConfigured()) {
+            return Response::html($this->configErrorPage(), 503);
+        }
+
         // Entra can redirect back with an explicit error (e.g. user denied consent).
         if (isset($req->query['error'])) {
             $desc = (string) ($req->query['error_description'] ?? $req->query['error']);
@@ -350,16 +358,21 @@ final class AuthController
 
     private function configErrorPage(): string
     {
+        $disabled = OidcClient::isExplicitlyDisabled();
+        $title    = $disabled ? 'OIDC disabled' : 'OIDC not configured';
+        $body     = $disabled
+            ? '<code>OIDC_ENABLED</code> is set to <code>false</code> in <code>.env</code>. '
+                . 'Use the local admin sign-in (<code>/auth/local</code>) or re-enable OIDC '
+                . 'and restart the container.'
+            : 'Set <code>ENTRA_TENANT_ID</code>, <code>ENTRA_CLIENT_ID</code>, '
+                . '<code>ENTRA_CLIENT_SECRET</code> and <code>APP_BASE_URL</code> in '
+                . '<code>.env</code> and restart the container.';
         return <<<HTML
             <!doctype html><meta charset="utf-8">
-            <title>OIDC not configured</title>
+            <title>{$title}</title>
             <div style="font-family:system-ui;max-width:560px;margin:4rem auto;padding:1rem;border:1px solid #e2e8f0;border-radius:8px">
-              <h1 style="margin:0 0 .5rem;font-size:1.1rem">Sign-in is not configured</h1>
-              <p style="color:#475569;line-height:1.5">
-                Set <code>ENTRA_TENANT_ID</code>, <code>ENTRA_CLIENT_ID</code>,
-                <code>ENTRA_CLIENT_SECRET</code> and <code>APP_BASE_URL</code> in
-                <code>.env</code> and restart the container.
-              </p>
+              <h1 style="margin:0 0 .5rem;font-size:1.1rem">Sign-in is not available</h1>
+              <p style="color:#475569;line-height:1.5">{$body}</p>
             </div>
             HTML;
     }

+ 135 - 0
tests/Auth/OidcClientTest.php

@@ -0,0 +1,135 @@
+<?php
+/*
+ * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * See the LICENSE file in the project root for the full license text.
+ */
+
+declare(strict_types=1);
+
+namespace App\Tests\Auth;
+
+use App\Auth\OidcClient;
+use App\Tests\TestCase;
+
+/**
+ * Pins the OIDC env-driven gating: `isConfigured()` is true only when every
+ * ENTRA_* + APP_BASE_URL var is set AND `OIDC_ENABLED` isn't explicitly false.
+ * The `OIDC_ENABLED=false` path is the dev/testing kill-switch that lets
+ * operators run on the LOCAL_ADMIN_* fallback alone without unsetting the
+ * Entra creds in .env.
+ */
+final class OidcClientTest extends TestCase
+{
+    /** @var array<string, string|false> */
+    private array $envBackup = [];
+
+    /** @var string[] */
+    private array $envKeys = [
+        'ENTRA_TENANT_ID',
+        'ENTRA_CLIENT_ID',
+        'ENTRA_CLIENT_SECRET',
+        'APP_BASE_URL',
+        'OIDC_ENABLED',
+    ];
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        foreach ($this->envKeys as $k) {
+            $this->envBackup[$k] = getenv($k);
+            putenv($k);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        foreach ($this->envKeys as $k) {
+            $prev = $this->envBackup[$k] ?? false;
+            if ($prev === false) {
+                putenv($k);
+            } else {
+                putenv("{$k}={$prev}");
+            }
+        }
+        parent::tearDown();
+    }
+
+    private function setEntraVars(): void
+    {
+        putenv('ENTRA_TENANT_ID=tenant-guid');
+        putenv('ENTRA_CLIENT_ID=client-guid');
+        putenv('ENTRA_CLIENT_SECRET=secret');
+        putenv('APP_BASE_URL=https://example.com');
+    }
+
+    public function testNotConfiguredWithoutEntraVars(): void
+    {
+        self::assertFalse(OidcClient::isConfigured());
+        self::assertFalse(OidcClient::isExplicitlyDisabled());
+    }
+
+    public function testConfiguredWhenAllEntraVarsSet(): void
+    {
+        $this->setEntraVars();
+        self::assertTrue(OidcClient::isConfigured());
+        self::assertFalse(OidcClient::isExplicitlyDisabled());
+    }
+
+    public function testConfiguredStaysTrueWhenFlagBlankOrUnset(): void
+    {
+        $this->setEntraVars();
+        // Unset (default of putenv with no =).
+        putenv('OIDC_ENABLED');
+        self::assertTrue(OidcClient::isConfigured());
+        // Explicit blank.
+        putenv('OIDC_ENABLED=');
+        self::assertTrue(OidcClient::isConfigured());
+        self::assertFalse(OidcClient::isExplicitlyDisabled());
+    }
+
+    public function testConfiguredStaysTrueWhenFlagAnyTruthyValue(): void
+    {
+        $this->setEntraVars();
+        foreach (['true', '1', 'yes', 'on', 'TRUE', 'enabled', 'whatever'] as $v) {
+            putenv("OIDC_ENABLED={$v}");
+            self::assertTrue(
+                OidcClient::isConfigured(),
+                "isConfigured() should be true with OIDC_ENABLED={$v}",
+            );
+            self::assertFalse(
+                OidcClient::isExplicitlyDisabled(),
+                "isExplicitlyDisabled() should be false with OIDC_ENABLED={$v}",
+            );
+        }
+    }
+
+    public function testFalseyFlagDisablesOidcEvenWithEntraVarsSet(): void
+    {
+        $this->setEntraVars();
+        foreach (['false', '0', 'no', 'off', 'FALSE', 'No', ' off '] as $v) {
+            putenv("OIDC_ENABLED={$v}");
+            self::assertFalse(
+                OidcClient::isConfigured(),
+                "isConfigured() should be false with OIDC_ENABLED={$v}",
+            );
+            self::assertTrue(
+                OidcClient::isExplicitlyDisabled(),
+                "isExplicitlyDisabled() should be true with OIDC_ENABLED={$v}",
+            );
+        }
+    }
+
+    public function testFalseyFlagWithoutEntraVarsStillReportsDisabled(): void
+    {
+        // No ENTRA_*. Flag set false. isConfigured stays false (already was);
+        // isExplicitlyDisabled tells the operator-facing page which message
+        // to show ("disabled" vs. "not configured").
+        putenv('OIDC_ENABLED=false');
+        self::assertFalse(OidcClient::isConfigured());
+        self::assertTrue(OidcClient::isExplicitlyDisabled());
+    }
+}

+ 2 - 1
views/home.twig

@@ -36,7 +36,8 @@
                 {% endif %}
                 {% if not oidcConfigured and not localAdminEnabled %}
                     <span class="inline-block rounded-md bg-slate-100 text-slate-600 px-3 py-2 text-sm dark:bg-slate-700 dark:text-slate-300">
-                        No sign-in method configured. Set <code>ENTRA_*</code> or
+                        No sign-in method configured. Set <code>ENTRA_*</code> (with
+                        <code>OIDC_ENABLED</code> not set to <code>false</code>) or
                         <code>LOCAL_ADMIN_*</code> in <code>.env</code>.
                     </span>
                 {% endif %}