Release 0.7.3
This commit is contained in:
1 parent
d5b1c58ba2
commit
21c0de7dd9
31 files changed
+1582
-15
No files matched your search
@@ -7,5 +7,6 @@ export const load: LayoutServerLoad = ({ locals, url }) => ({
|
||||
: locals.user,
|
||||
presentationMode: url.pathname.startsWith('/heinzlive/'),
|
||||
guestMode: url.pathname.startsWith('/freigabe/'),
|
||||
oauthMode: url.pathname === '/oauth/authorize',
|
||||
liveControllerMode: url.pathname === '/live'
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
{#if data.presentationMode}
|
||||
{@render children()}
|
||||
{:else if data.guestMode}
|
||||
{:else if data.guestMode || data.oauthMode}
|
||||
<div class="guest-shell">
|
||||
<header class="auth-header">
|
||||
<span class="brand"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { MCP_SCOPE, oauthIssuer } from '#lib/server/oauth.js';
|
||||
|
||||
export const GET: RequestHandler = () => {
|
||||
const issuer = oauthIssuer();
|
||||
return json(
|
||||
{
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
registration_endpoint: `${issuer}/oauth/register`,
|
||||
revocation_endpoint: `${issuer}/oauth/revoke`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
token_endpoint_auth_methods_supported: ['none'],
|
||||
revocation_endpoint_auth_methods_supported: ['none'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
scopes_supported: [MCP_SCOPE],
|
||||
authorization_response_iss_parameter_supported: true
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from './mcp/+server.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { MCP_SCOPE, mcpResource, oauthIssuer } from '#lib/server/oauth.js';
|
||||
|
||||
export const GET: RequestHandler = () =>
|
||||
json(
|
||||
{
|
||||
resource: mcpResource(),
|
||||
authorization_servers: [oauthIssuer()],
|
||||
scopes_supported: [MCP_SCOPE],
|
||||
bearer_methods_supported: ['header']
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'public, max-age=3600' } }
|
||||
);
|
||||
@@ -8,13 +8,26 @@ import {
|
||||
validEmail
|
||||
} from '#lib/server/auth.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
function safeReturnTo(value: string | null): string {
|
||||
if (!value || value.length > 4000) return '/fundus';
|
||||
try {
|
||||
const target = new URL(value, 'http://sendewerk.local');
|
||||
if (target.origin === 'http://sendewerk.local' && target.pathname === '/oauth/authorize') {
|
||||
return `${target.pathname}${target.search}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the normal authenticated landing page.
|
||||
}
|
||||
return '/fundus';
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
if (!hasUsers()) redirect(303, '/setup');
|
||||
if (locals.user) redirect(303, '/fundus');
|
||||
if (locals.user) redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, cookies, getClientAddress }) => {
|
||||
default: async ({ request, cookies, getClientAddress, url }) => {
|
||||
const data = await request.formData();
|
||||
const email = String(data.get('email') || '').trim();
|
||||
const password = String(data.get('password') || '');
|
||||
@@ -31,6 +44,6 @@ export const actions = {
|
||||
const user = await authenticate(email, password);
|
||||
if (!user) return fail(400, { email, error: 'E-Mail oder Passwort ist nicht korrekt.' });
|
||||
createSession(user.id, cookies);
|
||||
redirect(303, '/fundus');
|
||||
redirect(303, safeReturnTo(url.searchParams.get('returnTo')));
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { json, text } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { handleMcpRequest, validJsonRpcRequest } from '#lib/server/mcp.js';
|
||||
import { getAuthConfig } from '#lib/server/config.js';
|
||||
import {
|
||||
authenticateOAuth,
|
||||
MCP_SCOPE,
|
||||
OAuthError,
|
||||
protectedResourceMetadataUrl
|
||||
} from '#lib/server/oauth.js';
|
||||
|
||||
const MODERN_VERSION = '2026-07-28';
|
||||
const supportedProtocolVersions = [MODERN_VERSION, '2025-11-25', '2025-06-18', '2025-03-26'];
|
||||
|
||||
function challenge(errorCode?: string) {
|
||||
const fields = [`resource_metadata="${protectedResourceMetadataUrl()}"`, `scope="${MCP_SCOPE}"`];
|
||||
if (errorCode) fields.push(`error="${errorCode}"`);
|
||||
return `Bearer ${fields.join(', ')}`;
|
||||
}
|
||||
|
||||
function authenticate(request: Request) {
|
||||
try {
|
||||
return authenticateOAuth(request);
|
||||
} catch (cause) {
|
||||
if (cause instanceof OAuthError) {
|
||||
const headers: Record<string, string> = { 'Cache-Control': 'no-store' };
|
||||
if (cause.status === 401) headers['WWW-Authenticate'] = challenge(cause.code);
|
||||
return json(
|
||||
{ error: cause.code, error_description: cause.message },
|
||||
{ status: cause.status, headers }
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get('origin');
|
||||
return !origin || origin === getAuthConfig().origin.origin;
|
||||
}
|
||||
|
||||
function protocolError(id: string | number | null, code: number, message: string, data?: unknown) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: { code, message, ...(data === undefined ? {} : { data }) }
|
||||
};
|
||||
}
|
||||
|
||||
function modernValidation(
|
||||
request: Request,
|
||||
body: {
|
||||
id?: string | number | null;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
): Response | null {
|
||||
const id = typeof body.id === 'string' || typeof body.id === 'number' ? body.id : null;
|
||||
const metadata = body.params?._meta;
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
return json(protocolError(id, -32602, 'Required MCP request metadata is missing.'), {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const meta = metadata as Record<string, unknown>;
|
||||
const bodyVersion = meta['io.modelcontextprotocol/protocolVersion'];
|
||||
if (typeof bodyVersion !== 'string' || !supportedProtocolVersions.includes(bodyVersion)) {
|
||||
return json(
|
||||
protocolError(id, -32022, 'Unsupported protocol version', {
|
||||
supported: supportedProtocolVersions,
|
||||
requested: bodyVersion ?? null
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
request.headers.get('mcp-protocol-version') !== bodyVersion ||
|
||||
request.headers.get('mcp-method') !== body.method ||
|
||||
(body.method === 'tools/call' && request.headers.get('mcp-name') !== body.params?.name)
|
||||
) {
|
||||
return json(protocolError(id, -32020, 'MCP request headers do not match the body.'), {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
const capabilities = meta['io.modelcontextprotocol/clientCapabilities'];
|
||||
if (
|
||||
body.id === null ||
|
||||
!capabilities ||
|
||||
typeof capabilities !== 'object' ||
|
||||
Array.isArray(capabilities)
|
||||
) {
|
||||
return json(protocolError(id, -32602, 'Required MCP request metadata is invalid.'), {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
|
||||
const authenticated = authenticate(request);
|
||||
if (authenticated instanceof Response) return authenticated;
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
|
||||
return text('JSON wird erwartet.', { status: 415 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
||||
}
|
||||
if (!validJsonRpcRequest(body)) {
|
||||
return json({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
|
||||
}
|
||||
const metadata = body.params?._meta;
|
||||
const bodyVersion =
|
||||
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
|
||||
? (metadata as Record<string, unknown>)['io.modelcontextprotocol/protocolVersion']
|
||||
: undefined;
|
||||
const headerVersion = request.headers.get('mcp-protocol-version');
|
||||
const modern = headerVersion === MODERN_VERSION || bodyVersion === MODERN_VERSION;
|
||||
if (modern) {
|
||||
const validationFailure = modernValidation(request, body);
|
||||
if (validationFailure) return validationFailure;
|
||||
} else if (headerVersion && !supportedProtocolVersions.includes(headerVersion)) {
|
||||
return json(
|
||||
protocolError(body.id ?? null, -32022, 'Unsupported protocol version', {
|
||||
supported: supportedProtocolVersions,
|
||||
requested: headerVersion
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (body.id === undefined) return new Response(null, { status: 202 });
|
||||
const response = handleMcpRequest(body, authenticated);
|
||||
const status = modern && 'error' in response && response.error?.code === -32601 ? 404 : 200;
|
||||
return json(response, {
|
||||
status,
|
||||
headers: { 'Cache-Control': 'no-store' }
|
||||
});
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = ({ request }) => {
|
||||
if (!originAllowed(request)) return text('Forbidden', { status: 403 });
|
||||
const authenticated = authenticate(request);
|
||||
if (authenticated instanceof Response) return authenticated;
|
||||
return text('Dieser MCP-Endpunkt bietet keinen serverseitigen Ereignisstrom.', {
|
||||
status: 405,
|
||||
headers: { Allow: 'POST' }
|
||||
});
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = () =>
|
||||
text('Method Not Allowed', { status: 405, headers: { Allow: 'POST' } });
|
||||
@@ -0,0 +1,53 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import {
|
||||
authorizationErrorRedirect,
|
||||
authorizationRedirect,
|
||||
createAuthorizationCode,
|
||||
OAuthError,
|
||||
parseAuthorizationRequest
|
||||
} from '#lib/server/oauth.js';
|
||||
|
||||
function authorization(url: URL) {
|
||||
try {
|
||||
return parseAuthorizationRequest(url);
|
||||
} catch (cause) {
|
||||
if (cause instanceof OAuthError) {
|
||||
const target = authorizationErrorRedirect(url, cause);
|
||||
if (target) redirect(303, target, { external: true });
|
||||
error(cause.status, cause.message);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, url }) => {
|
||||
const request = authorization(url);
|
||||
if (!locals.user) {
|
||||
const returnTo = `${url.pathname}${url.search}`;
|
||||
redirect(303, `/login?returnTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
return {
|
||||
clientName: request.client.name,
|
||||
redirectHost: new URL(request.redirectUri).host,
|
||||
scope: request.scope,
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: ({ request: browserRequest, locals, url }) => {
|
||||
if (!locals.user)
|
||||
redirect(303, `/login?returnTo=${encodeURIComponent(`${url.pathname}${url.search}`)}`);
|
||||
const oauthRequest = authorization(url);
|
||||
return browserRequest.formData().then((data) => {
|
||||
if (data.get('decision') !== 'approve') {
|
||||
redirect(303, authorizationRedirect(oauthRequest, { error: 'access_denied' }), {
|
||||
external: true
|
||||
});
|
||||
}
|
||||
const code = createAuthorizationCode(oauthRequest, locals.user!);
|
||||
redirect(303, authorizationRedirect(oauthRequest, { code }), { external: true });
|
||||
});
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Zugriff erlauben | Sendewerk</title></svelte:head>
|
||||
|
||||
<section class="auth-card oauth-consent">
|
||||
<p class="eyebrow">Externe Verbindung</p>
|
||||
<h1>{data.clientName} verbinden?</h1>
|
||||
<p>
|
||||
Die Anwendung erhält im Namen von <strong>{data.user.displayName}</strong> lesenden Zugriff auf redaktionelle
|
||||
Pieces, Sendungen und Abläufe. Dazu gehören auch private Recherche, Moderationsnotizen, Quellen und
|
||||
die Reservebank.
|
||||
</p>
|
||||
<div class="oauth-permission">
|
||||
<strong>Nur lesen</strong>
|
||||
<span>Keine Inhalte anlegen, ändern, einplanen oder archivieren.</span>
|
||||
</div>
|
||||
<p class="oauth-redirect">
|
||||
<strong>Nicht verifizierter OAuth-Client.</strong> Nach Zustimmung erfolgt der Rücksprung zu
|
||||
<code>{data.redirectHost}</code>.
|
||||
</p>
|
||||
<form class="oauth-actions" method="POST">
|
||||
<button class="secondary-button" type="submit" name="decision" value="deny">Ablehnen</button>
|
||||
<button class="primary-button" type="submit" name="decision" value="approve">
|
||||
Zugriff erlauben
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { OAuthError, registerOAuthClient, reserveOAuthAttempt } from '#lib/server/oauth.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||
try {
|
||||
if (!reserveOAuthAttempt('registration', getClientAddress())) {
|
||||
throw new OAuthError('temporarily_unavailable', 'Zu viele Registrierungsversuche.', 429);
|
||||
}
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) {
|
||||
throw new OAuthError('invalid_client_metadata', 'JSON wird erwartet.');
|
||||
}
|
||||
const client = registerOAuthClient(await request.json());
|
||||
return json(
|
||||
{
|
||||
client_id: client.id,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
client_name: client.name,
|
||||
redirect_uris: client.redirectUris,
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none'
|
||||
},
|
||||
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
} catch (cause) {
|
||||
const problem =
|
||||
cause instanceof OAuthError
|
||||
? cause
|
||||
: new OAuthError('invalid_client_metadata', 'Ungültige Client-Metadaten.');
|
||||
return json(
|
||||
{ error: problem.code, error_description: problem.message },
|
||||
{ status: problem.status, headers: { 'Cache-Control': 'no-store' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { text } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { revokeOAuthToken } from '#lib/server/oauth.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
if (
|
||||
request.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('application/x-www-form-urlencoded')
|
||||
) {
|
||||
const data = await request.formData();
|
||||
revokeOAuthToken(String(data.get('token') ?? ''), String(data.get('client_id') ?? ''));
|
||||
}
|
||||
return text('', { headers: { 'Cache-Control': 'no-store' } });
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import {
|
||||
exchangeAuthorizationCode,
|
||||
oauthTokenDurationSeconds,
|
||||
OAuthError,
|
||||
reserveOAuthAttempt
|
||||
} from '#lib/server/oauth.js';
|
||||
|
||||
function failure(cause: unknown) {
|
||||
const problem =
|
||||
cause instanceof OAuthError
|
||||
? cause
|
||||
: new OAuthError('invalid_request', 'Ungültige Token-Anfrage.');
|
||||
return json(
|
||||
{ error: problem.code, error_description: problem.message },
|
||||
{ status: problem.status, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
|
||||
);
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||
try {
|
||||
if (!reserveOAuthAttempt('token', getClientAddress())) {
|
||||
throw new OAuthError('temporarily_unavailable', 'Zu viele Token-Anfragen.', 429);
|
||||
}
|
||||
if (
|
||||
!request.headers
|
||||
.get('content-type')
|
||||
?.toLowerCase()
|
||||
.startsWith('application/x-www-form-urlencoded')
|
||||
) {
|
||||
throw new OAuthError('invalid_request', 'Formulardaten werden erwartet.');
|
||||
}
|
||||
const data = await request.formData();
|
||||
if (data.get('grant_type') !== 'authorization_code') {
|
||||
throw new OAuthError('unsupported_grant_type', 'Nur Authorization Code wird unterstützt.');
|
||||
}
|
||||
const result = exchangeAuthorizationCode({
|
||||
code: String(data.get('code') ?? ''),
|
||||
clientId: String(data.get('client_id') ?? ''),
|
||||
redirectUri: String(data.get('redirect_uri') ?? ''),
|
||||
codeVerifier: String(data.get('code_verifier') ?? ''),
|
||||
resource: String(data.get('resource') ?? '')
|
||||
});
|
||||
return json(
|
||||
{
|
||||
access_token: result.accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: oauthTokenDurationSeconds(),
|
||||
scope: result.scope
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } }
|
||||
);
|
||||
} catch (cause) {
|
||||
return failure(cause);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user