Google SSO protection for a directory

We had a textbook that had been created with Quarto1. The instructor wanted to put it online but restrict access to Middlebury people through SSO. The one option I got centrally (paste the individual files into a Google Site) was not optimal.

While our Middlebury accounts are limited in Google, I thought I had enough access to do the OAuth handshake and that usage is free (but you do have to put in a credit card).2

Were I advising me on a sensible path, I’d say set up the folder structure the way you want. You need to have at least one allowed redirect path when you set things up in the Google Cloud interface.

In my case, I was doing it a demo to prove it works. My place for that is experiments.middcreate.net and I have gotten in the habit of making a folder named /extras/ to hold my various examples and tests. That saves me time as jump between servers and local development setups.

This project was put in a directory named /secure/. In that directory were three things.

  1. auth.php – a PHP file where the Google stuff goes
  2. .htaccess – an .htaccess file that controls the attempts to go places
  3. _secure3 – a directory that will hold both the protected content and an .htaccss file that says Deny from all4

Based on that setup, my redirect URL will be https://experiments.middcreate.net/extras/secure/auth.php?callback=1

I can now go to the Google Cloud Console and setup a project. I’ll name it something useful.

I don’t spend a lot of time in Google Cloud Console and I find it totally unintuitive. Each time I use it I stagger around clicking on things. Hopefully, writing this down will help me remember or at least provide someplace to look when I forget next time.

With your project selected, make sure the sidebar menu is expanded, then go to APIs & Services. From there you go to Credentials (again in the sidebar).

You are going to click the +Credentials button at the to and create an OAuth client ID. Your application type is a Web Application.

Then you should see the option to add an authorized redirect URL. That’s where we want https://experiments.middcreate.net/extras/secure/auth.php?callback=1. You can add more than one of these. I don’t know if there’s any real drama involved in adding 10 allowed redirects vs creating 10 OAuth client IDs. Please consult better people for advice.

Copy the ID and Secret stuff down when it comes up. It’s 90% of why we are here.

auth.php

I think most of this is well commented and understandable. For beginners, do put your stuff in quotes. I tried to make that clear with the example ID and secret. That used to confuse me when I started out.

Also note that I’m restricting to just my Middlebury.edu Google account people (ALLOWED_DOMAIN). That’s what I want and it let’s me avoid having to get this approved by Google. I haven’t looked at what it’d take to just allow some Middlebury people. This code is a mix of StackOverflow and some Claude code smoothing. Judge if you’d like, I’ve always hated authentication workflows.

<?php
session_start();

const CLIENT_ID      = 'a_long_chunk_o_stuff_goes_inthese_quotes.apps.googleusercontent.com';//From the google stuff we just did
const CLIENT_SECRET  = 'another_chunk_inthese_quotes';//From the google stuff we just did
const ALLOWED_DOMAIN = 'middlebury.edu';//community email domain restriction (doesn't have to be the same as where the code is hosted)
const CONTENT_DIR = __DIR__ . '/_private/';//where the protected files are stored
const PROTECTED_PATH = '/extras/secure/';//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);
    $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 media reactions.
    $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;
}

.htaccess in /secure

This is the .htaccess file that sits in /extras/secure.

# Google SSO Protection for /extras/secure/
# All requests are authenticated via auth.php
# which verifies users with @middlebury.edu email accounts

RewriteEngine On

# Allow direct access to auth.php (needed for OAuth callback)
RewriteRule ^auth\.php$ - [L]

# Route all other requests through auth.php for SSO authentication
RewriteCond %{REQUEST_URI} ^/extras/secure/(.+)$
RewriteRule ^(.+)$ /extras/secure/auth.php?file=$1 [QSA,L]

.htaccess in /secure/_private

This is the .htaccess file that sits in /extras/secure/_private.

Deny from all

I think that’s it. Today we’re going to talk through how this will work on his individual site and think about what happens with multiple textbooks and if we wanted some textbooks to be public. All clearly possible but it comes down to making it work as easily as possible. I think we’re also going to try to figure out how to do some GitHub actions to make it all update automatically. That’s new territory for me so we’ll see how that goes.


1 Which looks interesting if time allows someday.

2 I ended up having to use a personal card as the fake charge with the secret codes used to authenticate the card weren’t visible for me on my Middlebury card. Modern life is weird.

3 Leading with an underscore tends to mean it’s a protected/internal resource.

4 Feel free to make a shirt six people will find funny.

Leave a Reply