Merge branch 'dev' into feature/naver-support

This commit is contained in:
Marco Sadowski
2026-04-13 10:04:28 +02:00
committed by GitHub
220 changed files with 34926 additions and 3272 deletions
+28 -7
View File
@@ -3,6 +3,7 @@ import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
import * as svc from '../services/adminService';
import { invalidateMcpSessions } from '../mcp';
import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService';
const router = express.Router();
@@ -101,13 +102,16 @@ router.get('/oidc', (_req: Request, res: Response) => {
});
router.put('/oidc', (req: Request, res: Response) => {
svc.updateOidcSettings(req.body);
const result = svc.updateOidcSettings(req.body);
if (result.error) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { oidc_only: !!req.body.oidc_only, issuer_set: !!req.body.issuer },
details: { issuer_set: !!req.body.issuer },
});
res.json({ success: true });
});
@@ -292,6 +296,8 @@ router.put('/addons/:id', (req: Request, res: Response) => {
ip: getClientIp(req),
details: result.auditDetails,
});
// Invalidate all MCP sessions so they re-create with the updated addon tool set
invalidateMcpSessions();
res.json({ addon: result.addon });
});
@@ -307,6 +313,25 @@ router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
res.json({ success: true });
});
// ── OAuth Sessions ─────────────────────────────────────────────────────────
router.get('/oauth-sessions', (_req: Request, res: Response) => {
res.json({ sessions: svc.listOAuthSessions() });
});
router.delete('/oauth-sessions/:id', (req: Request, res: Response) => {
const result = svc.revokeOAuthSession(req.params.id);
if ('error' in result) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oauth_session.revoke',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
// ── JWT Rotation ───────────────────────────────────────────────────────────
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
@@ -314,12 +339,8 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
if (result.error) return res.status(result.status!).json({ error: result.error });
const authReq = req as AuthRequest;
writeAudit({
user_id: authReq.user?.id ?? null,
username: authReq.user?.username ?? 'unknown',
userId: authReq.user.id,
action: 'admin.rotate_jwt_secret',
target_type: 'system',
target_id: null,
details: null,
ip: getClientIp(req),
});
res.json({ success: true });
+2
View File
@@ -18,6 +18,7 @@ import {
updateTime,
setParticipants,
} from '../services/assignmentService';
import { onPlaceCreated } from '../services/journeyService';
import { AuthRequest } from '../types';
const router = express.Router({ mergeParams: true });
@@ -45,6 +46,7 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA
const assignment = createAssignment(dayId, place_id, notes);
res.status(201).json({ assignment });
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
try { onPlaceCreated(Number(tripId), Number(place_id)); } catch {}
});
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
+290
View File
@@ -0,0 +1,290 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import * as svc from '../services/journeyService';
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
import { uploadToImmich } from '../services/memories/immichService';
const router = express.Router();
const uploadsBase = path.join(__dirname, '../../uploads/journey');
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true });
cb(null, uploadsBase);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
cb(null, `${crypto.randomUUID()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 },
});
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ journeys: svc.listJourneys(authReq.user.id) });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { title, subtitle, trip_ids } = req.body || {};
if (!title || typeof title !== 'string' || !title.trim()) {
return res.status(400).json({ error: 'Title is required' });
}
const journey = svc.createJourney(authReq.user.id, {
title: title.trim(),
subtitle,
trip_ids: Array.isArray(trip_ids) ? trip_ids : [],
});
res.status(201).json(journey);
});
router.get('/suggestions', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.getSuggestions(authReq.user.id) });
});
router.get('/available-trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ trips: svc.listUserTrips(authReq.user.id) });
});
// ── Entries (prefix /entries — before /:id) ──────────────────────────────
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result);
});
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ success: true });
});
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
const results: any[] = [];
for (const file of files) {
const relativePath = `journey/${file.filename}`;
const photo = svc.addPhoto(
Number(req.params.entryId),
authReq.user.id,
relativePath,
undefined,
req.body?.caption
);
if (photo) {
// sync to Immich if connected — update the same photo record
try {
const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname);
if (immichId) {
svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id);
photo.provider = 'immich' as any;
photo.asset_id = immichId;
photo.owner_id = authReq.user.id;
}
} catch {}
results.push(photo);
}
}
if (!results.length) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json({ photos: results });
});
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, caption } = req.body || {};
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
res.status(201).json(photo);
});
// Link an existing photo to a (different) entry
router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { photo_id } = req.body || {};
if (!photo_id) return res.status(400).json({ error: 'photo_id required' });
const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(photo_id), authReq.user.id);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.status(201).json(result);
});
router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Photo not found' });
res.json(result);
});
router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// delete local file
if (photo.file_path) {
const fullPath = path.join(__dirname, '../../uploads', photo.file_path);
try { fs.unlinkSync(fullPath); } catch {}
}
// only delete from Immich if the photo was UPLOADED through TREK (has local file)
// photos imported from Immich (no file_path) are just references — don't touch Immich
if (photo.provider === 'immich' && photo.asset_id && photo.file_path) {
try {
const { getImmichCredentials } = await import('../services/memories/immichService');
const creds = getImmichCredentials(authReq.user.id);
if (creds) {
const { safeFetch } = await import('../utils/ssrfGuard');
await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'DELETE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [photo.asset_id] }),
});
}
} catch {}
}
res.json({ success: true });
});
// ── Journeys /:id (parameterized routes AFTER static prefixes) ───────────
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id);
if (!data) return res.status(404).json({ error: 'Journey not found' });
res.json(data);
});
router.patch('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {});
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const relativePath = `journey/${req.file.filename}`;
const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath });
if (!result) return res.status(404).json({ error: 'Journey not found' });
res.json(result);
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) {
return res.status(404).json({ error: 'Journey not found' });
}
res.json({ success: true });
});
// ── Journey trips ────────────────────────────────────────────────────────
router.post('/:id/trips', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { trip_id } = req.body || {};
if (!trip_id) return res.status(400).json({ error: 'trip_id required' });
if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Entries under journey ────────────────────────────────────────────────
router.get('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const entries = svc.listEntries(Number(req.params.id), authReq.user.id);
if (!entries) return res.status(404).json({ error: 'Journey not found' });
res.json({ entries });
});
router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { entry_date } = req.body || {};
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry);
});
// ── Contributors ─────────────────────────────────────────────────────────
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { user_id, role } = req.body || {};
if (!user_id) return res.status(400).json({ error: 'user_id required' });
if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) {
return res.status(403).json({ error: 'Not allowed' });
}
res.status(201).json({ success: true });
});
router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { role } = req.body || {};
if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) {
return res.status(403).json({ error: 'Not allowed' });
}
res.json({ success: true });
});
// ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const link = getJourneyShareLink(Number(req.params.id));
res.json({ link });
});
router.post('/:id/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { share_timeline, share_gallery, share_map } = req.body || {};
const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map });
res.json(result);
});
router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => {
deleteJourneyShareLink(Number(req.params.id));
res.json({ success: true });
});
export default router;
+50
View File
@@ -0,0 +1,50 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import fs from 'node:fs';
const router = express.Router();
router.get('/:token', (req: Request, res: Response) => {
const data = getPublicJourney(req.params.token);
if (!data) return res.status(404).json({ error: 'Not found' });
res.json(data);
});
// Public photo proxy — validates share token instead of auth
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) {
return res.status(404).json({ error: 'Not found' });
}
res.set('Cache-Control', 'public, max-age=86400');
return res.sendFile(resolved);
}
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else {
// Synology or other providers — try dynamic import
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
} catch {
res.status(404).json({ error: 'Provider not supported' });
}
}
});
export default router;
+6 -2
View File
@@ -36,12 +36,13 @@ router.put('/settings', authenticate, async (req: Request, res: Response) => {
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username) {
handleServiceResult(res, fail('URL and username are required', 400));
}
else {
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl));
}
});
@@ -51,10 +52,13 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
});
router.post('/test', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const body = req.body as Record<string, unknown>;
const synology_url = _parseStringBodyField(body.synology_url);
const synology_username = _parseStringBodyField(body.synology_username);
const synology_password = _parseStringBodyField(body.synology_password);
const synology_otp = _parseStringBodyField(body.synology_otp);
const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true';
if (!synology_url || !synology_username || !synology_password) {
const missingFields: string[] = [];
@@ -64,7 +68,7 @@ router.post('/test', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` }));
}
else{
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl));
}
});
+420
View File
@@ -0,0 +1,420 @@
import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import {
validateAuthorizeRequest,
createAuthCode,
consumeAuthCode,
saveConsent,
issueTokens,
refreshTokens,
revokeToken,
verifyPKCE,
authenticateClient,
isValidRedirectUri,
listOAuthClients,
createOAuthClient,
deleteOAuthClient,
rotateOAuthClientSecret,
listOAuthSessions,
revokeSession,
AuthorizeParams,
} from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
// ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts)
// ---------------------------------------------------------------------------
interface RateEntry { count: number; first: number; }
function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) {
const store = new Map<string, RateEntry>();
setInterval(() => {
const now = Date.now();
for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k);
}, windowMs).unref();
return (req: Request, res: Response, next: () => void): void => {
const key = keyFn(req);
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' });
return;
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /.well-known, /oauth/token, /oauth/revoke
// ---------------------------------------------------------------------------
export const oauthPublicRouter = express.Router();
// RFC 8414 discovery document
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
// M2: return 404 (not 403) so feature presence isn't fingerprinted
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
});
});
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
// M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
// Accept both JSON and application/x-www-form-urlencoded
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
}
if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
}
// ---- authorization_code grant ----
if (grant_type === 'authorization_code') {
if (!code || !redirect_uri || !code_verifier) {
return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' });
}
const pending = consumeAuthCode(code);
// H5: collapse all invalid_grant cases to one message; log specifics server-side
if (!pending) {
writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.clientId !== client_id) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
if (pending.redirectUri !== redirect_uri) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
// Verify client secret
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Verify PKCE
if (!verifyPKCE(code_verifier, pending.codeChallenge)) {
writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip });
return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' });
}
const tokens = issueTokens(client_id, pending.userId, pending.scopes);
writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes }, ip });
return res.json(tokens);
}
// ---- refresh_token grant ----
if (grant_type === 'refresh_token') {
if (!refresh_token) {
return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' });
}
const result = refreshTokens(refresh_token, client_id, client_secret, ip);
if (result.error) {
if (result.error === 'invalid_client') {
logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`);
}
return res.status(result.status || 400).json({
error: result.error,
error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired',
});
}
return res.json(result.tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// RFC 7591 Dynamic Client Registration endpoint
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
const ip = getClientIp(req);
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
if (redirectUris.length === 0) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
}
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
const clientName = rawName || 'MCP Client';
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
const isPublic = authMethod === 'none';
// Resolve requested scopes — scope is required; no implicit full-access grant
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
}
const rawScope = body.scope;
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
if (requestedScopes.length === 0) {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
}
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
isPublic,
createdVia: 'dcr',
});
if (result.error) {
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
}
const client = result.client!;
const now = Math.floor(Date.now() / 1000);
return res.status(201).json({
client_id: client.client_id,
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
client_id_issued_at: now,
redirect_uris: client.redirect_uris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: (client.allowed_scopes as string[]).join(' '),
client_name: client.name,
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
});
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
if (!token || !client_id) {
return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
}
if (!authenticateClient(client_id, client_secret)) {
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
revokeToken(token, client_id, undefined, ip);
// RFC 7009 §2.2: always respond 200 even if token was already revoked or not found
return res.status(200).json({});
});
// ---------------------------------------------------------------------------
// API router: /api/oauth/* — authenticated endpoints used by the SPA
// ---------------------------------------------------------------------------
export const oauthApiRouter = express.Router();
// SPA calls this on page load to validate OAuth params before rendering consent UI
oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => {
// M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const params = req.query as Partial<AuthorizeParams>;
const userId = (req as OptionalAuthRequest).user?.id ?? null;
const result = validateAuthorizeRequest(
{
response_type: params.response_type || '',
client_id: params.client_id || '',
redirect_uri: params.redirect_uri || '',
scope: params.scope || '',
state: params.state,
code_challenge: params.code_challenge || '',
code_challenge_method: params.code_challenge_method || '',
},
userId,
);
// H3: when caller is unauthenticated, strip client name / allowed_scopes from the response
// (validateAuthorizeRequest already does this, but be explicit here)
if (userId === null && result.valid) {
return res.json({ valid: result.valid, loginRequired: true });
}
// For unauthenticated error cases return a generic error to prevent oracle enumeration
if (userId === null && !result.valid) {
return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' });
}
return res.json(result);
});
// User submits consent (approve or deny) — requires cookie-only auth (M7)
oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => {
const { user } = req as AuthRequest;
const {
client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, approved,
} = req.body as {
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
approved: boolean;
};
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'MCP is not enabled' });
}
if (!approved) {
// User denied — redirect with error
const url = new URL(redirect_uri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied the authorization request');
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
}
// Re-validate all params (server-side re-check after user action)
const params: AuthorizeParams = {
response_type: 'code',
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method,
};
const validation = validateAuthorizeRequest(params, user.id);
if (!validation.valid) {
return res.status(400).json({ error: validation.error, error_description: validation.error_description });
}
const scopes = validation.scopes!;
// Store consent (union with any existing scopes)
saveConsent(client_id, user.id, scopes, ip);
// Issue auth code
const code = createAuthCode({
clientId: client_id,
userId: user.id,
redirectUri: redirect_uri,
scopes,
codeChallenge: code_challenge,
codeChallengeMethod: 'S256',
});
if (!code) {
return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' });
}
const url = new URL(redirect_uri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return res.json({ redirect: url.toString() });
});
// ---- OAuth client CRUD ----
oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ clients: listOAuthClients(user.id) });
});
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const { name, redirect_uris, allowed_scopes } = req.body as {
name: string;
redirect_uris: string[];
allowed_scopes: string[];
};
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result);
});
oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ client_secret: result.client_secret });
});
oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
// ---- Active OAuth sessions ----
oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
return res.json({ sessions: listOAuthSessions(user.id) });
});
oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest;
const result = revokeSession(user.id, Number(req.params.id), getClientIp(req));
if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.json({ success: true });
});
+9
View File
@@ -15,12 +15,17 @@ import {
frontendUrl,
getAppUrl,
} from '../services/oidcService';
import { resolveAuthToggles } from '../services/authService';
const router = express.Router();
// ---- GET /login ----------------------------------------------------------
router.get('/login', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.status(403).json({ error: 'SSO login is disabled.' });
}
const config = getOidcConfig();
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
@@ -57,6 +62,10 @@ router.get('/login', async (req: Request, res: Response) => {
// ---- GET /callback -------------------------------------------------------
router.get('/callback', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
}
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
if (oidcError) {
+4
View File
@@ -18,6 +18,7 @@ import {
importNaverList,
searchPlaceImage,
} from '../services/placeService';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
@@ -51,6 +52,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
const place = createPlace(tripId, req.body);
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
try { onPlaceCreated(Number(tripId), place.id); } catch {}
});
// Import places from GPX file with full track geometry (must be before /:id)
@@ -176,6 +178,7 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
try { onPlaceUpdated(place.id); } catch {}
});
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
@@ -185,6 +188,7 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo
const { tripId, id } = req.params;
try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete
const deleted = deletePlace(tripId, id);
if (!deleted) {
return res.status(404).json({ error: 'Place not found' });