OAuth Architecture

rforssen.net – Authentication Strategy, Principles & Design

Table of Contents

Authentication Mental Model

Principle

This is how mature OAuth-backed platforms are designed: Google provides identity one time, the platform enforces trust continuously.

Authentication Flow

1️⃣ Popup navigates to GET https://api.rforssen.net/auth/login/google?popup=true&redirect=...
2️⃣ That request redirects the popup to Google
3️⃣ Google authenticates the user
4️⃣ Google redirects the popup to /auth/login/google/callback
Backend now:
– verifies state
– verifies nonce
– exchanges auth code for tokens
– extracts user identity
– creates server-side session cookie (HttpOnly) storing user identity / role
5️⃣ Backend redirects popup to https://www.rforssen.net/oauth-popup-complete.html
6️⃣ That page notifies the main window via BroadcastChannel("auth") with { type: "auth:success" }
7️⃣ Popup closes itself (prevents COOP issues)
8️⃣ Main page re-fetches /auth/me and updates UI
9️⃣ From now on every backend request automatically includes the session cookie (with credentials: "include") —
so the backend always knows who the user is by verifying the cookie.
Note: On initial page load, /auth/me may return 401. This is expected when the user is logged out. Frontend treats 401 as “public/anonymous”.

Current Implementation (Modules & File Layout)

This section documents the current frontend implementation used across rforssen.net apps: a shared auth core, plus app-specific UI renderers.

File Layout

/rforssen_net/
  js/
    auth/
      authClient.js
      initAuth.js
      setupAuthUI.js
  authtest/
    index.html
    js/
      authUI.js         (app-specific UI demo)
  oauth-popup-complete.html
  

Other apps (e.g. /media, /genealogy, admin pages) can have their own ./js/authUI.js while reusing the shared core in /js/auth/.

Backend Endpoints (Auth API)

EndpointPurpose
GET /auth/meReturn current session user (401 if not logged in)
GET /auth/login/googleStart Google OAuth flow (popup-capable)
GET /auth/login/google/callbackOAuth callback (server-side session creation)
POST /auth/logoutClear session cookie

/auth/me Contract (Current)

If user is NOT authenticated (via 401):

{
  "authenticated": false,
  "is_authorized": false,
  "user": null,
  "role": "public"
}

If user IS authenticated:

{
  "authenticated": true,
  "is_authorized": true,
  "user": {
    "email": "...",
    "name": "...",
    "role": "admin | family | public | ..."
  },
  "role": "admin | family | public | ..."
}
Important: frontend role logic improves UX. Backend checks provide real security.

Frontend Modules (Current)

Module Responsibility Scope
/js/auth/authClient.js
  • fetchAuth() → calls /auth/me with credentials: "include"
  • loginWithPopup() → opens popup and waits for BroadcastChannel message
  • logout() → POST /auth/logout
✅ Shared across all apps
/js/auth/initAuth.js Creates an authClient with apiBase, popup redirect, and channel name. Returns { auth, authClient } or { authClient } depending on split. ✅ Shared across all apps
/js/auth/setupAuthUI.js One-call helper used by pages to wire auth: fetch initial auth state → render badge → attach login/logout handlers → optionally refresh. ✅ Shared across all apps
./js/authUI.js App-specific auth badge/menu rendering and DOM wiring. Calls provided callbacks onLogin() / onLogout(). ❌ App-specific (one per app)
Design decision: keep core auth logic centralized, but allow each app to choose its own UX (badge location, dropdown menu, styling, redirects).

Popup Completion Page (COOP-safe)

The popup completion page sends a success message to the opener via BroadcastChannel, then closes itself. This avoids COOP restrictions where the opener cannot close the popup.

<!-- /oauth-popup-complete.html -->
<script>
  try {
    const bc = new BroadcastChannel("auth");
    bc.postMessage({ type: "auth:success" });
    bc.close();
  } catch (e) {}

  setTimeout(() => window.close(), 150);
</script>
  
If you still see “COOP would block window.close()”, ensure the opener does not call popup.close(). Let the popup close itself.

This document defines how authentication works across the rforssen.net platform.
Different apps have different security needs, so the platform deliberately supports two authentication models.

Roland maintains multiple environments:

Authentication Strategies

No single authentication pattern fits all of them. Therefore the platform supports two complementary authentication strategies:

1️⃣ In-App Authentication
2️⃣ Edge Authentication

1️⃣ In-App Authentication
User-Aware UI + Filtered Content

Objective

Use this strategy when the page or app should:

Typical use cases:

Model

User opens page → Page loads → Page calls /auth/me (cookie included)
If authenticated → personalize UI
If not authenticated → show login + limited content

Backend still enforces actual security.

Backend Requirements

Google OAuth + secure session cookies.

