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
10 changes: 6 additions & 4 deletions openapi/member-bridge.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# 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`.
`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 `e7d72781629edc8bc1070f69b4e33ba476590f11`.

Raw SHA-256: `7cb034e19fee49f377c9ac4a3a03a986498070fa1aeba52d3f02b3b022ebf227`.
Raw SHA-256: `5b22b136d9682da2bd26ff36b1c83ee4004330ab7f168ec300daeca0c2cd537d`.

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.
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 or registration with the real member schema/group.

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.
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 member actions, 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.

Registration posts through the same private bridge and signs in persistently to `/account`. It reuses legacy validation and requires the CMS's existing `Member` type, `firstName`, `lastName`, `username` properties and `SGF Devs` member group. Missing prerequisites return unavailable without creating schema or groups. Forgot/reset remain separate work.

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.
137 changes: 136 additions & 1 deletion openapi/sgf-member-v1.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,80 @@
},
"servers": [
{
"url": "http://127.0.0.1:35899/"
"url": "http://127.0.0.1:53625/"
}
],
"paths": {
"/api/v1/member/register": {
"post": {
"tags": [
"MemberRegistration"
],
"operationId": "Member_Register",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
}
}
},
"503": {
"description": "Service Unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/MemberRegistrationResult"
}
}
}
}
}
}
},
"/api/v1/member/login": {
"post": {
"tags": [
Expand Down Expand Up @@ -145,6 +215,68 @@
}
}
},
"MemberRegistrationRequest": {
"type": "object",
"properties": {
"firstName": {
"type": [
"null",
"string"
]
},
"lastName": {
"type": [
"null",
"string"
]
},
"email": {
"type": [
"null",
"string"
]
},
"username": {
"type": [
"null",
"string"
]
},
"password": {
"type": [
"null",
"string"
]
},
"challengeQuestion": {
"type": [
"null",
"string"
]
}
}
},
"MemberRegistrationResult": {
"required": [
"succeeded",
"errors"
],
"type": "object",
"properties": {
"succeeded": {
"type": "boolean"
},
"errors": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"MemberSessionDto": {
"required": [
"username",
Expand All @@ -163,6 +295,9 @@
}
},
"tags": [
{
"name": "MemberRegistration"
},
{
"name": "MemberSession"
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const handle: Handle = async ({ event, resolve }) => {
}
}
const response = await resolve(event);
if (hasCookie || ['/login', '/logout', '/account'].includes(event.url.pathname)) {
if (hasCookie || ['/login', '/logout', '/account', '/register'].includes(event.url.pathname)) {
response.headers.set('Cache-Control', 'no-store');
}
return response;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/server/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function createMemberClient(options: {
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'
'/api/v1/member/login', '/api/v1/member/session', '/api/v1/member/logout', '/api/v1/member/register'
].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);
Expand All @@ -45,6 +45,7 @@ export function createMemberClient(options: {
}
});
return {
register: (body: components['schemas']['MemberRegistrationRequest']) => client.POST('/api/v1/member/register', { body }),
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')
Expand Down
69 changes: 69 additions & 0 deletions src/lib/server/auth/generated/memberApiSchema.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
/** Generated from openapi/sgf-member-v1.openapi.json. Run npm run api:member:generate. */
export interface paths {
"/api/v1/member/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["Member_Register"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/member/login": {
parameters: {
query?: never;
Expand Down Expand Up @@ -61,6 +77,20 @@ export interface components {
MemberLoginResult: {
succeeded: boolean;
};
MemberRegistrationRequest: {
firstName?: null | string;
lastName?: null | string;
email?: null | string;
username?: null | string;
password?: null | string;
challengeQuestion?: null | string;
};
MemberRegistrationResult: {
succeeded: boolean;
errors: {
[key: string]: string[];
};
};
MemberSessionDto: {
username: string;
name: string;
Expand All @@ -74,6 +104,45 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
Member_Register: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MemberRegistrationRequest"];
"text/json": components["schemas"]["MemberRegistrationRequest"];
"application/*+json": components["schemas"]["MemberRegistrationRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MemberRegistrationResult"];
"text/json": components["schemas"]["MemberRegistrationResult"];
"text/plain": components["schemas"]["MemberRegistrationResult"];
};
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MemberRegistrationResult"];
"text/json": components["schemas"]["MemberRegistrationResult"];
"text/plain": components["schemas"]["MemberRegistrationResult"];
};
};
};
};
Member_Login: {
parameters: {
query?: never;
Expand Down
46 changes: 46 additions & 0 deletions src/routes/register/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { fail, redirect } from '@sveltejs/kit';
import { memberClient } from '../../lib/server/auth/runtime';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = ({ locals }) => {
if (locals.member) redirect(303, '/account');
};

export const actions: Actions = {
default: async event => {
const form = await event.request.formData();
const fields = ['firstName', 'lastName', 'email', 'username', 'challengeQuestion'] as const;
const values = Object.fromEntries(fields.map(field => {
const value = form.get(field);
return [field, typeof value === 'string' ? value.slice(0, 256) : ''];
})) as Record<typeof fields[number], string>;
const errors: Record<string, string[]> = {};
for (const field of fields) {
const value = form.get(field);
if (typeof value !== 'string') errors[field] = ['This field is required.'];
else if (value.length > 256) errors[field] = ['This value is too long.'];
}
const password = form.get('password');
if (typeof password !== 'string' || !password) errors.password = ['Password is required.'];
else if (password.length > 4096) errors.password = ['This value is too long.'];
if (Object.keys(errors).length || typeof password !== 'string') return fail(400, { values, errors });

try {
const { data, error, response } = await memberClient(event).register({ ...values, password });
if (!data?.succeeded) {
const upstream = data?.errors ?? error?.errors;
for (const field of [...fields, 'password', '']) {
const messages = upstream?.[field];
if (Array.isArray(messages) && messages.length) errors[field] = messages.slice(0, 3);
}
if (!Object.keys(errors).length) errors[''] = ['Registration is unavailable right now.'];
return fail(response.status === 503 || response.status === 429 ? 503 : 400, { values, errors });
}
} catch {
errors[''] = ['Registration is unavailable right now.'];
return fail(503, { values, errors });
}
// The bridge has already relayed the persistent member cookie. The next request loads its session.
redirect(303, '/account');
}
};
Loading