Bläddra i källkod

Phase 2: Entra OIDC auth + session + audit log

Migration 001_init.sql lays down the full schema from spec section 3 up
front (users, workers, sprints, sprint_weeks, sprint_workers,
sprint_worker_days, tasks, task_assignments, audit_log + indexes). Later
phases build on these tables without further migrations.

Auth:
- OidcClient — factory around jumbojett/openid-connect-php configured for
  Entra v2.0, Authorization Code + PKCE (S256), scopes openid/profile/email.
- SessionGuard — start/login/logout/currentUser with HttpOnly SameSite=Lax
  cookies (Secure when APP_BASE_URL is https), plus CSRF token + verify.
- AuthController:
    GET  /auth/login     -> redirects to Entra authorize endpoint
    GET  /auth/callback  -> verifies code, upserts user, first-user-becomes-
                           admin bootstrap, emits CREATE/UPDATE + optional
                           BOOTSTRAP_ADMIN + LOGIN audit rows in one tx
    POST /auth/logout    -> CSRF-checked; emits LOGOUT, clears session
  On any failure the controller records a LOGIN_FAILED audit row with the
  reason and redirects to /?auth_error=1.

Audit:
- AuditLogger::record() takes before/after arrays, writes a single row in
  the caller transaction. No-op UPDATEs (before === after after canonical
  key sort) are skipped per spec section 5. Other actions always write.

UI:
- Layout header shows the signed-in user (+ admin badge) and a CSRF-
  protected sign-out form, or a Sign-in link when anonymous.
- Home page shows the sign-in CTA for anonymous visitors, a welcome +
  role for signed-in users, an inline auth-error banner driven by
  ?auth_error=1, and still exposes the runtime diagnostics from Phase 1.

Verified:
- php -l on all changed files.
- End-to-end: migrations -> user upsert (new + repeat) -> audit rows
  (CREATE, BOOTSTRAP_ADMIN on first user only, LOGIN, UPDATE on re-login,
  LOGIN_FAILED with null user_id). No-op UPDATE writes no row.
- Home template renders correctly for anonymous/signed-in/error states.
- OidcClient::create() returns Jumbojett\OpenIDConnectClient with PKCE=S256
  and the right Entra v2.0 issuer URL.
- Router dispatches the three auth routes; GET /auth/logout returns 405.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
achiappa 2 veckor sedan
förälder
incheckning
be193d258f

+ 100 - 0
migrations/001_init.sql