EndpointPurpose
GET /auth/login/googleStart login
GET /auth/login/google/callbackComplete login
GET /auth/meCheck who user is
POST /auth/logoutLog out

/auth/me Contract (Current)

If user is NOT authenticated (401):

{
  "authenticated": false,
  "is_authorized": false,
  "user": null,
  "role": "public"
}

If user IS authenticated:

{
  "authenticated": true,
  "is_authorized": true,
  "user": {
    "email": "...",
    "name": "...",
    "role": "admin | family | public | ..."
  },
  "role": "admin | family | public | ..."
}

Frontend Responsibilities

Frontend role logic improves UX.
Backend checks provide real security.

Backend Security Rules

Cookie Security Requirements

SESSION_COOKIE_SECURE   = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"

Redirect Safety

Do NOT allow redirect to arbitrary domains.

Only allow trusted redirect destinations.

Summary

Use In-App Authentication when you want public + authenticated users, personalization, roles, and mixed-audience experiences.

🔁 Authentication Invocation Model (Frontend)

This section explains where authentication is invoked in a frontend application, and which parts are reusable vs app-specific.

Mental Model

Authentication in rforssen.net is event-driven, not global.

There is no background authentication process running continuously. Instead, authentication is invoked explicitly at a small number of well-defined points.

The Three Authentication Entry Points

Every in-app authenticated page invokes authentication in exactly three situations:

1️⃣ Page Load (Initial State)

When the page loads, it probes authentication state to decide:
page load →
  setupAuthUI() →
    fetchAuth() →
      GET /auth/me (credentials: include) →
        renderAuthBadge()
        decide page behavior
2️⃣ After Popup Login (Dynamic State Change)

Login happens in a popup window. The opener is notified via BroadcastChannel. The app then refreshes auth state and re-renders.
popup login →
  oauth-popup-complete.html →
    BroadcastChannel("auth") "auth:success" →
      (opener) fetchAuth() →
        GET /auth/me →
          re-render auth badge / enable features
This guarantees:
3️⃣ Logout (Explicit Session Destruction)

Logout is an imperative action:
logout click →
  POST /auth/logout →
    session cleared →
      GET /auth/me →
        unauthenticated state →
          render login button

What Does NOT Invoke Authentication

The following actions do not trigger authentication checks:

Authentication is always explicit and intentional.

Reusable vs App-Specific Code (Current)

Layer Responsibility Reusable?
Auth Core /js/auth/authClient.js, /js/auth/initAuth.js, /js/auth/setupAuthUI.js ✅ Yes (shared across all apps)
Auth UI ./js/authUI.js (badge/menu rendering and DOM wiring) ❌ No (one per app)
Page Logic Application-specific functionality and data fetching ❌ No (page-specific)

✅ How to Add Auth to a New Page (Minimal Pattern)

Any new page that wants In-App Authentication only needs:

Minimal HTML Pattern

<div id="authBadge"></div>

<script type="module">
  import { setupAuthUI } from "../js/auth/setupAuthUI.js";
  import { renderAuthBadge, wireAuthUI } from "./js/authUI.js";

  const { auth } = await setupAuthUI({ renderAuthBadge, wireAuthUI });

  if (auth.is_authorized) {
    // app-specific load here
  }
</script>

Important Note: Cookies Must Be Sent to the API

If your app fetches protected API endpoints, ensure requests include: credentials: "include" so the session cookie is sent to api.rforssen.net.

Design Principle

Authentication is a gate, not a background service.
Pages decide when to check it.
Backend always enforces it.

This keeps the platform predictable, debuggable, reusable, and difficult to break accidentally.


2️⃣ Edge Authentication
Hard Gate / Full Access Blocker

Objective

Use this when:

Typical use cases:

Model

Request → Traefik → oauth2-proxy → Application

If not authenticated → block
If authenticated → allow access

Implementation Summary

Optional Identity Injection

oauth2-proxy can inject headers:

X-Auth-User
X-Auth-Email

Backend may use them — or ignore them.

Behavior Rules

Summary

Use Edge Authentication when you need strict perimeter security, platform protection, and hard blocking of all unknown users.

Which Strategy To Use?

NeedStrategy
User-facing experienceIn-App Auth
Public + Private mixedIn-App Auth
Roles & permissionsIn-App Auth
Family experiencesIn-App Auth
Admin toolsEdge Auth
phpMyAdminEdge Auth
Internal dashboardsEdge Auth
No UI unless logged inEdge Auth

Platform Philosophy

Platform / Admin / Infrastructure
➡ Edge Authentication
Family Apps / User Experiences
➡ In-App Authentication

They are not competing approaches — they are deliberately chosen tools, each excellent at its job.

Future Enhancements (Optional)