Google SSO Protected Directory Revision

This is an update to the code that I posted earlier.

The changes to the serveFile function deal with the relative path stuff I was seeing in Quatro and allow multiple directories and other more complex setups beyond a single index file.

<?php
session_start();


//example setup for the *teaching* directory, make sure you change lines 8 and 11 to match your directory and then adjust the .htaccess file lines 11 and 12
const CLIENT_ID      = 'PLACEHOLDER_INFORMATION_HERE.apps.googleusercontent.com';//get from Google Cloud Console OAuth 2.0 credentials
const CLIENT_SECRET  = 'PLACEHOLDER_INFORMATION_HERE';//get from Google Cloud Console OAuth 2.0 credentials
const REDIRECT_URI   = 'https://PLACEHOLDER_INFORMATION_HERE/teaching/protected/auth.php?callback=1';//matches cloud console authorized redirect URI to your directory
const ALLOWED_DOMAIN = 'middlebury.edu';//community email domain restriction - leave me alone
const CONTENT_DIR = __DIR__ . '/_private/';//where the protected files are stored
const PROTECTED_PATH = '/teaching/protected/';//the path to the protected directory

// --- Logout ---
if (isset($_GET['logout'])) {
    session_destroy();
    header('Location: /');
    exit;
}

// --- OAuth Callback ---
if (isset($_GET['callback']) && isset($_GET['code'])) {
    $token    = fetchToken($_GET['code']);
    $userInfo = fetchUserInfo($token);

    if (!$userInfo || !str_ends_with($userInfo['email'] ?? '', '@' . ALLOWED_DOMAIN)) {
        http_response_code(403);
        die('Access restricted to @' . ALLOWED_DOMAIN . ' accounts. <a href="?logout=1">Try again</a>');
    }

    $_SESSION['authed_email'] = $userInfo['email'];
    $return = $_SESSION['return_to'] ?? 'index.html';
    unset($_SESSION['return_to']);
    header('Location: ' . PROTECTED_PATH . $return);
    exit;
}

// --- Auth Check ---
if (empty($_SESSION['authed_email'])) {
    $_SESSION['return_to'] = $_GET['file'] ?? 'index.html';
    header('Location: ' . buildAuthUrl());
    exit;
}

// --- Serve File ---
$requestedFile = $_GET['file'] ?? 'index.html';
serveFile($requestedFile);

// --- Functions ---

function buildAuthUrl(): string {
    $params = http_build_query([
        'client_id'     => CLIENT_ID,
        'redirect_uri'  => REDIRECT_URI,
        'response_type' => 'code',
        'scope'         => 'openid email',
        'hd'            => ALLOWED_DOMAIN,
        'state'         => session_id(),
    ]);
    return 'https://accounts.google.com/o/oauth2/v2/auth?' . $params;
}

function fetchToken(string $code): ?string {
    $response = httpPost('https://oauth2.googleapis.com/token', [
        'code'          => $code,
        'client_id'     => CLIENT_ID,
        'client_secret' => CLIENT_SECRET,
        'redirect_uri'  => REDIRECT_URI,
        'grant_type'    => 'authorization_code',
    ]);
    return json_decode($response, true)['access_token'] ?? null;
}

function fetchUserInfo(?string $token): ?array {
    if (!$token) return null;
    $ctx    = stream_context_create(['http' => ['header' => "Authorization: Bearer $token"]]);
    $result = @file_get_contents('https://www.googleapis.com/oauth2/v3/userinfo', false, $ctx);
    return $result ? json_decode($result, true) : null;
}

function httpPost(string $url, array $data): string {
    $ctx = stream_context_create(['http' => [
        'method'  => 'POST',
        'header'  => 'Content-Type: application/x-www-form-urlencoded',
        'content' => http_build_query($data),
    ]]);
    return @file_get_contents($url, false, $ctx) ?: '';
}

function serveFile(string $file): void {
    $realBase = realpath(CONTENT_DIR);

    // Check if the path resolves to a directory . . . deals with relative URLs, multiple directories, etc.
    $realPath = realpath(CONTENT_DIR . $file);
    if ($realPath && is_dir($realPath)) {
        if (!str_ends_with($file, '/')) {
            // Redirect to add trailing slash so relative URLs resolve correctly
            header('Location: ' . PROTECTED_PATH . $file . '/');
            exit;
        }
        // Serve index.html from the directory
        $file = rtrim($file, '/') . '/index.html';
    }

    $realFile = realpath(CONTENT_DIR . $file);

    if (!$realFile || !str_starts_with($realFile, $realBase) || !is_file($realFile)) {
        http_response_code(404);
        die('File not found.');
    }
    //this is a basic mapping of common file extensions to MIME types. Expand this list if we end up with weird stuff.
    $mimeTypes = [
        'html'  => 'text/html',
        'js'    => 'application/javascript',
        'css'   => 'text/css',
        'png'   => 'image/png',
        'jpg'   => 'image/jpeg',
        'jpeg'  => 'image/jpeg',
        'gif'   => 'image/gif',
        'svg'   => 'image/svg+xml',
        'pdf'   => 'application/pdf',
        'woff2' => 'font/woff2',
        'woff'  => 'font/woff',
        'json'  => 'application/json',
        // Video formats
        'mp4'   => 'video/mp4',
        'webm'  => 'video/webm',
        'mov'   => 'video/quicktime',
        'mkv'   => 'video/x-matroska',
        'avi'   => 'video/x-msvideo',
        'flv'   => 'video/x-flv',
        // Audio formats
        'mp3'   => 'audio/mpeg',
        'wav'   => 'audio/wav',
        'ogg'   => 'audio/ogg',
        'm4a'   => 'audio/mp4',
        'flac'  => 'audio/flac',
        'aac'   => 'audio/aac',
    ];

    $ext  = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
    $mime = $mimeTypes[$ext] ?? 'application/octet-stream';

    header('Content-Type: ' . $mime);
    header('Content-Length: ' . filesize($realFile));
    readfile($realFile);
    exit;
}

Leave a Reply