@@ -0,0 +1,100 @@
+-- Phase 2/3 initial schema.
+--
+-- Users = people who sign in via Entra ID.
+-- Workers = entities that get work assigned (may or may not also be users).
+-- Never conflate the two.
+
+CREATE TABLE users (
+    id              INTEGER PRIMARY KEY,
+    entra_oid       TEXT NOT NULL UNIQUE,
+    email           TEXT NOT NULL,
+    display_name    TEXT NOT NULL,
+    is_admin        INTEGER NOT NULL DEFAULT 0,
+    created_at      TEXT NOT NULL,
+    last_login_at   TEXT
+);
+
+CREATE TABLE workers (
+    id           INTEGER PRIMARY KEY,
+    name         TEXT NOT NULL UNIQUE,
+    is_active    INTEGER NOT NULL DEFAULT 1,
+    default_rtb  REAL NOT NULL DEFAULT 0.0,
+    created_at   TEXT NOT NULL,
+    updated_at   TEXT NOT NULL
+);
+
+CREATE TABLE sprints (
+    id                INTEGER PRIMARY KEY,
+    name              TEXT NOT NULL,
+    start_date        TEXT NOT NULL,
+    end_date          TEXT NOT NULL,
+    reserve_fraction  REAL NOT NULL DEFAULT 0.2,
+    is_archived       INTEGER NOT NULL DEFAULT 0,
+    created_at        TEXT NOT NULL,
+    updated_at        TEXT NOT NULL
+);
+
+CREATE TABLE sprint_weeks (
+    id                    INTEGER PRIMARY KEY,
+    sprint_id             INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
+    sort_order            INTEGER NOT NULL,
+    iso_week              INTEGER NOT NULL,
+    start_date            TEXT NOT NULL,
+    max_working_days      REAL NOT NULL,
+    UNIQUE (sprint_id, sort_order)
+);
+
+CREATE TABLE sprint_workers (
+    id           INTEGER PRIMARY KEY,
+    sprint_id    INTEGER NOT NULL REFERENCES sprints(id)  ON DELETE CASCADE,
+    worker_id    INTEGER NOT NULL REFERENCES workers(id)  ON DELETE RESTRICT,
+    rtb          REAL NOT NULL DEFAULT 0.0,
+    sort_order   INTEGER NOT NULL,
+    UNIQUE (sprint_id, worker_id)
+);
+
+CREATE TABLE sprint_worker_days (
+    id                INTEGER PRIMARY KEY,
+    sprint_worker_id  INTEGER NOT NULL REFERENCES sprint_workers(id) ON DELETE CASCADE,
+    sprint_week_id    INTEGER NOT NULL REFERENCES sprint_weeks(id)   ON DELETE CASCADE,
+    days              REAL NOT NULL DEFAULT 0,
+    UNIQUE (sprint_worker_id, sprint_week_id)
+);
+
+CREATE TABLE tasks (
+    id               INTEGER PRIMARY KEY,
+    sprint_id        INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
+    title            TEXT NOT NULL,
+    owner_worker_id  INTEGER REFERENCES workers(id) ON DELETE SET NULL,
+    priority         INTEGER NOT NULL CHECK (priority IN (1, 2)),
+    sort_order       INTEGER NOT NULL,
+    created_at       TEXT NOT NULL,
+    updated_at       TEXT NOT NULL
+);
+
+CREATE TABLE task_assignments (
+    id                INTEGER PRIMARY KEY,
+    task_id           INTEGER NOT NULL REFERENCES tasks(id)           ON DELETE CASCADE,
+    sprint_worker_id  INTEGER NOT NULL REFERENCES sprint_workers(id)  ON DELETE CASCADE,
+    days              REAL NOT NULL DEFAULT 0,
+    UNIQUE (task_id, sprint_worker_id)
+);
+
+CREATE TABLE audit_log (
+    id            INTEGER PRIMARY KEY,
+    occurred_at   TEXT NOT NULL,
+    user_id       INTEGER,
+    user_email    TEXT,
+    action        TEXT NOT NULL,
+    entity_type   TEXT NOT NULL,
+    entity_id     INTEGER,
+    before_json   TEXT,
+    after_json    TEXT,
+    ip_address    TEXT,
+    user_agent    TEXT
+);
+
+CREATE INDEX idx_audit_occurred_at ON audit_log(occurred_at DESC);
+CREATE INDEX idx_audit_entity      ON audit_log(entity_type, entity_id);
+CREATE INDEX idx_tasks_sprint      ON tasks(sprint_id, sort_order);
+CREATE INDEX idx_sw_sprint         ON sprint_workers(sprint_id, sort_order);

+ 29 - 7
public/index.php

@@ -2,12 +2,17 @@
 
 declare(strict_types=1);
 
+use App\Auth\OidcClient;
+use App\Auth\SessionGuard;
+use App\Controllers\AuthController;
 use App\Db\Connection;
 use App\Db\Migrator;
 use App\Http\Request;
 use App\Http\Response;
 use App\Http\Router;
 use App\Http\View;
+use App\Repositories\UserRepository;
+use App\Services\AuditLogger;
 
 define('APP_ROOT', dirname(__DIR__));
 
@@ -57,24 +62,41 @@ try {
 }
 
 // ---------------------------------------------------------------------------
-// Routing
+// Shared services
 // ---------------------------------------------------------------------------
 $view   = new View(APP_ROOT . '/views');
+$users  = new UserRepository($pdo);
+$audit  = new AuditLogger($pdo);
+$auth   = new AuthController($pdo, $users, $audit);
+
+// ---------------------------------------------------------------------------
+// Routing
+// ---------------------------------------------------------------------------
 $router = new Router();
 
