Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Server-only. Used only when SvelteKit server code calls the CMS API.
CMS_INTERNAL_ORIGIN=http://127.0.0.1:5099

# Member login is disabled until CMS_MEMBER_BRIDGE_SECRET is set privately and matches
# backend SGFDevs__MemberBridge__Secret. Never commit a secret here.
# CMS_MEMBER_COOKIE_NAME defaults to .AspNetCore.Identity.Application.

# Server-only. Set these only when testing fictional local media through the same-origin proxy.
# Do not point ordinary local development at production media.
# MEDIA_UPSTREAM_ORIGIN=http://127.0.0.1:5099
Expand Down
15 changes: 15 additions & 0 deletions openapi/member-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Private member bridge

`sgf-member-v1.openapi.json` is the unedited response from the backend's named `sgf-member-v1` document, exported at `/umbraco/openapi/sgf-member-v1.json` from commit `9ad88406bb8bb604343922d44268d6979556e2bf`.

Raw SHA-256: `7cb034e19fee49f377c9ac4a3a03a986498070fa1aeba52d3f02b3b022ebf227`.

The export used a fresh source archive, private empty SQLite install, Development mode and the process-only `AF.Umbraco.S3.Media.Storage,` assembly exclusion. It did not import content or members. Empty installation does not prove existing-member login.

Run `npm run api:member:generate` to generate the separate server-only types. The public document and client are unchanged.

Set private `CMS_INTERNAL_ORIGIN` to the fixed CMS origin. HTTPS is required outside loopback. Set `CMS_MEMBER_BRIDGE_SECRET` privately to match backend `SGFDevs__MemberBridge__Secret`. Missing configuration disables login, not public pages. No browser receives this header.

`CMS_MEMBER_COOKIE_NAME` defaults to Umbraco 18.2's ASP.NET Identity application cookie, `.AspNetCore.Identity.Application`. Set it only if the CMS's existing member cookie has been customized. The bridge forwards that cookie and its `C1`, `C2`, etc. chunks only, preserving remembered expiry and sliding renewal. Frontend cookies are host-only, HTTPOnly, SameSite=Lax and Path=/, with Secure enabled in production. Configure the trusted reverse proxy's protocol handling correctly for Kit Origin/CSRF checks. Do not trust browser-supplied forwarded headers.

