feat(oauth): browser-initiated dynamic client registration (DCR)

Adds an OAuth 2.1 public client registration flow so MCP clients can
self-register via a user-facing consent page instead of requiring manual
setup in Settings.

Server:
- DB migration adds `is_public` and `created_via` columns to oauth_clients
- New GET /api/oauth/register/validate — validates DCR params, returns
  requested scopes; unauthenticated callers get loginRequired flag
- New POST /api/oauth/register — creates a public client, saves consent,
  and redirects with client_id (cookie auth required)
- `authenticateClient` / `refreshTokens` skip secret check for public
  clients (PKCE provides the security guarantee)
- `createOAuthClient` accepts options for isPublic/createdVia; public
  clients store an opaque secret hash instead of a usable secret
- `rotateOAuthClientSecret` blocked on public clients
- `isValidRedirectUri` extracted as a shared helper
- Discovery metadata now advertises registration_endpoint and auth method
  `none`; token/revoke endpoints no longer require client_secret for
  public clients

Client:
- New OAuthRegisterPage (/oauth/register) — loading → optional
  login-required gate → scope selection → done states
- New ScopeGroupPicker component — collapsible groups, indeterminate
  checkboxes, select-all per group or globally
- oauthApi.register.{validate,submit} added to api/client.ts
- apiClient exported so it can be reused outside api/client.ts
- IntegrationsTab tests fixed for new collapsible section structure
- collab_notes fallback changed from undefined to [] in MCP trip tools
This commit is contained in:
jubnl
2026-04-10 05:20:38 +02:00
parent 81a360f9a7
commit 9b1baaf7b8
25 changed files with 739 additions and 235 deletions
+11 -4
View File
@@ -21,6 +21,7 @@ const wsMock = await import('../../../src/api/websocket');
// Import the API client AFTER the mock is set up so it picks up our getSocketId mock
const {
apiClient,
authApi,
tripsApi,
placesApi,
@@ -465,19 +466,25 @@ describe('API client interceptors', () => {
});
it('FE-API-022: authApi.uploadAvatar sends multipart/form-data', async () => {
let contentType = '';
// jsdom's FormData ≠ undici's FormData, so Node.js's Request always
// serialises it as text/plain — Content-Type header checks are unreliable.
// MSW wraps XHR in a Proxy, so XHR prototype spies never fire. axios.create()
// copies prototype methods onto the instance as bound functions, so prototype
// spies don't fire either. Spy on the exported apiClient instance directly.
server.use(
http.post('/api/auth/avatar', ({ request }) => {
contentType = request.headers.get('Content-Type') ?? '';
http.post('/api/auth/avatar', () => {
return HttpResponse.json({ avatar_url: '/uploads/avatar.jpg' });
})
);
const postSpy = vi.spyOn(apiClient, 'post');
const formData = new FormData();
formData.append('avatar', new Blob(['img'], { type: 'image/jpeg' }), 'avatar.jpg');
await authApi.uploadAvatar(formData);
expect(contentType).toMatch(/multipart\/form-data/);
expect(postSpy).toHaveBeenCalledWith('/auth/avatar', expect.any(FormData), expect.anything());
postSpy.mockRestore();
});
it('FE-API-023: authApi.mcpTokens.create posts name to /api/auth/mcp-tokens', async () => {