-$router->get('/', function (Request $req) use ($view, $pdo): Response {
-    $version = (int) $pdo->query('SELECT COALESCE(MAX(version), 0) FROM schema_version')->fetchColumn();
+$router->get('/', function (Request $req) use ($view, $pdo, $users, $appEnv): Response {
+    $currentUser = SessionGuard::currentUser($users);
+    $schemaVersion = (int) $pdo->query(
+        'SELECT COALESCE(MAX(version), 0) FROM schema_version'
+    )->fetchColumn();
+
     return Response::html($view->render('home', [
         'title'         => 'Sprint Planner',
-        'schemaVersion' => $version,
+        'currentUser'   => $currentUser,
+        'schemaVersion' => $schemaVersion,
         'dbPath'        => Connection::path(),
         'appEnv'        => $appEnv,
+        'oidcConfigured' => OidcClient::isConfigured(),
+        'authError'     => isset($req->query['auth_error']),
+        'csrfToken'     => SessionGuard::csrfToken(),
     ]));
 });
 
-$router->get('/healthz', function (): Response {
-    return Response::text('ok');
-});
+$router->get('/healthz', fn() => Response::text('ok'));
+
+$router->get('/auth/login',     $auth->login(...));
+$router->get('/auth/callback',  $auth->callback(...));
+$router->post('/auth/logout',   $auth->logout(...));
 
 // ---------------------------------------------------------------------------
 // Dispatch

+ 60 - 0
src/Auth/OidcClient.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+use Jumbojett\OpenIDConnectClient;
+use RuntimeException;
+
+/**
+ * Thin factory around jumbojett/openid-connect-php configured for
+ * Microsoft Entra ID (v2.0 endpoint) with Authorization Code + PKCE.
+ *
+ * Reads these env vars:
+ *   ENTRA_TENANT_ID
+ *   ENTRA_CLIENT_ID
+ *   ENTRA_CLIENT_SECRET
+ *   APP_BASE_URL
+ */
+final class OidcClient
+{
+    public static function create(): OpenIDConnectClient
+    {
+        $tenant       = self::env('ENTRA_TENANT_ID');
+        $clientId     = self::env('ENTRA_CLIENT_ID');
+        $clientSecret = self::env('ENTRA_CLIENT_SECRET');
+        $baseUrl      = rtrim(self::env('APP_BASE_URL'), '/');
+
+        $issuer = "https://login.microsoftonline.com/{$tenant}/v2.0";
+
+        $oidc = new OpenIDConnectClient($issuer, $clientId, $clientSecret);
+        $oidc->setRedirectURL($baseUrl . '/auth/callback');
+        $oidc->addScope(['openid', 'profile', 'email']);
+        $oidc->setCodeChallengeMethod('S256'); // PKCE
+
+        // Entra's userinfo endpoint doesn't return new info beyond the id_token
+        // for our scopes; rely on verified claims to avoid the extra round trip.
+        return $oidc;
+    }
+
+    public static function isConfigured(): bool
+    {
+        foreach (['ENTRA_TENANT_ID', 'ENTRA_CLIENT_ID', 'ENTRA_CLIENT_SECRET', 'APP_BASE_URL'] as $k) {
+            $v = getenv($k);
+            if (!is_string($v) || $v === '') {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static function env(string $name): string
+    {
+        $v = getenv($name);
+        if (!is_string($v) || $v === '') {
+            throw new RuntimeException("Required env var not set: {$name}");
+        }
+        return $v;
+    }
+}

+ 132 - 0
src/Auth/SessionGuard.php

@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Auth;
+
+use App\Domain\User;
+use App\Http\Request;
+use App\Repositories\UserRepository;
+
+/**
+ * Session lifecycle + CSRF helpers.
+ *
+ * Session data:
+ *   $_SESSION['user_id']    — int id, set after successful login
+ *   $_SESSION['login_at']   — unix timestamp of login
+ *   $_SESSION['csrf_token'] — hex token, lazy-created
+ *
+ * Intentionally stateless utility methods; the caller hydrates the full User
+ * from the repo on demand.
+ */
+final class SessionGuard
+{
+    public const COOKIE_NAME = 'sp_session';
+
+    public static function start(): void
+    {
+        if (session_status() === PHP_SESSION_ACTIVE) {
+            return;
+        }
+
+        $savePath = getenv('SESSION_PATH');
+        if (is_string($savePath) && $savePath !== '') {
+            if (!is_dir($savePath)) {
+                @mkdir($savePath, 0o700, true);
+            }
+            session_save_path($savePath);
+        }
+
+        $baseUrl = (string) (getenv('APP_BASE_URL') ?: '');
+        $secure  = str_starts_with($baseUrl, 'https://');
+
+        ini_set('session.use_strict_mode', '1');
+        ini_set('session.use_only_cookies', '1');
+        ini_set('session.cookie_httponly', '1');
+        ini_set('session.cookie_samesite', 'Lax');
+        ini_set('session.cookie_secure', $secure ? '1' : '0');
+        ini_set('session.gc_maxlifetime', '28800'); // 8h
+
+        session_name(self::COOKIE_NAME);
+        session_set_cookie_params([
+            'lifetime' => 0,
+            'path'     => '/',
+            'httponly' => true,
+            'samesite' => 'Lax',
+            'secure'   => $secure,
+        ]);
+        session_start();
+    }
+
+    public static function login(User $user): void
+    {
+        self::start();
+        // Fresh id on privilege change, but preserve any pre-login state
+        // (the OIDC client stores its nonce/state in the session).
+        session_regenerate_id(true);
+        $_SESSION['user_id']  = $user->id;
+        $_SESSION['login_at'] = time();
+    }
+
+    public static function logout(): void
+    {
+        if (session_status() !== PHP_SESSION_ACTIVE) {
+            self::start();
+        }
+        $_SESSION = [];
+        if (ini_get('session.use_cookies')) {
+            $p = session_get_cookie_params();
+            setcookie(
+                session_name(),
+                '',
+                [
+                    'expires'  => time() - 42000,
+                    'path'     => $p['path'],
+                    'domain'   => $p['domain'],
+                    'secure'   => $p['secure'],
+                    'httponly' => $p['httponly'],
+                    'samesite' => $p['samesite'] ?? 'Lax',
+                ]
+            );
+        }
+        session_destroy();
+    }
+
+    public static function currentUserId(): ?int
+    {
+        self::start();
+        $id = $_SESSION['user_id'] ?? null;
+        return is_int($id) ? $id : null;
+    }
+
+    public static function currentUser(UserRepository $users): ?User
+    {
+        $id = self::currentUserId();
+        return $id === null ? null : $users->find($id);
+    }
+
+    public static function csrfToken(): string
+    {
+        self::start();
+        if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
+            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+        }
+        return (string) $_SESSION['csrf_token'];
+    }
+
+    /** Returns true when the request is GET/HEAD (not guarded) or the token matches. */
+    public static function verifyCsrf(Request $req): bool
+    {
+        if (in_array($req->method, ['GET', 'HEAD', 'OPTIONS'], true)) {
+            return true;
+        }
+        $token = $req->header('x-csrf-token');
+        if ($token === null || $token === '') {
+            $token = isset($req->post['_csrf']) ? (string) $req->post['_csrf'] : '';
+        }
+        if ($token === '') {
+            return false;
+        }
+        return hash_equals(self::csrfToken(), $token);
+    }
+}

+ 212 - 0
src/Controllers/AuthController.php

@@ -0,0 +1,212 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers;
+
+use App\Auth\OidcClient;
+use App\Auth\SessionGuard;
+use App\Http\Request;
+use App\Http\Response;
+use App\Repositories\UserRepository;
+use App\Services\AuditLogger;
+use PDO;
+use Throwable;
+
+final class AuthController
+{
+    public function __construct(
+        private readonly PDO            $pdo,
+        private readonly UserRepository $users,
+        private readonly AuditLogger    $audit,
+    ) {
+    }
+
+    /** GET /auth/login — kick off the OIDC flow. */
+    public function login(Request $req): Response
+    {
+        SessionGuard::start();
+
+        if (!OidcClient::isConfigured()) {
+            return Response::html(
+                $this->configErrorPage(),
+                503
+            );
+        }
+
+        try {
+            $oidc = OidcClient::create();
+            // No 'code' in the query on this route → authenticate() builds the
+            // Entra authorize URL and redirects via header() + exit. The line
+            // below is only reached if the library ever changes that contract.
+            $oidc->authenticate();
+        } catch (Throwable $e) {
+            $this->logFailure($req, 'login_redirect_failed: ' . $e->getMessage());
+            return Response::redirect('/?auth_error=1');
+        }
+
+        return Response::redirect('/');
+    }
+
+    /** GET /auth/callback — exchange code for tokens, upsert user, start session. */
+    public function callback(Request $req): Response
+    {
+        SessionGuard::start();
+
+        // 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']);
+            $this->logFailure($req, 'entra_error: ' . $desc);
+            return Response::redirect('/?auth_error=1');
+        }
+
+        try {
+            $oidc    = OidcClient::create();
+            $oidc->authenticate();
+            $claims = $oidc->getVerifiedClaims();
+        } catch (Throwable $e) {
+            $this->logFailure($req, 'token_validation_failed: ' . $e->getMessage());
+            return Response::redirect('/?auth_error=1');
+        }
+
+        $oid   = (string) ($claims->oid   ?? $claims->sub ?? '');
+        $email = (string) ($claims->email ?? $claims->preferred_username ?? '');
+        $name  = (string) ($claims->name  ?? $email ?? 'user');
+
+        if ($oid === '') {
+            $this->logFailure($req, 'missing_oid_or_sub_claim');
+            return Response::redirect('/?auth_error=1');
+        }
+
+        $this->pdo->beginTransaction();
+        try {
+            $isFirstUser = $this->users->count() === 0;
+            $result      = $this->users->upsertFromOidc($oid, $email, $name, $isFirstUser);
+            $user        = $result['user'];
+            $before      = $result['before']?->toAuditSnapshot();
+
+            $action = $before === null ? 'CREATE' : 'UPDATE';
+            $this->audit->record(
+                action:     $action,
+                entityType: 'user',
+                entityId:   $user->id,
+                before:     $before,
+                after:      $user->toAuditSnapshot(),
+                userId:     $user->id,
+                userEmail:  $user->email,
+                ipAddress:  $req->ip(),
+                userAgent:  $req->userAgent(),
+            );
+
+            if ($isFirstUser) {
+                $this->audit->record(
+                    action:     'BOOTSTRAP_ADMIN',
+                    entityType: 'user',
+                    entityId:   $user->id,
+                    before:     null,
+                    after:      ['is_admin' => 1],
+                    userId:     $user->id,
+                    userEmail:  $user->email,
+                    ipAddress:  $req->ip(),
+                    userAgent:  $req->userAgent(),
+                );
+            }
+
+            $this->audit->record(
+                action:     'LOGIN',
+                entityType: 'user',
+                entityId:   $user->id,
+                before:     null,
+                after:      null,
+                userId:     $user->id,
+                userEmail:  $user->email,
+                ipAddress:  $req->ip(),
+                userAgent:  $req->userAgent(),
+            );
+
+            $this->pdo->commit();
+        } catch (Throwable $e) {
+            $this->pdo->rollBack();
+            $this->logFailure($req, 'user_upsert_failed: ' . $e->getMessage());
+            return Response::redirect('/?auth_error=1');
+        }
+
+        SessionGuard::login($user);
+        return Response::redirect('/');
+    }
+
+    /** POST /auth/logout — end the session; CSRF-protected. */
+    public function logout(Request $req): Response
+    {
+        SessionGuard::start();
+
+        if (!SessionGuard::verifyCsrf($req)) {
+            return Response::text('CSRF token invalid', 403);
+        }
+
+        $user = SessionGuard::currentUser($this->users);
+        if ($user !== null) {
+            $this->pdo->beginTransaction();
+            try {
+                $this->audit->record(
+                    action:     'LOGOUT',
+                    entityType: 'user',
+                    entityId:   $user->id,
+                    before:     null,
+                    after:      null,
+                    userId:     $user->id,
+                    userEmail:  $user->email,
+                    ipAddress:  $req->ip(),
+                    userAgent:  $req->userAgent(),
+                );
+                $this->pdo->commit();
+            } catch (Throwable) {
+                $this->pdo->rollBack();
+                // audit failure shouldn't block logout
+            }
+        }
+
+        SessionGuard::logout();
+        return Response::redirect('/');
+    }
+
+    /** Write a LOGIN_FAILED audit row in its own tx; never throws. */
+    private function logFailure(Request $req, string $reason): void
+    {
+        try {
+            $this->pdo->beginTransaction();
+            $this->audit->record(
+                action:     'LOGIN_FAILED',
+                entityType: 'user',
+                entityId:   null,
+                before:     null,
+                after:      ['reason' => $reason],
+                userId:     null,
+                userEmail:  null,
+                ipAddress:  $req->ip(),
+                userAgent:  $req->userAgent(),
+            );
+            $this->pdo->commit();
+        } catch (Throwable) {
+            if ($this->pdo->inTransaction()) {
+                $this->pdo->rollBack();
+            }
+        }
+    }
+
+    private function configErrorPage(): string
+    {
+        return <<<HTML
+            <!doctype html><meta charset="utf-8">
+            <title>OIDC not configured</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>
+            </div>
+            HTML;
+    }
+}

+ 33 - 0
src/Domain/User.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain;
+
+final class User
+{
+    public function __construct(
+        public readonly int     $id,
+        public readonly string  $entraOid,
+        public readonly string  $email,
+        public readonly string  $displayName,
+        public readonly bool    $isAdmin,
+        public readonly string  $createdAt,
+        public readonly ?string $lastLoginAt,
+    ) {
+    }
+
+    /** Stable row snapshot for audit JSON. */
+    public function toAuditSnapshot(): array
+    {
+        return [
+            'id'            => $this->id,
+            'entra_oid'     => $this->entraOid,
+            'email'         => $this->email,
+            'display_name'  => $this->displayName,
+            'is_admin'      => $this->isAdmin ? 1 : 0,
+            'created_at'    => $this->createdAt,
+            'last_login_at' => $this->lastLoginAt,
+        ];
+    }
+}

+ 101 - 0
src/Repositories/UserRepository.php

@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repositories;
+
+use App\Domain\User;
+use PDO;
+use RuntimeException;
+
+final class UserRepository
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    public function find(int $id): ?User
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
+        $stmt->execute([$id]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    public function findByOid(string $oid): ?User
+    {
+        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE entra_oid = ?');
+        $stmt->execute([$oid]);
+        $row = $stmt->fetch();
+        return is_array($row) ? self::hydrate($row) : null;
+    }
+
+    public function count(): int
+    {
+        return (int) $this->pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
+    }
+
+    /**
+     * Insert-or-update a user keyed by entra_oid. Returns the hydrated row and
+     * the version *before* the call (null for a newly-created user) so the
+     * caller can audit the change.
+     *
+     * @return array{user: User, before: ?User}
+     */
+    public function upsertFromOidc(
+        string $oid,
+        string $email,
+        string $name,
+        bool $promoteToAdmin,
+    ): array {
+        $now      = gmdate('Y-m-d\TH:i:s\Z');
+        $existing = $this->findByOid($oid);
+
+        if ($existing === null) {
+            $stmt = $this->pdo->prepare(
+                'INSERT INTO users (entra_oid, email, display_name, is_admin, created_at, last_login_at)
+                 VALUES (?, ?, ?, ?, ?, ?)'
+            );
+            $stmt->execute([
+                $oid,
+                $email,
+                $name,
+                $promoteToAdmin ? 1 : 0,
+                $now,
+                $now,
+            ]);
+            $id = (int) $this->pdo->lastInsertId();
+            $user = $this->find($id);
+            if ($user === null) {
+                throw new RuntimeException('Inserted user not found');
+            }
+            return ['user' => $user, 'before' => null];
+        }
+
+        $stmt = $this->pdo->prepare(
+            'UPDATE users SET email = ?, display_name = ?, last_login_at = ? WHERE id = ?'
+        );
+        $stmt->execute([$email, $name, $now, $existing->id]);
+
+        $after = $this->find($existing->id) ?? $existing;
+        return ['user' => $after, 'before' => $existing];
+    }
+
+    /**
+     * @param array<string,mixed> $row
+     */
+    private static function hydrate(array $row): User
+    {
+        return new User(
+            id:          (int) $row['id'],
+            entraOid:    (string) $row['entra_oid'],
+            email:       (string) $row['email'],
+            displayName: (string) $row['display_name'],
+            isAdmin:     ((int) $row['is_admin']) === 1,
+            createdAt:   (string) $row['created_at'],
+            lastLoginAt: isset($row['last_login_at']) && $row['last_login_at'] !== null
+                ? (string) $row['last_login_at']
+                : null,
+        );
+    }
+}

+ 118 - 0
src/Services/AuditLogger.php

@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Services;
+
+use JsonException;
+use PDO;
+use RuntimeException;
+
+/**
+ * Cross-cutting audit writer.
+ *
+ * Callers are expected to invoke record() inside the same SQL transaction as
+ * the change itself, so that a rollback also rolls back the audit row.
+ *
+ * No-op rule: for action = 'UPDATE', if $before and $after are both non-null
+ * and semantically equal, no row is written. Other actions always write.
+ * Equality is structural (arrays deep-compared via JSON canonicalisation)
+ * so reordered keys don't count as a change.
+ */
+final class AuditLogger
+{
+    public function __construct(private readonly PDO $pdo)
+    {
+    }
+
+    /**
+     * @param array<string,mixed>|null $before row snapshot before the change
+     * @param array<string,mixed>|null $after  row snapshot after the change
+     */
+    public function record(
+        string $action,
+        string $entityType,
+        ?int $entityId = null,
+        ?array $before = null,
+        ?array $after = null,
+        ?int $userId = null,
+        ?string $userEmail = null,
+        ?string $ipAddress = null,
+        ?string $userAgent = null,
+    ): ?int {
+        if (self::isNoOp($action, $before, $after)) {
+            return null;
+        }
+
+        $stmt = $this->pdo->prepare(
+            'INSERT INTO audit_log
+                 (occurred_at, user_id, user_email, action, entity_type, entity_id,
+                  before_json, after_json, ip_address, user_agent)
+             VALUES (?,?,?,?,?,?,?,?,?,?)'
+        );
+        $stmt->execute([
+            gmdate('Y-m-d\TH:i:s\Z'),
+            $userId,
+            $userEmail,
+            $action,
+            $entityType,
+            $entityId,
+            self::encodeJson($before),
+            self::encodeJson($after),
+            $ipAddress,
+            $userAgent,
+        ]);
+        return (int) $this->pdo->lastInsertId();
+    }
+
+    private static function isNoOp(string $action, ?array $before, ?array $after): bool
+    {
+        if ($action !== 'UPDATE') {
+            return false;
+        }
+        if ($before === null || $after === null) {
+            return false;
+        }
+        return self::canonicalJson($before) === self::canonicalJson($after);
+    }
+
+    private static function encodeJson(?array $value): ?string
+    {
+        if ($value === null) {
+            return null;
+        }
+        try {
+            return json_encode(
+                $value,
+                JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
+            );
+        } catch (JsonException $e) {
+            throw new RuntimeException('Audit payload is not JSON-encodable', 0, $e);
+        }
+    }
+
+    /**
+     * Canonical encoding: sort object keys recursively so two structurally
+     * equal arrays compare equal regardless of key order.
+     */
+    private static function canonicalJson(array $value): string
+    {
+        self::ksortRecursive($value);
+        return self::encodeJson($value) ?? 'null';
+    }
+
+    private static function ksortRecursive(array &$arr): void
+    {
+        // Only associative arrays get re-sorted. Numeric-indexed lists keep
+        // their order — assignment day ordering in a list matters semantically.
+        $isList = array_is_list($arr);
+        if (!$isList) {
+            ksort($arr);
+        }
+        foreach ($arr as &$v) {
+            if (is_array($v)) {
+                self::ksortRecursive($v);
+            }
+        }
+    }
+}

+ 45 - 6
views/home.php

@@ -2,15 +2,51 @@
 /** @var int    $schemaVersion */
 /** @var string $dbPath */
 /** @var string $appEnv */
+/** @var \App\Domain\User|null $currentUser */
+/** @var bool   $oidcConfigured */
+/** @var bool   $authError */
 use function App\Http\e;
 ?>
 <section class="space-y-6">
-    <div>
-        <h1 class="text-2xl font-semibold tracking-tight">Hello 👋</h1>
-        <p class="text-slate-600 mt-1">
-            The skeleton is up. Auth, domain, and UI land in the next phases.
-        </p>
-    </div>
+    <?php if ($authError ?? false): ?>
+        <div class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
+            Sign-in failed. Check the server logs or the audit log for details.
+        </div>
+    <?php endif; ?>
+
+    <?php if ($currentUser === null): ?>
+        <div class="rounded-lg border bg-white p-6">
+            <h1 class="text-2xl font-semibold tracking-tight">Sprint Planner</h1>
+            <p class="text-slate-600 mt-2 max-w-prose">
+                Sign in with your Microsoft account to get started. The first person
+                to sign in becomes the admin automatically.
+            </p>
+            <div class="mt-4">
+                <?php if ($oidcConfigured): ?>
+                    <a href="/auth/login"
+                       class="inline-flex items-center gap-2 rounded-md bg-slate-900 text-white px-4 py-2 text-sm font-medium hover:bg-slate-800">
+                        Sign in with Microsoft
+                    </a>
+                <?php else: ?>
+                    <span class="inline-block rounded-md bg-slate-100 text-slate-600 px-3 py-2 text-sm">
+                        OIDC not configured — set <code>ENTRA_*</code> in <code>.env</code>.
+                    </span>
+                <?php endif; ?>
+            </div>
+        </div>
+    <?php else: ?>
+        <div>
+            <h1 class="text-2xl font-semibold tracking-tight">
+                Welcome, <?= e($currentUser->displayName) ?>.
+            </h1>
+            <p class="text-slate-600 mt-1">
+                <?= $currentUser->isAdmin
+                    ? 'You have admin rights.'
+                    : 'You are signed in.' ?>
+                Sprints and tasks land in the next phases.
+            </p>
+        </div>
+    <?php endif; ?>
 
     <div class="rounded-lg border bg-white p-4">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider">Runtime</h2>
@@ -26,6 +62,9 @@ use function App\Http\e;
 
             <dt class="text-slate-500">Schema version</dt>
             <dd class="font-mono"><?= e($schemaVersion) ?></dd>
+
+            <dt class="text-slate-500">OIDC</dt>
+            <dd class="font-mono"><?= $oidcConfigured ? 'configured' : 'not configured' ?></dd>
         </dl>
     </div>
 

+ 30 - 3
views/layout.php

@@ -1,4 +1,12 @@
-<?php /** @var string $content */ /** @var string $title */ use function App\Http\e; ?>
+<?php
+/** @var string $content */
+/** @var string $title */
+/** @var \App\Domain\User|null $currentUser */
+/** @var string $csrfToken */
+use function App\Http\e;
+$currentUser = $currentUser ?? null;
+$csrfToken   = $csrfToken   ?? '';
+?>
 <!doctype html>
 <html lang="en">
 <head>
@@ -19,11 +27,30 @@
     <header class="border-b bg-white">
         <div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-4">
             <a href="/" class="font-semibold tracking-tight">Sprint Planner</a>
-            <nav class="ml-auto text-sm text-slate-600">
-                <span class="opacity-60">Phase 1 — skeleton</span>
+
+            <nav class="ml-auto flex items-center gap-4 text-sm">
+                <?php if ($currentUser !== null): ?>
+                    <span class="text-slate-600">
+                        <?= e($currentUser->displayName) ?>
+                        <?php if ($currentUser->isAdmin): ?>
+                            <span class="ml-1 inline-block px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider bg-amber-100 text-amber-800 rounded">admin</span>
+                        <?php endif; ?>
+                    </span>
+                    <form method="post" action="/auth/logout" class="inline">
+                        <input type="hidden" name="_csrf" value="<?= e($csrfToken) ?>">
+                        <button type="submit"
+                                class="text-slate-600 hover:text-slate-900 hover:underline">
+                            Sign out
+                        </button>
+                    </form>
+                <?php else: ?>
+                    <a href="/auth/login"
+                       class="text-blue-700 hover:underline">Sign in</a>
+                <?php endif; ?>
             </nav>
         </div>
     </header>
+
     <main class="max-w-7xl mx-auto px-4 py-6">
         <?= $content ?>
     </main>