Login defaults to remember-me, matching the legacy persistent login. Account editing is not available yet. Logout is a same-origin POST; GET only displays confirmation. Logout removes frontend member cookies even during a CMS outage; it cannot revoke a copied ticket during that outage.
170 changes: 170 additions & 0 deletions openapi/sgf-member-v1.openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
{
"openapi": "3.1.1",
"info": {
"title": "SGF private member bridge",
"version": "1.0"
},
"servers": [
{
"url": "http://127.0.0.1:35899/"
}
],
"paths": {
"/api/v1/member/login": {
"post": {
"tags": [
"MemberSession"
],
"operationId": "Member_Login",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberLoginRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberLoginRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/MemberLoginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberLoginResult"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberLoginResult"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MemberLoginResult"
}
}
}
}
}
}
},
"/api/v1/member/session": {
"get": {
"tags": [
"MemberSession"
],
"operationId": "Member_Session",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberSessionDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberSessionDto"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MemberSessionDto"
}
}
}
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/api/v1/member/logout": {
"post": {
"tags": [
"MemberSession"
],
"operationId": "Member_Logout",
"responses": {
"204": {
"description": "No Content"
}
}
}
}
},
"components": {
"schemas": {
"MemberLoginRequest": {
"required": [
"username",
"password"
],
"type": "object",
"properties": {
"username": {
"type": [
"null",
"string"
]
},
"password": {
"type": [
"null",
"string"
]
},
"rememberMe": {
"type": "boolean",
"default": true
}
}
},
"MemberLoginResult": {
"required": [
"succeeded"
],
"type": "object",
"properties": {
"succeeded": {
"type": "boolean"
}
}
},
"MemberSessionDto": {
"required": [
"username",
"name"
],
"type": "object",
"properties": {
"username": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
}
},
"tags": [
{
"name": "MemberSession"
}
]
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"preview": "vite preview",
"serve": "node build",
"api:generate": "tsx scripts/generate-sgf-api.ts",
"api:member:generate": "tsx scripts/generate-sgf-member-api.ts",
"api:member:check": "tsx scripts/generate-sgf-member-api.ts --check",
"test:auth": "node --test --import tsx tests/auth/*.test.ts",
"api:check": "tsx scripts/generate-sgf-api.ts --check",
"api:schema:fetch": "tsx scripts/fetch-sgf-api-schema.ts",
"test:api": "svelte-kit sync && tsc --noEmit --project tsconfig.test.json && node --test --import tsx tests/api/*.test.ts",
Expand Down
16 changes: 16 additions & 0 deletions scripts/generate-sgf-member-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import openapiTS, { astToString } from 'openapi-typescript';
import { writeFileAtomically } from './lib/atomic-write';
import { parseOpenApiSnapshotText } from './lib/schema-bytes';

const input = resolve('openapi/sgf-member-v1.openapi.json');
const output = resolve('src/lib/server/auth/generated/memberApiSchema.d.ts');
const document = parseOpenApiSnapshotText(await readFile(input, 'utf8'));
const types = astToString(await openapiTS(document as Parameters<typeof openapiTS>[0]));
const text = '/** Generated from openapi/sgf-member-v1.openapi.json. Run npm run api:member:generate. */\n' + types;
if (process.argv.includes('--check')) {
if (await readFile(output, 'utf8') !== text) throw new Error('Member API types are stale.');
} else {
await writeFileAtomically(output, text);
}
3 changes: 3 additions & 0 deletions src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
declare global {
namespace App {
interface Locals {
member: { username: string; name: string } | null;
}
interface PageData {}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export const variables = defineEnvVars({
description: 'Private origin for the local or deployed SGF CMS API. Required only when server code calls the CMS.',
schema: (value) => (value ? value : undefined)
},
CMS_MEMBER_BRIDGE_SECRET: {
description: 'Server-only member bridge secret matching SGFDevs:MemberBridge:Secret. No default.',
schema: (value) => (value ? value : undefined)
},
CMS_MEMBER_COOKIE_NAME: {
description: 'Server-only member application cookie name. Match the CMS if its default is customized.',
schema: (value) => value || '.AspNetCore.Identity.Application'
},
MEDIA_SOURCE_PUBLIC_ORIGIN: {
description: 'Private validation origin for public SGF media DTO URLs. Defaults to https://media.sgf.dev.',
schema: (value) => (value ? value : undefined)
Expand Down
23 changes: 23 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Handle } from '@sveltejs/kit/hooks';
import { memberClient, memberCookieName } from './lib/server/auth/runtime';
import { memberCookieHeader } from './lib/server/auth/cookies';

export const handle: Handle = async ({ event, resolve }) => {
event.locals.member = null;
const hasCookie = Boolean(memberCookieHeader(event.cookies, memberCookieName));
if (hasCookie) {
try {
const { data } = await memberClient(event).session();
if (data && typeof data.username === 'string' && typeof data.name === 'string') {
event.locals.member = { username: data.username, name: data.name };
}
} catch {
// Public pages remain usable during bridge outages or before configuration.
}
}
const response = await resolve(event);
if (hasCookie || ['/login', '/logout', '/account'].includes(event.url.pathname)) {
response.headers.set('Cache-Control', 'no-store');
}
return response;
};
52 changes: 52 additions & 0 deletions src/lib/server/auth/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import createClient from 'openapi-fetch';
import type { Cookies } from '@sveltejs/kit';
import type { paths, components } from './generated/memberApiSchema';
import { parseCmsInternalOrigin } from '../api/origin';
import { DEFAULT_MEMBER_COOKIE, memberCookieHeader, relayMemberCookies } from './cookies';

export type MemberSession = components['schemas']['MemberSessionDto'];

export function createMemberClient(options: {
origin: string | undefined;
secret: string | undefined;
cookieName?: string;
cookies: Cookies;
secure: boolean;
fetchImpl?: typeof globalThis.fetch;
}) {
const origin = parseCmsInternalOrigin(options.origin);
const url = new URL(origin);
if (url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
throw new Error('Member bridge requires HTTPS outside loopback.');
}
if (!options.secret?.trim()) throw new Error('Member bridge is not configured.');
const base = options.cookieName || DEFAULT_MEMBER_COOKIE;
if (!/^[A-Za-z0-9_.-]+$/.test(base)) throw new Error('Invalid member cookie name.');
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
const client = createClient<paths>({
baseUrl: origin,
fetch: async request => {
const target = new URL(request.url);
if (target.origin !== origin || ![
'/api/v1/member/login', '/api/v1/member/session', '/api/v1/member/logout'
].includes(target.pathname) || target.search || target.hash) throw new Error('Invalid member bridge request.');
const headers = new Headers({ 'X-SGF-Member-Bridge': options.secret! });
const cookie = memberCookieHeader(options.cookies, base);
if (cookie) headers.set('Cookie', cookie);
if (request.body) headers.set('Content-Type', 'application/json');
// Native fetch, not event.fetch: Kit must never auto-forward browser credentials.
const response = await fetchImpl(new Request(target, {
method: request.method, body: request.body ? await request.text() : undefined,
headers, redirect: 'error', credentials: 'omit', cache: 'no-store',
signal: AbortSignal.timeout(8000)
}));
relayMemberCookies(response.headers, options.cookies, base, options.secure);
return response;
}
});
return {
login: (body: components['schemas']['MemberLoginRequest']) => client.POST('/api/v1/member/login', { body }),
session: () => client.GET('/api/v1/member/session'),
logout: () => client.POST('/api/v1/member/logout')
};
}
52 changes: 52 additions & 0 deletions src/lib/server/auth/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Cookies } from '@sveltejs/kit';

export const DEFAULT_MEMBER_COOKIE = '.AspNetCore.Identity.Application';

export function isMemberCookie(name: string, base: string): boolean {
return name === base || (name.startsWith(base + 'C') && /^[1-9][0-9]*$/.test(name.slice(base.length + 1)));
}

export function memberCookieHeader(cookies: Cookies, base: string): string {
return cookies.getAll({ decode: (value) => value })
.filter(({ name, value }) => isMemberCookie(name, base) && /^[A-Za-z0-9_=-]+$/.test(value))
.map(({ name, value }) => `${name}=${value}`).join('; ');
}

export function clearMemberCookies(cookies: Cookies, base: string, secure: boolean): void {
const names = new Set([base, ...cookies.getAll().filter(c => isMemberCookie(c.name, base)).map(c => c.name)]);
for (const name of names) cookies.delete(name, { path: '/', httpOnly: true, secure, sameSite: 'lax' });
}

// ASP.NET's ChunkingCookieManager uses base=chunks-N and baseC1, baseC2, etc.
// Only relay that cookie family. CMS domains/paths must not follow it to the frontend.
export function relayMemberCookies(headers: Headers, cookies: Cookies, base: string, secure: boolean): void {
const received = headers.getSetCookie().map(header => {
const [pair, ...attributes] = header.split(';');
const equals = pair.indexOf('=');
const name = pair.slice(0, equals).trim();
const value = pair.slice(equals + 1).trim();
if (equals < 1 || !isMemberCookie(name, base) || !/^[A-Za-z0-9_=-]*$/.test(value)) return null;
const options: Parameters<Cookies['set']>[2] = {
path: '/', httpOnly: true, secure, sameSite: 'lax', encode: value => value
};
for (const attribute of attributes) {
const [key, ...rest] = attribute.trim().split('=');
const text = rest.join('=');
if (key.toLowerCase() === 'expires') {
const expires = new Date(text);
if (!Number.isNaN(expires.getTime())) options.expires = expires;
}
if (key.toLowerCase() === 'max-age' && /^-?[0-9]+$/.test(text)) options.maxAge = Number(text);
}
return { name, value, options };
}).filter(value => value !== null);
if (received.some(cookie => cookie.name === base)) {
const names = new Set(received.map(c => c.name));
for (const cookie of cookies.getAll()) {
if (isMemberCookie(cookie.name, base) && !names.has(cookie.name)) {
cookies.delete(cookie.name, { path: '/', httpOnly: true, secure, sameSite: 'lax' });
}
}
}
for (const { name, value, options } of received) cookies.set(name, value, options);
}
Loading