diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 4b3240173..6ff25da6f 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -42,7 +42,7 @@ response types, or import `OperationInput<'getWebsiteStats'>` / `OperationOutput | Option | Description | | --------- | ----------------------------------------------------------------------------------------------------------- | | `baseUrl` | API root. Defaults to `https://api.umami.is/v1`. Self-hosted: `https://your-umami/api`. | -| `token` | Bearer token: login token, OAuth access token, or self-hosted API key. | +| `token` | Bearer token: login token or self-hosted API key. | | `apiKey` | Umami Cloud API key (sent as `x-umami-api-key`; also sent as a bearer token when `token` is not provided). | | `headers` | Extra headers for every request. | | `fetch` | Custom `fetch` (tests, other runtimes, in-process dispatch). | diff --git a/packages/api-client/src/generated/operations.ts b/packages/api-client/src/generated/operations.ts index 095b63a2c..26c82615d 100644 --- a/packages/api-client/src/generated/operations.ts +++ b/packages/api-client/src/generated/operations.ts @@ -15,7 +15,6 @@ export interface OperationDefinition { readonly pathParams: readonly string[]; readonly queryParams: readonly string[]; readonly hasBody: boolean; - readonly scope?: string; } export const operations = { @@ -401,7 +400,6 @@ export const operations = { 'maxResults', ], hasBody: false, - scope: 'analytics:read', }, getEventDataArraySeries: { operationId: 'getEventDataArraySeries', @@ -719,7 +717,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getEventDataPropertySeries: { operationId: 'getEventDataPropertySeries', @@ -796,7 +793,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getEventDataValues: { operationId: 'getEventDataValues', @@ -836,7 +832,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getLink: { operationId: 'getLink', @@ -906,7 +901,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: false, - scope: 'websites:read', }, getMyApiKeys: { operationId: 'getMyApiKeys', @@ -1028,7 +1022,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getReport: { operationId: 'getReport', @@ -1531,7 +1524,6 @@ export const operations = { pathParams: ['websiteId'], queryParams: [], hasBody: false, - scope: 'websites:read', }, getWebsiteActive: { operationId: 'getWebsiteActive', @@ -1540,7 +1532,6 @@ export const operations = { pathParams: ['websiteId'], queryParams: [], hasBody: false, - scope: 'analytics:read', }, getWebsiteAnnotation: { operationId: 'getWebsiteAnnotation', @@ -1565,7 +1556,6 @@ export const operations = { pathParams: ['websiteId'], queryParams: [], hasBody: false, - scope: 'analytics:read', }, getWebsiteEvents: { operationId: 'getWebsiteEvents', @@ -1611,7 +1601,6 @@ export const operations = { 'search', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteEventSeries: { operationId: 'getWebsiteEventSeries', @@ -1651,7 +1640,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteEventStats: { operationId: 'getWebsiteEventStats', @@ -1693,7 +1681,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteExpandedMetrics: { operationId: 'getWebsiteExpandedMetrics', @@ -1739,7 +1726,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteMetrics: { operationId: 'getWebsiteMetrics', @@ -1785,7 +1771,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsitePageviews: { operationId: 'getWebsitePageviews', @@ -1827,7 +1812,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteRecorderConfig: { operationId: 'getWebsiteRecorderConfig', @@ -2119,7 +2103,6 @@ export const operations = { pathParams: ['websiteId', 'sessionId'], queryParams: [], hasBody: false, - scope: 'analytics:read', }, getWebsiteSessionActivity: { operationId: 'getWebsiteSessionActivity', @@ -2128,7 +2111,6 @@ export const operations = { pathParams: ['websiteId', 'sessionId'], queryParams: ['startAt', 'endAt', 'distinctId'], hasBody: false, - scope: 'analytics:read', }, getWebsiteSessionProperties: { operationId: 'getWebsiteSessionProperties', @@ -2137,7 +2119,6 @@ export const operations = { pathParams: ['websiteId', 'sessionId'], queryParams: [], hasBody: false, - scope: 'analytics:read', }, getWebsiteSessionReplays: { operationId: 'getWebsiteSessionReplays', @@ -2203,7 +2184,6 @@ export const operations = { 'search', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteSessionStats: { operationId: 'getWebsiteSessionStats', @@ -2240,7 +2220,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteSessionsWeekly: { operationId: 'getWebsiteSessionsWeekly', @@ -2356,7 +2335,6 @@ export const operations = { 'match', ], hasBody: false, - scope: 'analytics:read', }, getWebsiteValues: { operationId: 'getWebsiteValues', @@ -2407,7 +2385,6 @@ export const operations = { 'includeTeams', ], hasBody: false, - scope: 'websites:read', }, login: { operationId: 'login', @@ -2448,7 +2425,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runBreakdownReport: { operationId: 'runBreakdownReport', @@ -2457,7 +2433,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runFunnelReport: { operationId: 'runFunnelReport', @@ -2466,7 +2441,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runGoalReport: { operationId: 'runGoalReport', @@ -2491,7 +2465,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runPerformanceReport: { operationId: 'runPerformanceReport', @@ -2508,7 +2481,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runRevenueReport: { operationId: 'runRevenueReport', @@ -2517,7 +2489,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, runUtmReport: { operationId: 'runUtmReport', @@ -2526,7 +2497,6 @@ export const operations = { pathParams: [], queryParams: [], hasBody: true, - scope: 'analytics:read', }, saveWebsiteReplay: { operationId: 'saveWebsiteReplay', @@ -3174,7 +3144,6 @@ export abstract class GeneratedUmamiClient { * List events with custom properties * Returns a page of events in the selected date range, grouping each event's custom property records together. * `GET /api/websites/{websiteId}/event-data` - * OAuth scope: `analytics:read` */ getEventData( input: OperationInput<'getEventData'>, @@ -3283,7 +3252,6 @@ export abstract class GeneratedUmamiClient { * List event property usage * Returns event names and their custom property names, data types, and record counts for the selected date range and filters. * `GET /api/websites/{websiteId}/event-data/properties` - * OAuth scope: `analytics:read` */ getEventDataProperties( input: OperationInput<'getEventDataProperties'>, @@ -3308,7 +3276,6 @@ export abstract class GeneratedUmamiClient { * Get event property totals * Returns counts of events with custom data, distinct property names, and property records for the selected date range and filters. * `GET /api/websites/{websiteId}/event-data/stats` - * OAuth scope: `analytics:read` */ getEventDataStats( input: OperationInput<'getEventDataStats'>, @@ -3321,7 +3288,6 @@ export abstract class GeneratedUmamiClient { * List event property values * Returns values and their occurrence counts for a custom event property, optionally restricted by event name and data type. * `GET /api/websites/{websiteId}/event-data/values` - * OAuth scope: `analytics:read` */ getEventDataValues( input: OperationInput<'getEventDataValues'>, @@ -3382,7 +3348,6 @@ export abstract class GeneratedUmamiClient { * Get my authentication details * Returns the current authentication context, including the authenticated user or share credentials. * `GET /api/me` - * OAuth scope: `websites:read` */ getMe( input?: OperationInput<'getMe'>, @@ -3479,7 +3444,6 @@ export abstract class GeneratedUmamiClient { * Get real-time website activity * Returns recent website activity and visitor data for the real-time view, applying the supplied filters. * `GET /api/realtime/{websiteId}` - * OAuth scope: `analytics:read` */ getRealtime( input: OperationInput<'getRealtime'>, @@ -3804,7 +3768,6 @@ export abstract class GeneratedUmamiClient { * Get a website * Returns the specified website's details and configuration. * `GET /api/websites/{websiteId}` - * OAuth scope: `websites:read` */ getWebsite( input: OperationInput<'getWebsite'>, @@ -3817,7 +3780,6 @@ export abstract class GeneratedUmamiClient { * Get active website visitors * Returns the number of visitors active on the website in the last few minutes. * `GET /api/websites/{websiteId}/active` - * OAuth scope: `analytics:read` */ getWebsiteActive( input: OperationInput<'getWebsiteActive'>, @@ -3854,7 +3816,6 @@ export abstract class GeneratedUmamiClient { * Get the website's available date range * Returns the earliest and latest recorded event dates for the website. * `GET /api/websites/{websiteId}/daterange` - * OAuth scope: `analytics:read` */ getWebsiteDateRange( input: OperationInput<'getWebsiteDateRange'>, @@ -3867,7 +3828,6 @@ export abstract class GeneratedUmamiClient { * List tracked events * Returns a page of pageviews and custom events in the date range, newest first. Supports filtering by event name and searching event details. * `GET /api/websites/{websiteId}/events` - * OAuth scope: `analytics:read` */ getWebsiteEvents( input: OperationInput<'getWebsiteEvents'>, @@ -3880,7 +3840,6 @@ export abstract class GeneratedUmamiClient { * Get custom event counts over time * Returns counts grouped by event name and time interval, optionally limited to the most frequent event names. * `GET /api/websites/{websiteId}/events/series` - * OAuth scope: `analytics:read` */ getWebsiteEventSeries( input: OperationInput<'getWebsiteEventSeries'>, @@ -3893,7 +3852,6 @@ export abstract class GeneratedUmamiClient { * Get website event statistics * Returns website event totals for the selected date range and filters, including totals for the comparison period. * `GET /api/websites/{websiteId}/events/stats` - * OAuth scope: `analytics:read` */ getWebsiteEventStats( input: OperationInput<'getWebsiteEventStats'>, @@ -3906,7 +3864,6 @@ export abstract class GeneratedUmamiClient { * Get detailed website metrics * Returns additional analytics for the selected page, event, visitor, or channel dimension, using the supplied date range and filters. * `GET /api/websites/{websiteId}/metrics/expanded` - * OAuth scope: `analytics:read` */ getWebsiteExpandedMetrics( input: OperationInput<'getWebsiteExpandedMetrics'>, @@ -3919,7 +3876,6 @@ export abstract class GeneratedUmamiClient { * Get ranked website metrics * Returns the most frequent values for a dimension such as pages, referrers, countries, browsers, campaigns, or events. Counts pageviews or events for activity dimensions and unique visitors for visitor dimensions. * `GET /api/websites/{websiteId}/metrics` - * OAuth scope: `analytics:read` */ getWebsiteMetrics( input: OperationInput<'getWebsiteMetrics'>, @@ -3932,7 +3888,6 @@ export abstract class GeneratedUmamiClient { * Get pageviews and sessions over time * Returns pageviews and sessions grouped by the requested time interval and timezone, including a comparison period when requested. * `GET /api/websites/{websiteId}/pageviews` - * OAuth scope: `analytics:read` */ getWebsitePageviews( input: OperationInput<'getWebsitePageviews'>, @@ -4101,7 +4056,6 @@ export abstract class GeneratedUmamiClient { * Get a visitor session * Returns details for a website session, including linked visitor identities and whether session deletion is available to the caller. * `GET /api/websites/{websiteId}/sessions/{sessionId}` - * OAuth scope: `analytics:read` */ getWebsiteSession( input: OperationInput<'getWebsiteSession'>, @@ -4114,7 +4068,6 @@ export abstract class GeneratedUmamiClient { * Get a visitor's session activity * Returns activity for the specified session and sessions linked by visitor identity, using the requested date range and optional distinct ID. * `GET /api/websites/{websiteId}/sessions/{sessionId}/activity` - * OAuth scope: `analytics:read` */ getWebsiteSessionActivity( input: OperationInput<'getWebsiteSessionActivity'>, @@ -4127,7 +4080,6 @@ export abstract class GeneratedUmamiClient { * Get a session's custom properties * Returns custom property records, data types, and values for the specified website session. * `GET /api/websites/{websiteId}/sessions/{sessionId}/properties` - * OAuth scope: `analytics:read` */ getWebsiteSessionProperties( input: OperationInput<'getWebsiteSessionProperties'>, @@ -4152,7 +4104,6 @@ export abstract class GeneratedUmamiClient { * List visitor sessions * Returns a page of visitor sessions in the date range, newest first. Search matches distinct ID, city, browser, operating system, or device. * `GET /api/websites/{websiteId}/sessions` - * OAuth scope: `analytics:read` */ getWebsiteSessions( input: OperationInput<'getWebsiteSessions'>, @@ -4165,7 +4116,6 @@ export abstract class GeneratedUmamiClient { * Get visitor session statistics * Returns aggregate session metrics for the website over the selected date range and filters. * `GET /api/websites/{websiteId}/sessions/stats` - * OAuth scope: `analytics:read` */ getWebsiteSessionStats( input: OperationInput<'getWebsiteSessionStats'>, @@ -4202,7 +4152,6 @@ export abstract class GeneratedUmamiClient { * Get website summary statistics * Returns pageviews, unique visitors, visits, bounces, and total time on site for the selected date range and comparison period. * `GET /api/websites/{websiteId}/stats` - * OAuth scope: `analytics:read` */ getWebsiteStats( input: OperationInput<'getWebsiteStats'>, @@ -4251,7 +4200,6 @@ export abstract class GeneratedUmamiClient { * List websites * Returns a paginated list of the current user's websites, optionally including websites accessible through team membership. * `GET /api/websites` - * OAuth scope: `websites:read` */ listWebsites( input?: OperationInput<'listWebsites'>, @@ -4312,7 +4260,6 @@ export abstract class GeneratedUmamiClient { * Run an attribution report * Calculates how traffic sources contribute to conversions using the supplied attribution settings, date range, and filters. * `POST /api/reports/attribution` - * OAuth scope: `analytics:read` */ runAttributionReport( input: OperationInput<'runAttributionReport'>, @@ -4325,7 +4272,6 @@ export abstract class GeneratedUmamiClient { * Run a breakdown report * Groups website activity by the selected dimensions for the requested date range and filters. * `POST /api/reports/breakdown` - * OAuth scope: `analytics:read` */ runBreakdownReport( input: OperationInput<'runBreakdownReport'>, @@ -4338,7 +4284,6 @@ export abstract class GeneratedUmamiClient { * Run a funnel report * Calculates visitor progression through a sequence of pages or events using the supplied funnel steps and filters. * `POST /api/reports/funnel` - * OAuth scope: `analytics:read` */ runFunnelReport( input: OperationInput<'runFunnelReport'>, @@ -4375,7 +4320,6 @@ export abstract class GeneratedUmamiClient { * Run a visitor journey report * Returns paths through pages or events using the supplied journey settings and website filters. * `POST /api/reports/journey` - * OAuth scope: `analytics:read` */ runJourneyReport( input: OperationInput<'runJourneyReport'>, @@ -4400,7 +4344,6 @@ export abstract class GeneratedUmamiClient { * Run a retention report * Calculates how groups of visitors return over time using the supplied retention settings, date range, and filters. * `POST /api/reports/retention` - * OAuth scope: `analytics:read` */ runRetentionReport( input: OperationInput<'runRetentionReport'>, @@ -4413,7 +4356,6 @@ export abstract class GeneratedUmamiClient { * Run a revenue report * Returns revenue trends, totals with a comparison period, and breakdowns by country, region, referrer, and channel. * `POST /api/reports/revenue` - * OAuth scope: `analytics:read` */ runRevenueReport( input: OperationInput<'runRevenueReport'>, @@ -4426,7 +4368,6 @@ export abstract class GeneratedUmamiClient { * Run a campaign report * Returns traffic breakdowns for UTM source, medium, campaign, term, and content using the supplied date range and filters. * `POST /api/reports/utm` - * OAuth scope: `analytics:read` */ runUtmReport( input: OperationInput<'runUtmReport'>, diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 52387e1b6..61ed1616f 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -750,90 +750,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/oauth/authorize': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get OAuth authorization request details - * @description Validates an OAuth authorization request and returns the client and requested permissions for the consent screen. Requires an interactive user session. - */ - get: operations['describeOAuthAuthorization']; - put?: never; - /** - * Approve or deny OAuth access - * @description Records the user's consent decision and returns a redirect URL. Approval includes an authorization code, the request state, and the issuer. - */ - post: operations['decideOAuthAuthorization']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/oauth/register': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Register an OAuth client - * @description Registers a public OAuth client for clients that do not support Client ID Metadata Documents. Clients that support them should use an HTTPS metadata URL as their client ID. - */ - post: operations['oauthRegisterClient']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/oauth/revoke': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Revoke an OAuth refresh token - * @description Revokes the supplied refresh token. Unknown tokens are accepted without revealing whether they existed. - */ - post: operations['oauthRevoke']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/oauth/token': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Get or refresh an OAuth access token - * @description Exchanges an authorization code and PKCE verifier, or a refresh token, for an access token. Accepts form-encoded or JSON requests and rotates refresh tokens on use. - */ - post: operations['oauthToken']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/pixels': { parameters: { query?: never; @@ -2959,149 +2875,6 @@ export interface components { y: number; }; MetricRows: components['schemas']['MetricRow'][]; - OAuthAuthorizationDecision: { - /** @description OAuth client identifier. */ - client_id: string; - /** @description PKCE challenge derived from the code verifier. */ - code_challenge?: string; - /** @description Method used to derive the PKCE challenge; use S256. */ - code_challenge_method?: string; - /** @enum {string} */ - decision: 'approve' | 'deny'; - /** @description Registered redirect URI to receive the authorization response. */ - redirect_uri?: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource?: string; - /** @description OAuth authorization response type; use code. */ - response_type?: string; - /** @description Space-separated OAuth permissions. */ - scope?: string; - /** @description Client-provided value returned unchanged to correlate the authorization response. */ - state?: string; - }; - OAuthAuthorizationDetails: { - client: { - /** @description Unique identifier of the resource. */ - id: string; - logoUri?: string; - /** @description Display name of the resource. */ - name: string; - /** @enum {string} */ - source: 'metadata-document' | 'registered'; - uri?: string; - }; - redirectUri: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource: string; - scopes: { - /** @description Description of the resource. */ - description: string; - /** @description Space-separated OAuth permissions. */ - scope: string; - }[]; - }; - OAuthAuthorizationRedirect: { - redirectUrl: string; - }; - OAuthClientRegistrationRequest: { - /** @enum {string} */ - application_type?: 'web' | 'native'; - /** @description Human-readable name of the OAuth client. */ - client_name: string; - /** @description URL of the OAuth client homepage. */ - client_uri?: string; - /** @description OAuth grant types supported by the client. */ - grant_types?: string[]; - /** @description URL of the OAuth client logo. */ - logo_uri?: string; - /** @description Redirect URIs registered for the OAuth client. */ - redirect_uris: string[]; - /** @description Authorization response types supported by the client. */ - response_types?: string[]; - /** @description Space-separated OAuth permissions. */ - scope?: string; - software_id?: string; - software_version?: string; - /** - * @description Client authentication method used at the token endpoint. - * @constant - */ - token_endpoint_auth_method?: 'none'; - }; - OAuthClientRegistrationResponse: { - /** @enum {string} */ - application_type?: 'web' | 'native'; - /** @description OAuth client identifier. */ - client_id: string; - client_id_issued_at: number; - /** @description Human-readable name of the OAuth client. */ - client_name: string; - /** @description URL of the OAuth client homepage. */ - client_uri?: string; - /** @description OAuth grant types supported by the client. */ - grant_types: string[]; - /** @description URL of the OAuth client logo. */ - logo_uri?: string; - /** @description Redirect URIs registered for the OAuth client. */ - redirect_uris: string[]; - /** @description Authorization response types supported by the client. */ - response_types: string[]; - /** @description Space-separated OAuth permissions. */ - scope?: string; - software_id?: string; - software_version?: string; - /** - * @description Client authentication method used at the token endpoint. - * @constant - */ - token_endpoint_auth_method: 'none'; - }; - OAuthErrorResponse: { - /** @description Error details returned when the operation fails. */ - error: string; - /** @description Human-readable explanation of the OAuth error. */ - error_description?: string; - }; - OAuthRevocationRequest: { - token: string; - /** @description Hint indicating whether the revoked token is an access or refresh token. */ - token_type_hint?: string; - }; - OAuthTokenRequest: { - /** @description OAuth client identifier. */ - client_id?: string; - code?: string; - /** @description Original PKCE verifier used to obtain the authorization code. */ - code_verifier?: string; - /** - * @description OAuth grant used to obtain or refresh an access token. - * @enum {string} - */ - grant_type: 'authorization_code' | 'refresh_token'; - /** @description Registered redirect URI to receive the authorization response. */ - redirect_uri?: string; - /** @description OAuth refresh token used to obtain a new access token. */ - refresh_token?: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource?: string; - /** @description Space-separated OAuth permissions. */ - scope?: string; - }; - OAuthTokenResponse: { - /** @description OAuth access token used to authorize API requests. */ - access_token: string; - /** @description Access token lifetime in seconds. */ - expires_in: number; - /** @description OAuth refresh token used to obtain a new access token. */ - refresh_token: string; - /** @description Space-separated OAuth permissions. */ - scope: string; - /** - * @description Authorization scheme for the access token. - * @constant - */ - token_type: 'Bearer'; - }; /** @description Successful operation response. */ Ok: { /** @@ -7392,12 +7165,7 @@ export interface operations { /** @description Display name of the resource. */ name: string; }; - authType?: 'session' | 'share' | 'api-key' | 'oauth'; - oauth?: { - clientId: string; - scopes: string[]; - tokenId?: string; - }; + authType?: 'session' | 'share' | 'api-key'; shareToken?: { /** @description ID of the board. */ boardId?: string; @@ -8041,240 +7809,6 @@ export interface operations { }; }; }; - describeOAuthAuthorization: { - parameters: { - query: { - /** @description OAuth client identifier. */ - client_id: string; - /** @description PKCE challenge derived from the code verifier. */ - code_challenge?: string; - /** @description Method used to derive the PKCE challenge; use S256. */ - code_challenge_method?: string; - /** @description Registered redirect URI to receive the authorization response. */ - redirect_uri?: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource?: string; - /** @description OAuth authorization response type; use code. */ - response_type?: string; - /** @description Space-separated OAuth permissions. */ - scope?: string; - /** @description Client-provided value returned unchanged to correlate the authorization response. */ - state?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Authorization request details. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthAuthorizationDetails']; - }; - }; - /** @description Invalid authorization request. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthErrorResponse']; - }; - }; - /** @description Unauthorized. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "error": { - * "code": "unauthorized", - * "message": "Unauthorized.", - * "status": 401 - * } - * } - */ - 'application/json': components['schemas']['ApiError']; - }; - }; - }; - }; - decideOAuthAuthorization: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['OAuthAuthorizationDecision']; - }; - }; - responses: { - /** @description Redirect URL for the browser. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthAuthorizationRedirect']; - }; - }; - /** @description Bad request. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "error": { - * "code": "bad-request", - * "message": "Bad request.", - * "status": 400 - * } - * } - */ - 'application/json': components['schemas']['ApiError']; - }; - }; - /** @description Unauthorized. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - /** - * @example { - * "error": { - * "code": "unauthorized", - * "message": "Unauthorized.", - * "status": 401 - * } - * } - */ - 'application/json': components['schemas']['ApiError']; - }; - }; - }; - }; - oauthRegisterClient: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['OAuthClientRegistrationRequest']; - }; - }; - responses: { - /** @description Registered client. */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthClientRegistrationResponse']; - }; - }; - /** @description Invalid client metadata. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthErrorResponse']; - }; - }; - }; - }; - oauthRevoke: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['OAuthRevocationRequest']; - 'application/x-www-form-urlencoded': components['schemas']['OAuthRevocationRequest']; - }; - }; - responses: { - /** @description Token revoked (or already invalid). */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': Record; - }; - }; - /** @description Malformed request. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthErrorResponse']; - }; - }; - }; - }; - oauthToken: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['OAuthTokenRequest']; - 'application/x-www-form-urlencoded': components['schemas']['OAuthTokenRequest']; - }; - }; - responses: { - /** @description Access and refresh tokens. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthTokenResponse']; - }; - }; - /** @description Invalid grant or request. */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthErrorResponse']; - }; - }; - /** @description Invalid client. */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['OAuthErrorResponse']; - }; - }; - }; - }; getPixels: { parameters: { query?: { @@ -13104,8 +12638,6 @@ export interface operations { createdBy: number; /** @description Tracked links associated with the user. */ links: number; - oauthAuthorizationCodes: number; - oauthRefreshTokens: number; /** @description Tracking pixels associated with the user. */ pixels: number; /** @description Saved reports associated with the user. */ @@ -13278,68 +12810,6 @@ export interface operations { /** @description ID of the associated user. */ userId: string; }[]; - oauthAuthorizationCodes: { - clientId: string; - codeChallenge: string; - codeChallengeMethod: string; - codeHash: string; - /** - * Format: date-time - * @description Date and time the record was created. - */ - createdAt: string; - /** - * Format: date-time - * @description Date and time the credential expires. - */ - expiresAt: string; - /** @description Unique identifier of the resource. */ - id: string; - redirectUri: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource: string; - /** @description Space-separated OAuth permissions. */ - scope: string; - /** - * Format: date-time - * @description Date and time the code was used. - */ - usedAt: string; - /** @description ID of the associated user. */ - userId: string; - }[]; - oauthRefreshTokens: { - clientId: string; - /** - * Format: date-time - * @description Date and time the record was created. - */ - createdAt: string; - /** - * Format: date-time - * @description Date and time the credential expires. - */ - expiresAt: string; - /** @description Unique identifier of the resource. */ - id: string; - /** - * Format: date-time - * @description Date and time the credential was last used. - */ - lastUsedAt: string; - /** @description Resource URI identifying the API targeted by the OAuth request. */ - resource: string; - /** - * Format: date-time - * @description Date and time the credential was revoked. - */ - revokedAt: string; - /** @description Space-separated OAuth permissions. */ - scope: string; - tokenHash: string; - /** @description ID of the associated user. */ - userId: string; - }[]; /** @description Password used to authenticate the account. */ password: string; /** @description Tracking pixels associated with the user. */ diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index a11ce80b1..4606e181e 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -7,7 +7,7 @@ export interface UmamiClientOptions { */ baseUrl?: string; /** - * Bearer token: a login token from `POST /api/auth/login`, an OAuth access token, or a + * Bearer token: a login token from `POST /api/auth/login`, or a * self-hosted API key (`umami_…`). */ token?: string; diff --git a/packages/mcp/README.md b/packages/mcp/README.md index f053170dd..a89ffec51 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -37,28 +37,30 @@ Authorization: Bearer api_ Clients that support custom headers may use `x-umami-api-key` instead. If both headers are provided, they must contain the same key. Use a client that supports API-key or bearer-header -configuration; Cloud OAuth discovery is not implemented by this endpoint. +configuration. Cloud MCP has the same subscription requirements and website/team permissions as the Cloud API. All tools call the Cloud API gateway, which validates the key and routes requests to your region. -## Remote: self-hosted with OAuth +## Remote: self-hosted -Add the MCP endpoint to your client: +Generate an API key under **Settings → API keys** in your Umami instance, then configure your +MCP client with the Streamable HTTP endpoint: ```text https://your-umami.example.com/mcp ``` -The client discovers `/.well-known/oauth-protected-resource/mcp`, sends you to Umami to sign in -and approve access (`websites:read`, `analytics:read`), and receives an OAuth token. No API key -is pasted anywhere. Clients register via Client ID Metadata Documents; dynamic client registration -is available for compatibility (`OAUTH_DISABLE_DCR=1` turns it off). -Registration is limited to 20 attempts per hour per IP when Redis is configured. Without Redis, -the limit is shared by all clients in each server process and resets when that process restarts. -Use Redis for a shared limit across multiple processes or serverless instances. +Set the authorization header using your key: -Self-hosted instances can alternatively pass an API key (`umami_…`) as the bearer token. +```text +Authorization: Bearer umami_ +``` + +Use a client that supports bearer tokens or custom authorization headers. The endpoint accepts +self-hosted API keys; browser login tokens are not supported. Tools are read-only and respect +the key owner's existing user/team permissions. Revoke the key in Settings to disconnect access. +Set `MCP_DISABLED=1` to disable the endpoint. ## Local / stdio diff --git a/packages/mcp/src/lib/errors.ts b/packages/mcp/src/lib/errors.ts index 2528bba79..385b2e872 100644 --- a/packages/mcp/src/lib/errors.ts +++ b/packages/mcp/src/lib/errors.ts @@ -97,7 +97,7 @@ export function describeError(error: unknown, context: ErrorContext = {}): Descr return { code: 'access_denied', status: 403, - message: `You do not have permission to access ${label}${error.code === 'insufficient_scope' ? ' with the granted OAuth scopes' : ''}.`, + message: `You do not have permission to access ${label}.`, }; case 404: return { diff --git a/packages/mcp/src/lib/tool.ts b/packages/mcp/src/lib/tool.ts index 5d1414ab1..566223b23 100644 --- a/packages/mcp/src/lib/tool.ts +++ b/packages/mcp/src/lib/tool.ts @@ -7,7 +7,7 @@ import type { McpLogger } from './logger'; export interface ToolContext { client: UmamiClient; logger: McpLogger; - /** Identity attached to the connection (HTTP/OAuth mode). Used for logs only. */ + /** Identity attached to the connection (HTTP mode). Used for logs only. */ identity?: { userId?: string; clientId?: string; requestId?: string }; } diff --git a/prisma/migrations/28_remove_oauth/migration.sql b/prisma/migrations/28_remove_oauth/migration.sql new file mode 100644 index 000000000..cdb012a0f --- /dev/null +++ b/prisma/migrations/28_remove_oauth/migration.sql @@ -0,0 +1,4 @@ +-- Remove the retired OAuth server's data. API keys are stored separately. +DROP TABLE "oauth_authorization_code"; +DROP TABLE "oauth_refresh_token"; +DROP TABLE "oauth_client"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd646aaea..a57873235 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,62 +33,10 @@ model User { twoFactorOtpUseds TwoFactorOtpUsed[] twoFactorRateLimit TwoFactorRateLimit? apiKeys ApiKey[] - oauthAuthorizationCodes OauthAuthorizationCode[] - oauthRefreshTokens OauthRefreshToken[] @@map("user") } -model OauthClient { - id String @id() @map("oauth_client_id") @db.Uuid - name String @db.VarChar(255) - redirectUris Json @map("redirect_uris") - metadata Json? - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - - @@map("oauth_client") -} - -model OauthAuthorizationCode { - id String @id() @map("oauth_code_id") @db.Uuid - codeHash String @unique @map("code_hash") @db.VarChar(128) - userId String @map("user_id") @db.Uuid - clientId String @map("client_id") @db.VarChar(2048) - redirectUri String @map("redirect_uri") @db.VarChar(2048) - scope String @db.VarChar(500) - resource String? @db.VarChar(2048) - codeChallenge String @map("code_challenge") @db.VarChar(128) - codeChallengeMethod String @map("code_challenge_method") @db.VarChar(10) - expiresAt DateTime @map("expires_at") @db.Timestamptz(6) - usedAt DateTime? @map("used_at") @db.Timestamptz(6) - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) - @@index([expiresAt]) - @@map("oauth_authorization_code") -} - -model OauthRefreshToken { - id String @id() @map("oauth_refresh_token_id") @db.Uuid - tokenHash String @unique @map("token_hash") @db.VarChar(128) - userId String @map("user_id") @db.Uuid - clientId String @map("client_id") @db.VarChar(2048) - scope String @db.VarChar(500) - resource String? @db.VarChar(2048) - expiresAt DateTime @map("expires_at") @db.Timestamptz(6) - revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6) - lastUsedAt DateTime? @map("last_used_at") @db.Timestamptz(6) - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) - @@index([clientId]) - @@map("oauth_refresh_token") -} - model ApiKey { id String @id() @map("api_key_id") @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/public/intl/messages/en-US.json b/public/intl/messages/en-US.json index 87dd2d1bf..292e153db 100644 --- a/public/intl/messages/en-US.json +++ b/public/intl/messages/en-US.json @@ -521,15 +521,6 @@ "two-factor-locked-message": "Too many failed attempts. Try again at {time}.", "two-factor-active-description": "Your account is protected with two-factor authentication. Each login requires a code from your authenticator app.", "api-key-created": "Copy your API key now. You won't be able to see it again.", - "no-api-keys": "You have not created any API keys.", - "oauth-login-prompt": "Sign in to continue.", - "oauth-consent": "{name} wants to access your Umami account.", - "oauth-permissions": "This will allow the application to:", - "oauth-scope-websites-read": "See the websites you have access to", - "oauth-scope-analytics-read": "Read analytics data for those websites", - "oauth-redirect-notice": "After you approve, you will be sent to {url}.", - "oauth-signed-in-as": "Signed in as {username}", - "oauth-invalid-request": "This authorization request is invalid.", - "oauth-redirecting": "Redirecting…" + "no-api-keys": "You have not created any API keys." } } diff --git a/scripts/generate-api-client.ts b/scripts/generate-api-client.ts index 693fbdc19..e6e5374de 100644 --- a/scripts/generate-api-client.ts +++ b/scripts/generate-api-client.ts @@ -42,7 +42,6 @@ interface OpenApiOperation { parameters?: OpenApiParameter[]; requestBody?: { required?: boolean }; 'x-umami-audience'?: string; - 'x-umami-oauth-scope'?: string; } interface OpenApiDocument { @@ -62,7 +61,6 @@ interface OperationDefinition { requiredQueryParams: string[]; hasBody: boolean; bodyRequired: boolean; - scope?: string; } function collectOperations(document: OpenApiDocument): OperationDefinition[] { @@ -127,7 +125,6 @@ function collectOperations(document: OpenApiDocument): OperationDefinition[] { requiredQueryParams, hasBody: !!operation.requestBody, bodyRequired: operation.requestBody?.required !== false && !!operation.requestBody, - scope: operation['x-umami-oauth-scope'], }); } } @@ -159,7 +156,6 @@ function renderOperations(operations: OperationDefinition[], version: string) { `pathParams: [${operation.pathParams.map(name => `'${name}'`).join(', ')}]`, `queryParams: [${operation.queryParams.map(name => `'${name}'`).join(', ')}]`, `hasBody: ${operation.hasBody}`, - ...(operation.scope ? [`scope: '${operation.scope}'`] : []), ]; return ` ${operation.operationId}: { ${fields.join(', ')} },`; @@ -178,7 +174,6 @@ function renderOperations(operations: OperationDefinition[], version: string) { operation.description, '', `\`${operation.method.toUpperCase()} ${operation.path}\``, - operation.scope ? `OAuth scope: \`${operation.scope}\`` : undefined, ]); const id = operation.operationId; @@ -203,7 +198,6 @@ export interface OperationDefinition { readonly pathParams: readonly string[]; readonly queryParams: readonly string[]; readonly hasBody: boolean; - readonly scope?: string; } export const operations = { diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts deleted file mode 100644 index 2b1b0ff56..000000000 --- a/src/app/.well-known/oauth-authorization-server/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { - buildAuthorizationServerMetadata, - corsPreflight, - metadataResponse, -} from '@/lib/oauth/metadata'; -import { notFound } from '@/lib/response'; - -export const dynamic = 'force-dynamic'; - -export function OPTIONS() { - return corsPreflight(); -} - -export async function GET(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - return metadataResponse(buildAuthorizationServerMetadata(request.headers)); -} diff --git a/src/app/.well-known/oauth-protected-resource/mcp/route.ts b/src/app/.well-known/oauth-protected-resource/mcp/route.ts deleted file mode 100644 index 6a22e14ac..000000000 --- a/src/app/.well-known/oauth-protected-resource/mcp/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { - buildProtectedResourceMetadata, - corsPreflight, - metadataResponse, -} from '@/lib/oauth/metadata'; -import { notFound } from '@/lib/response'; - -export const dynamic = 'force-dynamic'; - -export function OPTIONS() { - return corsPreflight(); -} - -/** Path-scoped protected resource metadata for the MCP endpoint (RFC 9728 §3.1). */ -export async function GET(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - return metadataResponse(buildProtectedResourceMetadata(request.headers)); -} diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts deleted file mode 100644 index a8b6bbb36..000000000 --- a/src/app/.well-known/oauth-protected-resource/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { - buildProtectedResourceMetadata, - corsPreflight, - metadataResponse, -} from '@/lib/oauth/metadata'; -import { notFound } from '@/lib/response'; - -export const dynamic = 'force-dynamic'; - -export function OPTIONS() { - return corsPreflight(); -} - -export async function GET(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - return metadataResponse(buildProtectedResourceMetadata(request.headers)); -} diff --git a/src/app/api/me/contract.generated.ts b/src/app/api/me/contract.generated.ts index ef734a934..6649fb802 100644 --- a/src/app/api/me/contract.generated.ts +++ b/src/app/api/me/contract.generated.ts @@ -10,9 +10,10 @@ const operation1 = defineOperation({ auth: 'bearer', operation: { operationId: 'getMe', - summary: 'Get me', + summary: 'Get my authentication details', + description: + 'Returns the current authentication context, including the authenticated user or share credentials.', tags: ['Account'], - 'x-umami-oauth-scope': 'websites:read', responses: { '200': { description: 'The operation completed successfully.', @@ -26,30 +27,38 @@ const operation1 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, username: { type: 'string', + description: 'Username of the account.', }, role: { type: 'string', + description: 'Permission role assigned to the user.', }, isAdmin: { type: 'boolean', + description: 'Whether the user has administrator privileges.', }, }, required: ['id', 'username', 'role', 'isAdmin'], + description: 'User associated with the resource.', }, apiKey: { type: 'object', properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, name: { type: 'string', + description: 'Display name of the resource.', }, }, required: ['id', 'name'], + description: 'API key authentication details.', }, authType: { anyOf: [ @@ -62,37 +71,18 @@ const operation1 = defineOperation({ { const: 'api-key', }, - { - const: 'oauth', - }, ], }, - oauth: { - type: 'object', - properties: { - clientId: { - type: 'string', - }, - scopes: { - type: 'array', - items: { - type: 'string', - }, - }, - tokenId: { - type: 'string', - }, - }, - required: ['clientId', 'scopes'], - }, shareToken: { type: 'object', properties: { shareType: { type: 'number', + description: 'Type of resource made available by the share.', }, websiteId: { type: 'string', + description: 'ID of the website.', }, websiteIds: { type: 'array', @@ -102,9 +92,11 @@ const operation1 = defineOperation({ }, boardId: { type: 'string', + description: 'ID of the board.', }, pixelId: { type: 'string', + description: 'ID of the tracking pixel.', }, pixelIds: { type: 'array', @@ -114,6 +106,7 @@ const operation1 = defineOperation({ }, linkId: { type: 'string', + description: 'ID of the tracked link.', }, linkIds: { type: 'array', @@ -154,6 +147,7 @@ const operation1 = defineOperation({ }, ], }, + description: 'Configuration parameters for the resource.', }, }, }, diff --git a/src/app/api/oauth/authorize/contract.ts b/src/app/api/oauth/authorize/contract.ts deleted file mode 100644 index 6ef3b148b..000000000 --- a/src/app/api/oauth/authorize/contract.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { defineOperation } from '@/openapi/operation'; -import { badRequestResponse, jsonResponse, unauthorizedResponse } from '@/openapi/schemas'; -import { - authorizationDecisionSchema, - authorizationDetailsSchema, - authorizationRedirectSchema, - authorizationRequestSchema, - oauthErrorSchema, -} from '../schema'; - -const describeAuthorizationOperation = defineOperation({ - method: 'get', - path: '/api/oauth/authorize', - audience: 'internal', - auth: 'bearer', - operation: { - operationId: 'describeOAuthAuthorization', - summary: 'Describe a pending OAuth authorization request', - description: - 'Validates an OAuth 2.1 authorization request and returns the client and scopes for the consent screen. Requires an interactive user session.', - tags: ['OAuth'], - requestParams: { query: authorizationRequestSchema }, - responses: { - '200': jsonResponse(authorizationDetailsSchema, 'Authorization request details.'), - '400': jsonResponse(oauthErrorSchema, 'Invalid authorization request.'), - '401': unauthorizedResponse, - }, - }, -}); - -const decideAuthorizationOperation = defineOperation({ - method: 'post', - path: '/api/oauth/authorize', - audience: 'internal', - auth: 'bearer', - operation: { - operationId: 'decideOAuthAuthorization', - summary: 'Approve or deny an OAuth authorization request', - description: - 'Records the user decision. On approval an authorization code is issued and the redirect URL (with code, state and iss) is returned.', - tags: ['OAuth'], - requestBody: { - required: true, - content: { 'application/json': { schema: authorizationDecisionSchema } }, - }, - responses: { - '200': jsonResponse(authorizationRedirectSchema, 'Redirect URL for the browser.'), - '400': badRequestResponse, - '401': unauthorizedResponse, - }, - }, -}); - -export const operations = [describeAuthorizationOperation, decideAuthorizationOperation] as const; diff --git a/src/app/api/oauth/authorize/route.ts b/src/app/api/oauth/authorize/route.ts deleted file mode 100644 index 8cea852ea..000000000 --- a/src/app/api/oauth/authorize/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - buildErrorRedirect, - buildSuccessRedirect, - issueAuthorizationCode, - validateAuthorizationRequest, -} from '@/lib/oauth/authorize'; -import { getIssuer, isOAuthEnabled } from '@/lib/oauth/config'; -import { OAuthError, oauthErrorResponse } from '@/lib/oauth/errors'; -import { OAUTH_SCOPE_DESCRIPTIONS } from '@/lib/oauth/scopes'; -import { parseRequest } from '@/lib/request'; -import { json, notFound, unauthorized } from '@/lib/response'; -import { authorizationDecisionSchema, authorizationRequestSchema } from '../schema'; - -function requireSession(auth: { user?: { id: string }; authType?: string } | null) { - // Only an interactive user session may grant consent: never an API key or another OAuth token. - return !!auth?.user?.id && (auth.authType === undefined || auth.authType === 'session'); -} - -/** - * Describes a pending authorization request for the consent screen. Validation errors are - * returned as JSON (the consent UI renders them) — no redirect happens until the user decides. - */ -export async function GET(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - const { auth, query, error } = await parseRequest(request, authorizationRequestSchema); - - if (error) { - return error(); - } - - if (!requireSession(auth)) { - return unauthorized(); - } - - try { - const validated = await validateAuthorizationRequest(query, getIssuer(request.headers)); - - return json({ - client: { - id: validated.client.clientId, - name: validated.client.clientName, - uri: validated.client.clientUri, - logoUri: validated.client.logoUri, - source: validated.client.source, - }, - scopes: validated.scopes.map(scope => ({ - scope, - description: OAUTH_SCOPE_DESCRIPTIONS[scope], - })), - redirectUri: validated.redirectUri, - resource: validated.resource, - }); - } catch (err) { - return oauthErrorResponse(err); - } -} - -/** Records the user's decision and returns the redirect URL the browser should navigate to. */ -export async function POST(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - const { auth, body, error } = await parseRequest(request, authorizationDecisionSchema); - - if (error) { - return error(); - } - - if (!requireSession(auth)) { - return unauthorized(); - } - - const issuer = getIssuer(request.headers); - const { decision, ...params } = body; - - try { - const validated = await validateAuthorizationRequest(params, issuer); - - if (decision !== 'approve') { - return json({ - redirectUrl: buildErrorRedirect( - validated.redirectUri, - new OAuthError('access_denied', 'The user denied the request.'), - validated.state, - issuer, - ), - }); - } - - const code = await issueAuthorizationCode(validated, auth.user.id); - - return json({ redirectUrl: buildSuccessRedirect(validated, code, issuer) }); - } catch (err) { - return oauthErrorResponse(err); - } -} diff --git a/src/app/api/oauth/register/contract.ts b/src/app/api/oauth/register/contract.ts deleted file mode 100644 index b947e448e..000000000 --- a/src/app/api/oauth/register/contract.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineOperation } from '@/openapi/operation'; -import { jsonResponse } from '@/openapi/schemas'; -import { - clientRegistrationRequestSchema, - clientRegistrationResponseSchema, - oauthErrorSchema, -} from '../schema'; - -const registerOperation = defineOperation({ - method: 'post', - path: '/api/oauth/register', - audience: 'internal', - auth: 'none', - operation: { - operationId: 'oauthRegisterClient', - summary: 'Register an OAuth client (dynamic client registration)', - description: - 'RFC 7591 compatibility endpoint for MCP clients without Client ID Metadata Document support. Registers a public client. Prefer Client ID Metadata Documents (https URL client_id).', - tags: ['OAuth'], - requestBody: { - required: true, - content: { 'application/json': { schema: clientRegistrationRequestSchema } }, - }, - responses: { - '201': jsonResponse(clientRegistrationResponseSchema, 'Registered client.'), - '400': jsonResponse(oauthErrorSchema, 'Invalid client metadata.'), - }, - }, -}); - -export const operations = [registerOperation] as const; diff --git a/src/app/api/oauth/register/route.ts b/src/app/api/oauth/register/route.ts deleted file mode 100644 index 449b9b825..000000000 --- a/src/app/api/oauth/register/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { uuid } from '@/lib/crypto'; -import { isDynamicRegistrationEnabled } from '@/lib/oauth/config'; -import { OAuthError, oauthErrorResponse, oauthJson } from '@/lib/oauth/errors'; -import { corsPreflight } from '@/lib/oauth/metadata'; -import { isAcceptableRedirectUri } from '@/lib/oauth/redirect'; -import { parseRequest } from '@/lib/request'; -import { notFound } from '@/lib/response'; -import { createOauthClient } from '@/queries/prisma/oauth'; -import { clientRegistrationRequestSchema } from '../schema'; - -export function OPTIONS() { - return corsPreflight(); -} - -/** - * Dynamic Client Registration (RFC 7591) compatibility endpoint for MCP clients that do not yet - * support Client ID Metadata Documents. Public clients only; disable with OAUTH_DISABLE_DCR=1. - */ -export async function POST(request: Request) { - if (!isDynamicRegistrationEnabled()) { - return notFound(); - } - - const { body, error } = await parseRequest(request, clientRegistrationRequestSchema, { - skipAuth: true, - }); - - if (error) { - return oauthJson( - { error: 'invalid_client_metadata', error_description: 'Invalid registration request.' }, - { status: 400 }, - ); - } - - try { - if (!body.redirect_uris.every(isAcceptableRedirectUri)) { - throw new OAuthError( - 'invalid_redirect_uri', - 'redirect_uris must be absolute https, loopback http or custom-scheme URLs without fragments.', - ); - } - - const grantTypes = body.grant_types ?? ['authorization_code', 'refresh_token']; - const responseTypes = body.response_types ?? ['code']; - - if (grantTypes.some(type => !['authorization_code', 'refresh_token'].includes(type))) { - throw new OAuthError('invalid_client_metadata', 'Unsupported grant_types.'); - } - - if (responseTypes.some(type => type !== 'code')) { - throw new OAuthError('invalid_client_metadata', 'Unsupported response_types.'); - } - - const id = uuid(); - const metadata = { - client_uri: body.client_uri, - logo_uri: body.logo_uri, - application_type: body.application_type, - software_id: body.software_id, - software_version: body.software_version, - scope: body.scope, - }; - - const client = await createOauthClient({ - id, - name: body.client_name, - redirectUris: body.redirect_uris, - metadata, - }); - - return oauthJson( - { - client_id: client.id, - client_id_issued_at: Math.floor((client.createdAt ?? new Date()).getTime() / 1000), - client_name: client.name, - redirect_uris: body.redirect_uris, - token_endpoint_auth_method: 'none', - grant_types: grantTypes, - response_types: responseTypes, - ...(body.client_uri ? { client_uri: body.client_uri } : {}), - ...(body.logo_uri ? { logo_uri: body.logo_uri } : {}), - ...(body.application_type ? { application_type: body.application_type } : {}), - ...(body.scope ? { scope: body.scope } : {}), - }, - { status: 201 }, - ); - } catch (err) { - return oauthErrorResponse(err); - } -} diff --git a/src/app/api/oauth/revoke/contract.ts b/src/app/api/oauth/revoke/contract.ts deleted file mode 100644 index c259ead0c..000000000 --- a/src/app/api/oauth/revoke/contract.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; -import { defineOperation } from '@/openapi/operation'; -import { jsonResponse } from '@/openapi/schemas'; -import { oauthErrorSchema, revocationRequestSchema } from '../schema'; - -const revokeOperation = defineOperation({ - method: 'post', - path: '/api/oauth/revoke', - audience: 'internal', - auth: 'none', - operation: { - operationId: 'oauthRevoke', - summary: 'Revoke an OAuth refresh token', - description: - 'RFC 7009 revocation endpoint. Revokes the given refresh token; unknown tokens are accepted silently.', - tags: ['OAuth'], - requestBody: { - required: true, - content: { - 'application/x-www-form-urlencoded': { schema: revocationRequestSchema }, - 'application/json': { schema: revocationRequestSchema }, - }, - }, - responses: { - '200': jsonResponse(z.object({}), 'Token revoked (or already invalid).'), - '400': jsonResponse(oauthErrorSchema, 'Malformed request.'), - }, - }, -}); - -export const operations = [revokeOperation] as const; diff --git a/src/app/api/oauth/revoke/route.ts b/src/app/api/oauth/revoke/route.ts deleted file mode 100644 index e1c2c5abb..000000000 --- a/src/app/api/oauth/revoke/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { oauthErrorResponse, oauthJson } from '@/lib/oauth/errors'; -import { readOAuthBody } from '@/lib/oauth/form'; -import { corsPreflight } from '@/lib/oauth/metadata'; -import { handleRevocationRequest } from '@/lib/oauth/token'; -import { notFound } from '@/lib/response'; - -export function OPTIONS() { - return corsPreflight(); -} - -/** RFC 7009 token revocation. Always returns 200 for well-formed requests. */ -export async function POST(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - try { - const params = await readOAuthBody(request); - - if (typeof params.token !== 'string' || !params.token) { - return oauthJson( - { error: 'invalid_request', error_description: 'token is required.' }, - { status: 400 }, - ); - } - - await handleRevocationRequest(params); - - return oauthJson({}); - } catch (error) { - return oauthErrorResponse(error); - } -} diff --git a/src/app/api/oauth/schema.ts b/src/app/api/oauth/schema.ts deleted file mode 100644 index aedfc8dd7..000000000 --- a/src/app/api/oauth/schema.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { z } from 'zod'; - -export const authorizationRequestSchema = z - .object({ - response_type: z.string().optional(), - client_id: z.string().max(2048), - redirect_uri: z.string().max(2048).optional(), - scope: z.string().max(500).optional(), - state: z.string().max(1024).optional(), - code_challenge: z.string().max(128).optional(), - code_challenge_method: z.string().max(10).optional(), - resource: z.string().max(2048).optional(), - }) - .meta({ id: 'OAuthAuthorizationRequest' }); - -export const authorizationDecisionSchema = authorizationRequestSchema - .extend({ - decision: z.enum(['approve', 'deny']), - }) - .meta({ id: 'OAuthAuthorizationDecision' }); - -export const authorizationDetailsSchema = z - .object({ - client: z.object({ - id: z.string(), - name: z.string(), - uri: z.string().optional(), - logoUri: z.string().optional(), - source: z.enum(['metadata-document', 'registered']), - }), - scopes: z.array(z.object({ scope: z.string(), description: z.string() })), - redirectUri: z.string(), - resource: z.string(), - }) - .meta({ id: 'OAuthAuthorizationDetails' }); - -export const authorizationRedirectSchema = z - .object({ redirectUrl: z.string() }) - .meta({ id: 'OAuthAuthorizationRedirect' }); - -export const tokenRequestSchema = z - .object({ - grant_type: z.enum(['authorization_code', 'refresh_token']), - code: z.string().optional(), - redirect_uri: z.string().optional(), - client_id: z.string().optional(), - code_verifier: z.string().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), - resource: z.string().optional(), - }) - .meta({ id: 'OAuthTokenRequest' }); - -export const tokenResponseSchema = z - .object({ - access_token: z.string(), - token_type: z.literal('Bearer'), - expires_in: z.number().int(), - refresh_token: z.string(), - scope: z.string(), - }) - .meta({ id: 'OAuthTokenResponse' }); - -export const oauthErrorSchema = z - .object({ - error: z.string(), - error_description: z.string().optional(), - }) - .meta({ id: 'OAuthErrorResponse' }); - -export const revocationRequestSchema = z - .object({ - token: z.string(), - token_type_hint: z.string().optional(), - }) - .meta({ id: 'OAuthRevocationRequest' }); - -export const clientRegistrationRequestSchema = z - .object({ - redirect_uris: z.array(z.string().max(2048)).min(1).max(20), - client_name: z.string().trim().min(1).max(255), - client_uri: z.string().max(2048).optional(), - logo_uri: z.string().max(2048).optional(), - token_endpoint_auth_method: z.literal('none').optional(), - grant_types: z.array(z.string()).optional(), - response_types: z.array(z.string()).optional(), - application_type: z.enum(['web', 'native']).optional(), - scope: z.string().max(500).optional(), - software_id: z.string().max(255).optional(), - software_version: z.string().max(255).optional(), - }) - .meta({ id: 'OAuthClientRegistrationRequest' }); - -export const clientRegistrationResponseSchema = clientRegistrationRequestSchema - .extend({ - client_id: z.string(), - client_id_issued_at: z.number().int(), - token_endpoint_auth_method: z.literal('none'), - grant_types: z.array(z.string()), - response_types: z.array(z.string()), - }) - .meta({ id: 'OAuthClientRegistrationResponse' }); diff --git a/src/app/api/oauth/token/contract.ts b/src/app/api/oauth/token/contract.ts deleted file mode 100644 index 4738b9cf3..000000000 --- a/src/app/api/oauth/token/contract.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineOperation } from '@/openapi/operation'; -import { jsonResponse } from '@/openapi/schemas'; -import { oauthErrorSchema, tokenRequestSchema, tokenResponseSchema } from '../schema'; - -const tokenOperation = defineOperation({ - method: 'post', - path: '/api/oauth/token', - audience: 'internal', - auth: 'none', - operation: { - operationId: 'oauthToken', - summary: 'OAuth 2.1 token endpoint', - description: - 'Exchanges an authorization code (with PKCE code_verifier) or a refresh token for an access token. Accepts application/x-www-form-urlencoded or JSON. Refresh tokens are rotated on every use.', - tags: ['OAuth'], - requestBody: { - required: true, - content: { - 'application/x-www-form-urlencoded': { schema: tokenRequestSchema }, - 'application/json': { schema: tokenRequestSchema }, - }, - }, - responses: { - '200': jsonResponse(tokenResponseSchema, 'Access and refresh tokens.'), - '400': jsonResponse(oauthErrorSchema, 'Invalid grant or request.'), - '401': jsonResponse(oauthErrorSchema, 'Invalid client.'), - }, - }, -}); - -export const operations = [tokenOperation] as const; diff --git a/src/app/api/oauth/token/route.ts b/src/app/api/oauth/token/route.ts deleted file mode 100644 index 8513ec52a..000000000 --- a/src/app/api/oauth/token/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getIssuer, isOAuthEnabled } from '@/lib/oauth/config'; -import { oauthErrorResponse, oauthJson } from '@/lib/oauth/errors'; -import { readOAuthBody } from '@/lib/oauth/form'; -import { corsPreflight } from '@/lib/oauth/metadata'; -import { handleTokenRequest } from '@/lib/oauth/token'; -import { notFound } from '@/lib/response'; - -export function OPTIONS() { - return corsPreflight(); -} - -/** OAuth 2.1 token endpoint: authorization_code (PKCE) and refresh_token grants. */ -export async function POST(request: Request) { - if (!isOAuthEnabled()) { - return notFound(); - } - - try { - const params = await readOAuthBody(request); - const tokens = await handleTokenRequest(params, getIssuer(request.headers)); - - return oauthJson(tokens); - } catch (error) { - return oauthErrorResponse(error); - } -} diff --git a/src/app/api/realtime/[websiteId]/contract.generated.ts b/src/app/api/realtime/[websiteId]/contract.generated.ts index 7fc0ea81b..5128a0452 100644 --- a/src/app/api/realtime/[websiteId]/contract.generated.ts +++ b/src/app/api/realtime/[websiteId]/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getRealtime', summary: 'Get realtime website id', tags: ['Realtime'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/reports/attribution/contract.generated.ts b/src/app/api/reports/attribution/contract.generated.ts index 9cb5559f2..fa819302a 100644 --- a/src/app/api/reports/attribution/contract.generated.ts +++ b/src/app/api/reports/attribution/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runAttributionReport', summary: 'Create or update reports attribution', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/breakdown/contract.generated.ts b/src/app/api/reports/breakdown/contract.generated.ts index 80e81bce0..c020929f9 100644 --- a/src/app/api/reports/breakdown/contract.generated.ts +++ b/src/app/api/reports/breakdown/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runBreakdownReport', summary: 'Create or update reports breakdown', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/funnel/contract.generated.ts b/src/app/api/reports/funnel/contract.generated.ts index d0ef96987..d4da8ce58 100644 --- a/src/app/api/reports/funnel/contract.generated.ts +++ b/src/app/api/reports/funnel/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runFunnelReport', summary: 'Create or update reports funnel', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/journey/contract.generated.ts b/src/app/api/reports/journey/contract.generated.ts index 859b098fc..cbc0fb7bd 100644 --- a/src/app/api/reports/journey/contract.generated.ts +++ b/src/app/api/reports/journey/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runJourneyReport', summary: 'Create or update reports journey', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/retention/contract.generated.ts b/src/app/api/reports/retention/contract.generated.ts index 356e8bc04..0027df323 100644 --- a/src/app/api/reports/retention/contract.generated.ts +++ b/src/app/api/reports/retention/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runRetentionReport', summary: 'Create or update reports retention', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/revenue/contract.generated.ts b/src/app/api/reports/revenue/contract.generated.ts index b66d45e25..e9af7cb70 100644 --- a/src/app/api/reports/revenue/contract.generated.ts +++ b/src/app/api/reports/revenue/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runRevenueReport', summary: 'Create or update reports revenue', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/reports/utm/contract.generated.ts b/src/app/api/reports/utm/contract.generated.ts index 2b2c1c1b4..b2e277975 100644 --- a/src/app/api/reports/utm/contract.generated.ts +++ b/src/app/api/reports/utm/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'runUtmReport', summary: 'Create or update reports utm', tags: ['Reports'], - 'x-umami-oauth-scope': 'analytics:read', requestBody: { required: true, content: { diff --git a/src/app/api/users/[userId]/contract.generated.ts b/src/app/api/users/[userId]/contract.generated.ts index 35c0f4315..c540d2ce7 100644 --- a/src/app/api/users/[userId]/contract.generated.ts +++ b/src/app/api/users/[userId]/contract.generated.ts @@ -10,7 +10,9 @@ const operation1 = defineOperation({ auth: 'bearer', operation: { operationId: 'deleteUser', - summary: 'Delete users user id', + summary: 'Delete a user', + description: + 'Deletes the specified user account. The current user cannot delete their own account through this operation.', tags: ['Users'], parameters: [ { @@ -20,6 +22,7 @@ const operation1 = defineOperation({ schema: { type: 'string', }, + description: 'ID of the associated user.', }, ], responses: { @@ -32,6 +35,7 @@ const operation1 = defineOperation({ properties: { ok: { const: true, + description: 'Whether the operation succeeded.', }, }, required: ['ok'], @@ -84,7 +88,9 @@ const operation2 = defineOperation({ auth: 'bearer-or-share', operation: { operationId: 'getUser', - summary: 'Get users user id', + summary: 'Get a user', + description: + 'Returns details for the specified user account when the caller has permission to view it.', tags: ['Users'], parameters: [ { @@ -94,6 +100,7 @@ const operation2 = defineOperation({ schema: { type: 'string', }, + description: 'ID of the associated user.', }, ], responses: { @@ -109,12 +116,15 @@ const operation2 = defineOperation({ properties: { isEnabled: { type: 'boolean', + description: 'Whether two-factor authentication is enabled.', }, id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, secret: { type: 'string', @@ -122,10 +132,12 @@ const operation2 = defineOperation({ createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, }, required: ['isEnabled', 'id', 'userId', 'secret', 'createdAt', 'updatedAt'], @@ -135,13 +147,16 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, attempts: { type: 'number', @@ -149,28 +164,35 @@ const operation2 = defineOperation({ lockedUntil: { type: 'string', format: 'date-time', + description: 'Time until which further authentication attempts are blocked.', }, }, required: ['id', 'userId', 'updatedAt', 'attempts', 'lockedUntil'], }, id: { type: 'string', + description: 'Unique identifier of the resource.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, username: { type: 'string', + description: 'Username of the account.', }, password: { type: 'string', + description: 'Password used to authenticate the account.', }, role: { type: 'string', + description: 'Permission role assigned to the user.', }, twoFactorRequired: { type: 'boolean', + description: 'Whether two-factor authentication is required.', }, websites: { type: 'array', @@ -179,40 +201,51 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, deletedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was deleted, if applicable.', }, createdBy: { type: 'string', + description: 'ID of the user who created the resource.', }, name: { type: 'string', + description: 'Display name of the resource.', }, domain: { type: 'string', + description: 'Domain name associated with the resource.', }, resetAt: { type: 'string', format: 'date-time', + description: 'Date and time the website analytics were last reset.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, recorderEnabled: { type: 'boolean', + description: 'Whether recording is enabled for the website.', }, replayConfig: { anyOf: [ @@ -268,6 +301,7 @@ const operation2 = defineOperation({ required: ['length'], }, ], + description: 'Session replay and heatmap recording configuration.', }, }, required: [ @@ -293,40 +327,51 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, deletedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was deleted, if applicable.', }, createdBy: { type: 'string', + description: 'ID of the user who created the resource.', }, name: { type: 'string', + description: 'Display name of the resource.', }, domain: { type: 'string', + description: 'Domain name associated with the resource.', }, resetAt: { type: 'string', format: 'date-time', + description: 'Date and time the website analytics were last reset.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, recorderEnabled: { type: 'boolean', + description: 'Whether recording is enabled for the website.', }, replayConfig: { anyOf: [ @@ -382,6 +427,7 @@ const operation2 = defineOperation({ required: ['length'], }, ], + description: 'Session replay and heatmap recording configuration.', }, }, required: [ @@ -399,6 +445,7 @@ const operation2 = defineOperation({ 'replayConfig', ], }, + description: 'ID of the user who created the resource.', }, links: { type: 'array', @@ -407,33 +454,42 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, deletedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was deleted, if applicable.', }, name: { type: 'string', + description: 'Display name of the resource.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, url: { type: 'string', + description: 'URL associated with the resource.', }, slug: { type: 'string', + description: 'URL slug used to access the resource.', }, }, required: [ @@ -448,6 +504,7 @@ const operation2 = defineOperation({ 'slug', ], }, + description: 'Tracked links associated with the user.', }, pixels: { type: 'array', @@ -456,30 +513,38 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, deletedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was deleted, if applicable.', }, name: { type: 'string', + description: 'Display name of the resource.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, slug: { type: 'string', + description: 'URL slug used to access the resource.', }, }, required: [ @@ -493,6 +558,7 @@ const operation2 = defineOperation({ 'slug', ], }, + description: 'Tracking pixels associated with the user.', }, teams: { type: 'array', @@ -501,27 +567,34 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, role: { type: 'string', + description: 'Permission role assigned to the user.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, }, required: ['id', 'userId', 'createdAt', 'updatedAt', 'role', 'teamId'], }, + description: 'Teams associated with the user.', }, reports: { type: 'array', @@ -530,26 +603,32 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, name: { type: 'string', + description: 'Display name of the resource.', }, type: { type: 'string', }, description: { type: 'string', + description: 'Description of the resource.', }, parameters: { anyOf: [ @@ -605,9 +684,11 @@ const operation2 = defineOperation({ required: ['length'], }, ], + description: 'Configuration parameters for the resource.', }, websiteId: { type: 'string', + description: 'ID of the website.', }, }, required: [ @@ -622,6 +703,7 @@ const operation2 = defineOperation({ 'websiteId', ], }, + description: 'Saved reports associated with the user.', }, boards: { type: 'array', @@ -630,29 +712,36 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, updatedAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was last updated.', }, name: { type: 'string', + description: 'Display name of the resource.', }, teamId: { type: 'string', + description: 'ID of the associated team.', }, type: { type: 'string', }, description: { type: 'string', + description: 'Description of the resource.', }, parameters: { anyOf: [ @@ -708,6 +797,7 @@ const operation2 = defineOperation({ required: ['length'], }, ], + description: 'Configuration parameters for the resource.', }, }, required: [ @@ -722,6 +812,7 @@ const operation2 = defineOperation({ 'parameters', ], }, + description: 'Boards associated with the user.', }, twoFactorBackupCodes: { type: 'array', @@ -730,13 +821,16 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, codeHash: { type: 'string', @@ -755,9 +849,11 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, otp: { type: 'string', @@ -765,6 +861,7 @@ const operation2 = defineOperation({ expiresAt: { type: 'string', format: 'date-time', + description: 'Date and time the credential expires.', }, }, required: ['id', 'userId', 'otp', 'expiresAt'], @@ -777,26 +874,32 @@ const operation2 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, userId: { type: 'string', + description: 'ID of the associated user.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, name: { type: 'string', + description: 'Display name of the resource.', }, keyHash: { type: 'string', }, keyPrefix: { type: 'string', + description: 'Visible prefix used to identify an API key.', }, lastUsedAt: { type: 'string', format: 'date-time', + description: 'Date and time the credential was last used.', }, }, required: [ @@ -809,121 +912,7 @@ const operation2 = defineOperation({ 'lastUsedAt', ], }, - }, - oauthAuthorizationCodes: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - }, - userId: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'date-time', - }, - codeHash: { - type: 'string', - }, - expiresAt: { - type: 'string', - format: 'date-time', - }, - clientId: { - type: 'string', - }, - redirectUri: { - type: 'string', - }, - scope: { - type: 'string', - }, - resource: { - type: 'string', - }, - codeChallenge: { - type: 'string', - }, - codeChallengeMethod: { - type: 'string', - }, - usedAt: { - type: 'string', - format: 'date-time', - }, - }, - required: [ - 'id', - 'userId', - 'createdAt', - 'codeHash', - 'expiresAt', - 'clientId', - 'redirectUri', - 'scope', - 'resource', - 'codeChallenge', - 'codeChallengeMethod', - 'usedAt', - ], - }, - }, - oauthRefreshTokens: { - type: 'array', - items: { - type: 'object', - properties: { - id: { - type: 'string', - }, - userId: { - type: 'string', - }, - createdAt: { - type: 'string', - format: 'date-time', - }, - expiresAt: { - type: 'string', - format: 'date-time', - }, - lastUsedAt: { - type: 'string', - format: 'date-time', - }, - clientId: { - type: 'string', - }, - scope: { - type: 'string', - }, - resource: { - type: 'string', - }, - tokenHash: { - type: 'string', - }, - revokedAt: { - type: 'string', - format: 'date-time', - }, - }, - required: [ - 'id', - 'userId', - 'createdAt', - 'expiresAt', - 'lastUsedAt', - 'clientId', - 'scope', - 'resource', - 'tokenHash', - 'revokedAt', - ], - }, + description: 'API keys associated with the user.', }, _count: { type: 'object', @@ -933,21 +922,27 @@ const operation2 = defineOperation({ }, createdBy: { type: 'number', + description: 'ID of the user who created the resource.', }, links: { type: 'number', + description: 'Tracked links associated with the user.', }, pixels: { type: 'number', + description: 'Tracking pixels associated with the user.', }, teams: { type: 'number', + description: 'Teams associated with the user.', }, reports: { type: 'number', + description: 'Saved reports associated with the user.', }, boards: { type: 'number', + description: 'Boards associated with the user.', }, twoFactorAuth: { type: 'number', @@ -963,12 +958,7 @@ const operation2 = defineOperation({ }, apiKeys: { type: 'number', - }, - oauthAuthorizationCodes: { - type: 'number', - }, - oauthRefreshTokens: { - type: 'number', + description: 'API keys associated with the user.', }, }, required: [ @@ -984,8 +974,6 @@ const operation2 = defineOperation({ 'twoFactorOtpUseds', 'twoFactorRateLimit', 'apiKeys', - 'oauthAuthorizationCodes', - 'oauthRefreshTokens', ], }, }, @@ -1008,8 +996,6 @@ const operation2 = defineOperation({ 'twoFactorBackupCodes', 'twoFactorOtpUseds', 'apiKeys', - 'oauthAuthorizationCodes', - 'oauthRefreshTokens', '_count', ], }, @@ -1044,7 +1030,8 @@ const operation3 = defineOperation({ auth: 'bearer', operation: { operationId: 'updateUser', - summary: 'Create or update users user id', + summary: 'Update a user', + description: "Updates a user's password. Administrators can also change the username and role.", tags: ['Users'], parameters: [ { @@ -1054,6 +1041,7 @@ const operation3 = defineOperation({ schema: { type: 'string', }, + description: 'ID of the associated user.', }, ], requestBody: { @@ -1066,15 +1054,18 @@ const operation3 = defineOperation({ username: { type: 'string', maxLength: 255, + description: 'Username of the account.', }, password: { type: 'string', minLength: 8, maxLength: 255, + description: 'Password used to authenticate the account.', }, role: { type: 'string', enum: ['admin', 'user', 'view-only'], + description: 'Permission role assigned to the user.', }, }, }, @@ -1091,16 +1082,20 @@ const operation3 = defineOperation({ properties: { id: { type: 'string', + description: 'Unique identifier of the resource.', }, createdAt: { type: 'string', format: 'date-time', + description: 'Date and time the record was created.', }, username: { type: 'string', + description: 'Username of the account.', }, role: { type: 'string', + description: 'Permission role assigned to the user.', }, }, required: ['id', 'createdAt', 'username', 'role'], diff --git a/src/app/api/websites/[websiteId]/daterange/contract.generated.ts b/src/app/api/websites/[websiteId]/daterange/contract.generated.ts index ab3654648..256f4a6c4 100644 --- a/src/app/api/websites/[websiteId]/daterange/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/daterange/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteDateRange', summary: 'Get websites website id daterange', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/event-data/contract.generated.ts b/src/app/api/websites/[websiteId]/event-data/contract.generated.ts index c3abe71c3..bdca26f29 100644 --- a/src/app/api/websites/[websiteId]/event-data/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/event-data/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getEventData', summary: 'Get websites website id event data', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/event-data/properties/contract.generated.ts b/src/app/api/websites/[websiteId]/event-data/properties/contract.generated.ts index 3ed48c7c0..7752430cc 100644 --- a/src/app/api/websites/[websiteId]/event-data/properties/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/event-data/properties/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getEventDataProperties', summary: 'Get websites website id event data properties', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/event-data/stats/contract.generated.ts b/src/app/api/websites/[websiteId]/event-data/stats/contract.generated.ts index ce1e7973c..9ebb32ef6 100644 --- a/src/app/api/websites/[websiteId]/event-data/stats/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/event-data/stats/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getEventDataStats', summary: 'Get websites website id event data stats', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/event-data/values/contract.generated.ts b/src/app/api/websites/[websiteId]/event-data/values/contract.generated.ts index 2a5cf2182..394212f3b 100644 --- a/src/app/api/websites/[websiteId]/event-data/values/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/event-data/values/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getEventDataValues', summary: 'Get websites website id event data values', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/events/series/contract.generated.ts b/src/app/api/websites/[websiteId]/events/series/contract.generated.ts index e81634ab3..b9538ff4b 100644 --- a/src/app/api/websites/[websiteId]/events/series/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/events/series/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteEventSeries', summary: 'Get websites website id events series', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/events/stats/contract.generated.ts b/src/app/api/websites/[websiteId]/events/stats/contract.generated.ts index a7cdc4406..d95ff2133 100644 --- a/src/app/api/websites/[websiteId]/events/stats/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/events/stats/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteEventStats', summary: 'Get websites website id events stats', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/contract.generated.ts b/src/app/api/websites/[websiteId]/metrics/expanded/contract.generated.ts index 5a36aacb1..dce903724 100644 --- a/src/app/api/websites/[websiteId]/metrics/expanded/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/metrics/expanded/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteExpandedMetrics', summary: 'Get websites website id metrics expanded', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/contract.generated.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/contract.generated.ts index 2c4bb3bf1..382eaa3ca 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteSessionActivity', summary: 'Get websites website id sessions session id activity', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/contract.generated.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/contract.generated.ts index dcbd8cfd5..6c11e0423 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/contract.generated.ts @@ -111,7 +111,6 @@ const operation2 = defineOperation({ operationId: 'getWebsiteSession', summary: 'Get websites website id sessions session id', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/contract.generated.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/contract.generated.ts index 8b2f61435..d1e86f6c0 100644 --- a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteSessionProperties', summary: 'Get websites website id sessions session id properties', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/api/websites/[websiteId]/sessions/stats/contract.generated.ts b/src/app/api/websites/[websiteId]/sessions/stats/contract.generated.ts index cead9e3b7..2a29457f2 100644 --- a/src/app/api/websites/[websiteId]/sessions/stats/contract.generated.ts +++ b/src/app/api/websites/[websiteId]/sessions/stats/contract.generated.ts @@ -12,7 +12,6 @@ const operation1 = defineOperation({ operationId: 'getWebsiteSessionStats', summary: 'Get websites website id sessions stats', tags: ['Websites'], - 'x-umami-oauth-scope': 'analytics:read', parameters: [ { name: 'websiteId', diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index 09140769e..a1eb97da4 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -3,10 +3,9 @@ import type { McpLogger } from '@umami/mcp'; import { createUmamiMcpHttpHandler } from '@umami/mcp'; import debug from 'debug'; import { uuid } from '@/lib/crypto'; +import { getBaseUrl } from '@/lib/get-base-url'; import { authenticateMcpRequest, mcpAuthErrorResponse } from '@/lib/mcp/auth'; import { createInProcessFetch } from '@/lib/mcp/dispatch'; -import { getIssuer, isOAuthEnabled } from '@/lib/oauth/config'; -import { corsPreflight } from '@/lib/oauth/metadata'; export const dynamic = 'force-dynamic'; @@ -27,10 +26,11 @@ const handler = createUmamiMcpHttpHandler({ logger, onerror: error => log('handler error: %s', error?.message), createClient: (authInfo, ctx) => { - const issuer = getIssuer(ctx.requestInfo?.headers); + const baseUrl = getBaseUrl(ctx.requestInfo?.headers).toString().replace(/\/+$/, ''); + const basePath = (process.env.BASE_PATH ?? '').replace(/\/+$/, ''); return new UmamiClient({ - baseUrl: `${issuer}/api`, + baseUrl: `${baseUrl}${basePath}/api`, token: authInfo.token, fetch: createInProcessFetch(), }); @@ -38,14 +38,14 @@ const handler = createUmamiMcpHttpHandler({ }); async function handle(request: Request) { - if (!isOAuthEnabled() && process.env.MCP_DISABLED === '1') { + if (process.env.MCP_DISABLED === '1') { return new Response(null, { status: 404 }); } const auth = await authenticateMcpRequest(request); if (!auth.ok) { - return mcpAuthErrorResponse(auth, request.headers); + return mcpAuthErrorResponse(auth); } const requestId = uuid(); @@ -55,7 +55,15 @@ async function handle(request: Request) { } export function OPTIONS() { - return corsPreflight(); + return new Response(null, { + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS', + 'access-control-allow-headers': '*', + 'access-control-max-age': '86400', + }, + }); } export async function GET(request: Request) { diff --git a/src/app/oauth/authorize/OAuthAuthorizePage.tsx b/src/app/oauth/authorize/OAuthAuthorizePage.tsx deleted file mode 100644 index 217fa0fef..000000000 --- a/src/app/oauth/authorize/OAuthAuthorizePage.tsx +++ /dev/null @@ -1,166 +0,0 @@ -'use client'; -import { Button, Column, Heading, Icon, Loading, Row, Text } from '@umami/react-zen'; -import { usePathname, useSearchParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; -import { LoginForm } from '@/app/login/LoginForm'; -import { useApi, useLoginQuery, useMessages } from '@/components/hooks'; -import { Logo } from '@/components/svg'; -import { setReturnUrl } from '@/lib/return-url'; - -interface AuthorizationDetails { - client: { id: string; name: string; uri?: string; logoUri?: string; source: string }; - scopes: { scope: string; description: string }[]; - redirectUri: string; - resource: string; -} - -const SCOPE_MESSAGE_KEYS: Record = { - 'websites:read': 'oauthScopeWebsitesRead', - 'analytics:read': 'oauthScopeAnalyticsRead', -}; - -function getOAuthErrorMessage(error: unknown) { - const record = error as { error_description?: string; message?: string; code?: string } | null; - - return record?.error_description || record?.message || null; -} - -function Frame({ children }: { children: React.ReactNode }) { - return ( - - - - - - umami - {children} - - - ); -} - -export function OAuthAuthorizePage() { - const { t, labels, messages } = useMessages(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const params = useMemo(() => Object.fromEntries(searchParams.entries()), [searchParams]); - const { user, isLoading } = useLoginQuery(); - const { get, post, useQuery, useMutation } = useApi(); - const [redirecting, setRedirecting] = useState(false); - - useEffect(() => { - if (!isLoading && !user) { - setReturnUrl(`${pathname}?${searchParams.toString()}`); - } - }, [isLoading, user, pathname, searchParams]); - - const details = useQuery({ - queryKey: ['oauth-authorize', params], - queryFn: () => get('/oauth/authorize', params), - enabled: !!user, - retry: false, - }); - - const decide = useMutation({ - mutationFn: (decision: 'approve' | 'deny') => - post('/oauth/authorize', { ...params, decision }) as Promise<{ redirectUrl: string }>, - onSuccess: ({ redirectUrl }) => { - setRedirecting(true); - window.location.assign(redirectUrl); - }, - }); - - if (isLoading) { - return ; - } - - if (!user) { - return ( - - {t(messages.oauthLoginPrompt)} - - - ); - } - - if (details.isLoading) { - return ; - } - - if (details.error || !details.data) { - return ( - - {t(messages.oauthInvalidRequest)} - {getOAuthErrorMessage(details.error) && ( - {getOAuthErrorMessage(details.error)} - )} - - ); - } - - const { client, scopes, redirectUri } = details.data; - const redirectHost = (() => { - try { - return new URL(redirectUri).host || redirectUri; - } catch { - return redirectUri; - } - })(); - const decisionError = getOAuthErrorMessage(decide.error); - - return ( - - - - {t(messages.oauthConsent, { name: client.name })} - - {client.uri && ( - - {client.uri} - - )} - {t(messages.oauthPermissions)} -
    - {scopes.map(({ scope, description }) => ( -
  • - - {SCOPE_MESSAGE_KEYS[scope] ? t(messages[SCOPE_MESSAGE_KEYS[scope]]) : description} - -
  • - ))} -
- - {t(messages.oauthRedirectNotice, { url: redirectHost })} - - - {t(messages.oauthSignedInAs, { username: user.username })} - - {decisionError && {decisionError}} - - - - - {redirecting && {t(messages.oauthRedirecting)}} -
- - ); -} diff --git a/src/app/oauth/authorize/page.tsx b/src/app/oauth/authorize/page.tsx deleted file mode 100644 index fe4b80750..000000000 --- a/src/app/oauth/authorize/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { Metadata } from 'next'; -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { OAuthAuthorizePage } from './OAuthAuthorizePage'; - -export const dynamic = 'force-dynamic'; - -export default async function () { - if (!isOAuthEnabled()) { - return null; - } - - return ; -} - -export const metadata: Metadata = { - title: 'Authorize application', -}; diff --git a/src/components/messages.ts b/src/components/messages.ts index 252aca003..32c8595fb 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -500,15 +500,6 @@ export const messages: Record = { twoFactorDisableDescription: 'message.two-factor-disable-description', twoFactorRequiredMessage: 'message.two-factor-required-message', twoFactorOverrideNote: 'message.two-factor-override-note', - oauthLoginPrompt: 'message.oauth-login-prompt', - oauthConsent: 'message.oauth-consent', - oauthPermissions: 'message.oauth-permissions', - oauthScopeWebsitesRead: 'message.oauth-scope-websites-read', - oauthScopeAnalyticsRead: 'message.oauth-scope-analytics-read', - oauthRedirectNotice: 'message.oauth-redirect-notice', - oauthSignedInAs: 'message.oauth-signed-in-as', - oauthInvalidRequest: 'message.oauth-invalid-request', - oauthRedirecting: 'message.oauth-redirecting', twoFactorAdminGlobalDescription: 'message.two-factor-admin-global-description', twoFactorRequireUserDescription: 'message.two-factor-require-user-description', twoFactorRequireTeamDescription: 'message.two-factor-require-team-description', diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index a549e536e..0840a6b69 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -15,7 +15,6 @@ export const API_KEY_BLOCKED_PATHS = [ '/api/auth', '/api/users', '/api/admin', - '/api/oauth', ]; export function generateApiKey() { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4f5923b80..8533ea11c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -15,7 +15,6 @@ import { } from '@/lib/constants'; import { createAuthKey, hash, secret } from '@/lib/crypto'; import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; -import { verifyOAuthRequest } from '@/lib/oauth/verify'; import redis from '@/lib/redis'; import { ensureArray } from '@/lib/utils'; import { getApiKeyByHash, updateApiKeyLastUsed } from '@/queries/prisma/apiKey'; @@ -75,19 +74,6 @@ export async function checkAuth(request: Request) { return checkApiKeyAuth(request, token); } - // OAuth access tokens are only honoured on routes that explicitly opted in (see - // src/lib/oauth/scopes.ts) and never fall through to session handling. - const oauth = await verifyOAuthRequest(token, request.method, new URL(request.url).pathname); - - if (oauth.status === 'ok') { - return oauth.auth; - } - - if (oauth.status !== 'not-oauth') { - log('OAuth token rejected', oauth); - return null; - } - const payload = parseSecureToken(token, secret()); const shareToken = await parseShareToken(request); diff --git a/src/lib/mcp/auth.test.ts b/src/lib/mcp/auth.test.ts index 0b63e79a1..d8d1df9d4 100644 --- a/src/lib/mcp/auth.test.ts +++ b/src/lib/mcp/auth.test.ts @@ -1,7 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { checkApiKeyAuth } from '@/lib/auth'; -import { createAccessToken } from '@/lib/oauth/tokens'; -import { getUser } from '@/queries/prisma/user'; import { authenticateMcpRequest, mcpAuthErrorResponse } from './auth'; vi.mock('@/lib/auth', () => ({ @@ -9,115 +7,78 @@ vi.mock('@/lib/auth', () => ({ checkApiKeyAuth: vi.fn(), })); -vi.mock('@/queries/prisma/user', () => ({ - getUser: vi.fn(), -})); - const checkApiKeyAuthMock = vi.mocked(checkApiKeyAuth); -const getUserMock = vi.mocked(getUser); -const ISSUER = 'https://analytics.example.com'; -const USER = { id: 'user-1', username: 'admin', role: 'user', password: 'pw' }; function request(token?: string) { - return new Request(`${ISSUER}/mcp`, { + return new Request('https://analytics.example.com/mcp', { method: 'POST', - headers: { - host: 'analytics.example.com', - 'x-forwarded-proto': 'https', - ...(token ? { authorization: `Bearer ${token}` } : {}), - }, + headers: token ? { authorization: `Bearer ${token}` } : {}, }); } -function accessToken(overrides: Record = {}) { - return createAccessToken({ - userId: USER.id, - clientId: 'https://app.example.com/client.json', - scopes: ['websites:read', 'analytics:read'], - issuer: ISSUER, - resource: `${ISSUER}/mcp`, - passwordHash: USER.password, - ...overrides, - }).token; -} - beforeEach(() => { - process.env.APP_SECRET = 'test'; - delete process.env.OAUTH_ISSUER; - delete process.env.CLOUD_MODE; + vi.unstubAllEnvs(); + vi.stubEnv('CLOUD_MODE', ''); checkApiKeyAuthMock.mockReset(); - getUserMock.mockReset(); - getUserMock.mockResolvedValue({ ...USER } as never); }); describe('authenticateMcpRequest', () => { - test('rejects missing tokens with a resource metadata challenge', async () => { + test('rejects missing tokens with an API key challenge', async () => { const result = await authenticateMcpRequest(request()); - - expect(result.ok).toBe(false); - - const response = mcpAuthErrorResponse(result as never, request().headers); + const response = mcpAuthErrorResponse(result); expect(response.status).toBe(401); - expect(response.headers.get('www-authenticate')).toContain( - `resource_metadata="${ISSUER}/.well-known/oauth-protected-resource/mcp"`, - ); - expect(response.headers.get('www-authenticate')).toContain( - 'scope="websites:read analytics:read"', + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer realm="Umami MCP", error="invalid_request"', ); + expect(response.headers.get('cache-control')).toBe('no-store'); }); - test('accepts OAuth access tokens issued for this resource', async () => { - const result = await authenticateMcpRequest(request(accessToken())); - - expect(result.ok).toBe(true); - - if (result.ok) { - expect(result.userId).toBe(USER.id); - expect(result.authInfo).toMatchObject({ - clientId: 'https://app.example.com/client.json', - scopes: ['analytics:read', 'websites:read'], + test.each(['not-a-token', 'eyJhbGciOiJIUzI1NiJ9.eyJ0eXBlIjoib2F1dGhfYWNjZXNzIn0.signature'])( + 'rejects non-API-key credentials: %s', + async token => { + await expect(authenticateMcpRequest(request(token))).resolves.toMatchObject({ + ok: false, + status: 401, }); - expect(result.authInfo.extra).toMatchObject({ userId: USER.id, authType: 'oauth' }); - } - }); + expect(checkApiKeyAuthMock).not.toHaveBeenCalled(); + }, + ); - test('rejects tokens issued for another deployment', async () => { - const foreign = createAccessToken({ - userId: USER.id, - clientId: 'c', - scopes: ['websites:read'], - issuer: 'https://other.example.com', - resource: 'https://other.example.com/mcp', - }).token; - - const result = await authenticateMcpRequest(request(foreign)); - - expect(result).toMatchObject({ ok: false, status: 401, error: 'invalid_token' }); - }); - - test('rejects session tokens and garbage', async () => { - await expect(authenticateMcpRequest(request('not-a-token'))).resolves.toMatchObject({ - ok: false, - status: 401, - }); - }); - - test('accepts self-hosted API keys', async () => { + test('accepts self-hosted API keys and preserves owner identity', async () => { checkApiKeyAuthMock.mockResolvedValue({ token: 'umami_key', - user: { id: USER.id, username: 'admin', role: 'user', isAdmin: false }, - authType: 'api-key', + user: { id: 'user-1' }, apiKey: { id: 'key-1', name: 'MCP' }, } as never); - const result = await authenticateMcpRequest(request('umami_key')); + await expect(authenticateMcpRequest(request('umami_key'))).resolves.toMatchObject({ + ok: true, + userId: 'user-1', + authInfo: { + token: 'umami_key', + clientId: 'api-key:key-1', + scopes: [], + extra: { userId: 'user-1', authType: 'api-key' }, + }, + }); + }); - expect(result.ok).toBe(true); + test('rejects revoked or unknown keys', async () => { + checkApiKeyAuthMock.mockResolvedValue(null); + await expect(authenticateMcpRequest(request('umami_revoked'))).resolves.toMatchObject({ + ok: false, + status: 401, + error: 'invalid_token', + }); + }); - if (result.ok) { - expect(result.authInfo.clientId).toBe('api-key:key-1'); - expect(result.authInfo.scopes).toEqual(['websites:read', 'analytics:read']); - } + test('rejects self-hosted keys in Cloud mode', async () => { + vi.stubEnv('CLOUD_MODE', '1'); + await expect(authenticateMcpRequest(request('umami_key'))).resolves.toMatchObject({ + ok: false, + status: 401, + }); + expect(checkApiKeyAuthMock).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index ebbaa275b..33f80975c 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -1,24 +1,12 @@ import type { AuthInfo } from '@umami/mcp'; import { isApiKey, isApiKeyEnabled } from '@/lib/api-key'; import { checkApiKeyAuth, getBearerToken } from '@/lib/auth'; -import { getIssuer, getOAuthEndpoints, isOAuthEnabled } from '@/lib/oauth/config'; -import { OAUTH_SCOPES } from '@/lib/oauth/scopes'; -import { looksLikeAccessToken, verifyAccessToken } from '@/lib/oauth/tokens'; -import { buildOAuthAuth } from '@/lib/oauth/verify'; export type McpAuthResult = | { ok: true; authInfo: AuthInfo; userId: string } - | { ok: false; status: 401 | 403; error: string; description: string; scope?: string }; + | { ok: false; status: 401; error: string; description: string }; -/** - * Authenticates a request to the embedded MCP endpoint. - * - * Accepted credentials: - * - OAuth access tokens issued by this deployment for the `/mcp` resource (primary). - * - Self-hosted API keys (`umami_…`), so a remote MCP client can be configured without OAuth. - * - * Browser session tokens are deliberately rejected. - */ +/** The embedded MCP endpoint accepts self-hosted API keys only. */ export async function authenticateMcpRequest(request: Request): Promise { const token = getBearerToken(request); @@ -27,65 +15,18 @@ export async function authenticateMcpRequest(request: Request): Promise) { +export function mcpAuthErrorResponse(result: McpAuthResult) { if (result.ok) { throw new Error('mcpAuthErrorResponse called with a successful result.'); } - // Explicit narrowing: the project compiles without strictNullChecks, so the guard above does - // not narrow the union automatically. const failure = result as Extract; - const endpoints = getOAuthEndpoints(headers); - const parts = [ - `Bearer error="${failure.error}"`, - `error_description="${failure.description.replace(/"/g, "'")}"`, - `resource_metadata="${endpoints.protectedResourceMetadata}"`, - ]; - - if (failure.scope) { - parts.push(`scope="${failure.scope}"`); - } else if (failure.status === 401) { - parts.push(`scope="${OAUTH_SCOPES.join(' ')}"`); - } return Response.json( { error: failure.error, error_description: failure.description }, { status: failure.status, headers: { - 'www-authenticate': parts.join(', '), + 'www-authenticate': `Bearer realm="Umami MCP", error="${failure.error}"`, 'cache-control': 'no-store', }, }, diff --git a/src/lib/mcp/dispatch.test.ts b/src/lib/mcp/dispatch.test.ts index f5d0c622c..64c1e4697 100644 --- a/src/lib/mcp/dispatch.test.ts +++ b/src/lib/mcp/dispatch.test.ts @@ -1,17 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { OAUTH_ROUTE_SCOPES } from '@/lib/oauth/scopes'; -import { MCP_DISPATCH_ROUTES, matchDispatchRoute } from './dispatch'; +import { matchDispatchRoute } from './dispatch'; describe('MCP dispatch table', () => { - test('covers exactly the OAuth-allowlisted routes', () => { - const dispatch = MCP_DISPATCH_ROUTES.map(route => `${route.method} ${route.path}`).sort(); - const allowlist = OAUTH_ROUTE_SCOPES.map( - route => `${route.method.toUpperCase()} ${route.path}`, - ).sort(); - - expect(dispatch).toEqual(allowlist); - }); - test('matches concrete paths and extracts parameters', () => { const match = matchDispatchRoute('GET', '/api/websites/abc/sessions/def/activity'); diff --git a/src/lib/mcp/dispatch.ts b/src/lib/mcp/dispatch.ts index 092c4e7d9..880a4970d 100644 --- a/src/lib/mcp/dispatch.ts +++ b/src/lib/mcp/dispatch.ts @@ -13,8 +13,7 @@ export interface DispatchRoute { /** * The API routes the embedded MCP server may call, dispatched in-process (no loopback HTTP). - * This list must stay identical to `OAUTH_ROUTE_SCOPES` in `src/lib/oauth/scopes.ts`; - * `dispatch.test.ts` enforces that. Anything not listed here is unreachable from MCP. + * Anything not listed here is unreachable from MCP. */ export const MCP_DISPATCH_ROUTES: readonly DispatchRoute[] = [ { method: 'GET', path: '/api/me', load: () => import('@/app/api/me/route') }, diff --git a/src/lib/oauth/authorize.test.ts b/src/lib/oauth/authorize.test.ts deleted file mode 100644 index 846661281..000000000 --- a/src/lib/oauth/authorize.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { createAuthorizationCode } from '@/queries/prisma/oauth'; -import { - buildErrorRedirect, - buildSuccessRedirect, - issueAuthorizationCode, - validateAuthorizationRequest, -} from './authorize'; -import { resolveOAuthClient } from './client'; -import { OAuthError } from './errors'; -import { computeCodeChallenge } from './pkce'; - -vi.mock('./client', () => ({ - resolveOAuthClient: vi.fn(), -})); - -vi.mock('@/queries/prisma/oauth', () => ({ - createAuthorizationCode: vi.fn(), -})); - -const resolveOAuthClientMock = vi.mocked(resolveOAuthClient); -const createAuthorizationCodeMock = vi.mocked(createAuthorizationCode); - -const ISSUER = 'https://analytics.example.com'; -const CLIENT = { - clientId: 'https://app.example.com/client.json', - clientName: 'App', - redirectUris: ['https://app.example.com/cb', 'http://127.0.0.1:1/cb'], - source: 'metadata-document' as const, -}; -const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; - -function request(overrides: Record = {}) { - return { - response_type: 'code', - client_id: CLIENT.clientId, - redirect_uri: 'https://app.example.com/cb', - scope: 'websites:read analytics:read', - state: 'xyz', - code_challenge: computeCodeChallenge(VERIFIER), - code_challenge_method: 'S256', - resource: `${ISSUER}/mcp`, - ...overrides, - }; -} - -beforeEach(() => { - process.env.APP_SECRET = 'test'; - resolveOAuthClientMock.mockReset(); - resolveOAuthClientMock.mockResolvedValue(CLIENT); - createAuthorizationCodeMock.mockReset(); - createAuthorizationCodeMock.mockResolvedValue({} as never); -}); - -describe('validateAuthorizationRequest', () => { - test('accepts a valid request', async () => { - await expect(validateAuthorizationRequest(request(), ISSUER)).resolves.toMatchObject({ - client: CLIENT, - redirectUri: 'https://app.example.com/cb', - scopes: ['analytics:read', 'websites:read'], - state: 'xyz', - codeChallengeMethod: 'S256', - resource: `${ISSUER}/mcp`, - }); - }); - - test('defaults to all scopes and the MCP resource when omitted', async () => { - const result = await validateAuthorizationRequest( - request({ scope: undefined, resource: undefined }), - ISSUER, - ); - - expect(result.scopes).toEqual(['analytics:read', 'websites:read']); - expect(result.resource).toBe(`${ISSUER}/mcp`); - }); - - test('allows loopback redirect ports to vary', async () => { - const result = await validateAuthorizationRequest( - request({ redirect_uri: 'http://127.0.0.1:54321/cb' }), - ISSUER, - ); - - expect(result.redirectUri).toBe('http://127.0.0.1:1/cb'); - }); - - test('rejects unregistered redirect URIs before anything else (not redirectable)', async () => { - const error = await validateAuthorizationRequest( - request({ redirect_uri: 'https://evil.example/cb', response_type: 'token' }), - ISSUER, - ).catch(e => e); - - expect(error).toBeInstanceOf(OAuthError); - expect(error.code).toBe('invalid_redirect_uri'); - expect(error.redirectable).toBe(false); - }); - - test('requires PKCE S256', async () => { - await expect( - validateAuthorizationRequest(request({ code_challenge: undefined }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_request', redirectable: true }); - await expect( - validateAuthorizationRequest(request({ code_challenge_method: 'plain' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_request' }); - }); - - test('rejects unknown scopes, response types and resources', async () => { - await expect( - validateAuthorizationRequest(request({ scope: 'websites:write' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_scope' }); - await expect( - validateAuthorizationRequest(request({ response_type: 'token' }), ISSUER), - ).rejects.toMatchObject({ code: 'unsupported_response_type' }); - await expect( - validateAuthorizationRequest(request({ resource: 'https://other.example/mcp' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_target' }); - }); - - test('propagates client resolution errors', async () => { - resolveOAuthClientMock.mockRejectedValue(new OAuthError('invalid_client', 'Unknown')); - - await expect(validateAuthorizationRequest(request(), ISSUER)).rejects.toMatchObject({ - code: 'invalid_client', - }); - }); -}); - -describe('issueAuthorizationCode', () => { - test('stores a hashed, expiring, PKCE-bound code and builds the redirect', async () => { - const validated = await validateAuthorizationRequest(request(), ISSUER); - const code = await issueAuthorizationCode(validated, 'user-1'); - - expect(code.length).toBeGreaterThanOrEqual(43); - expect(createAuthorizationCodeMock).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'user-1', - clientId: CLIENT.clientId, - redirectUri: 'https://app.example.com/cb', - scope: 'analytics:read websites:read', - codeChallenge: computeCodeChallenge(VERIFIER), - codeChallengeMethod: 'S256', - }), - ); - - const stored = createAuthorizationCodeMock.mock.calls[0][0]; - - expect(stored.codeHash).not.toContain(code); - expect(stored.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); - - const redirect = new URL(buildSuccessRedirect(validated, code, ISSUER)); - - expect(redirect.origin + redirect.pathname).toBe('https://app.example.com/cb'); - expect(redirect.searchParams.get('code')).toBe(code); - expect(redirect.searchParams.get('state')).toBe('xyz'); - expect(redirect.searchParams.get('iss')).toBe(ISSUER); - }); - - test('builds error redirects with iss and state', () => { - const redirect = new URL( - buildErrorRedirect( - 'https://app.example.com/cb', - new OAuthError('access_denied', 'Denied'), - 'st', - ISSUER, - ), - ); - - expect(redirect.searchParams.get('error')).toBe('access_denied'); - expect(redirect.searchParams.get('state')).toBe('st'); - expect(redirect.searchParams.get('iss')).toBe(ISSUER); - }); -}); diff --git a/src/lib/oauth/authorize.ts b/src/lib/oauth/authorize.ts deleted file mode 100644 index 048cc7f15..000000000 --- a/src/lib/oauth/authorize.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { uuid } from '@/lib/crypto'; -import { - type MetadataFetch, - type ResolvedOAuthClient, - resolveOAuthClient, -} from '@/lib/oauth/client'; -import { - AUTHORIZATION_CODE_TTL_SECONDS, - getMcpResource, - normalizeResource, -} from '@/lib/oauth/config'; -import { OAuthError } from '@/lib/oauth/errors'; -import { isPkceMethod, isValidCodeChallenge } from '@/lib/oauth/pkce'; -import { appendRedirectParams, findMatchingRedirectUri } from '@/lib/oauth/redirect'; -import { - isOAuthScope, - normalizeScopes, - OAUTH_SCOPES, - type OAuthScope, - parseScopes, -} from '@/lib/oauth/scopes'; -import { generateAuthorizationCode, hashToken } from '@/lib/oauth/tokens'; -import { createAuthorizationCode } from '@/queries/prisma/oauth'; - -export interface AuthorizationRequestParams { - response_type?: unknown; - client_id?: unknown; - redirect_uri?: unknown; - scope?: unknown; - state?: unknown; - code_challenge?: unknown; - code_challenge_method?: unknown; - resource?: unknown; -} - -export interface ValidatedAuthorizationRequest { - client: ResolvedOAuthClient; - redirectUri: string; - scopes: OAuthScope[]; - state?: string; - codeChallenge: string; - codeChallengeMethod: 'S256'; - resource: string; -} - -function optionalString(value: unknown, name: string, max = 2048) { - if (value === undefined || value === null || value === '') { - return undefined; - } - - if (typeof value !== 'string' || value.length > max) { - throw new OAuthError('invalid_request', `${name} is invalid.`); - } - - return value; -} - -/** - * Validates an authorization request per OAuth 2.1 + MCP requirements: PKCE S256 is mandatory, - * the redirect URI must be registered, scopes must be known, and the resource indicator must be - * this deployment's MCP server. - * - * Errors thrown before the redirect URI is validated must be shown to the user; errors thrown - * afterwards are marked `redirectable`. - */ -export async function validateAuthorizationRequest( - params: AuthorizationRequestParams, - issuer: string, - options: { fetch?: MetadataFetch } = {}, -): Promise { - const client = await resolveOAuthClient(params.client_id, options); - const redirectUriParam = optionalString(params.redirect_uri, 'redirect_uri'); - const redirectUri = redirectUriParam - ? findMatchingRedirectUri(redirectUriParam, client.redirectUris) - : client.redirectUris.length === 1 - ? client.redirectUris[0] - : null; - - if (!redirectUri) { - throw new OAuthError('invalid_redirect_uri', 'redirect_uri is not registered for this client.'); - } - - const redirectable = { redirectable: true }; - - if (params.response_type !== 'code') { - throw new OAuthError( - 'unsupported_response_type', - 'response_type must be "code".', - redirectable, - ); - } - - if (!isValidCodeChallenge(params.code_challenge)) { - throw new OAuthError( - 'invalid_request', - 'A PKCE code_challenge (S256) is required.', - redirectable, - ); - } - - if (!isPkceMethod(params.code_challenge_method)) { - throw new OAuthError('invalid_request', 'code_challenge_method must be "S256".', redirectable); - } - - const requestedScopes = parseScopes(optionalString(params.scope, 'scope', 500)); - const unknown = requestedScopes.filter( - scope => !isOAuthScope(scope) && scope !== 'offline_access', - ); - - if (unknown.length) { - throw new OAuthError('invalid_scope', `Unknown scope: ${unknown.join(', ')}.`, redirectable); - } - - const scopes = requestedScopes.length - ? normalizeScopes(requestedScopes) - : normalizeScopes([...OAUTH_SCOPES]); - - if (!scopes.length) { - throw new OAuthError('invalid_scope', 'At least one scope is required.', redirectable); - } - - const expectedResource = getMcpResource(issuer); - const resourceParam = optionalString(params.resource, 'resource'); - const resource = resourceParam ? normalizeResource(resourceParam) : expectedResource; - - if (!resource || resource !== expectedResource) { - throw new OAuthError('invalid_target', `resource must be ${expectedResource}.`, redirectable); - } - - return { - client, - redirectUri, - scopes, - state: optionalString(params.state, 'state', 1024), - codeChallenge: params.code_challenge, - codeChallengeMethod: 'S256', - resource, - }; -} - -export async function issueAuthorizationCode( - request: ValidatedAuthorizationRequest, - userId: string, -) { - const code = generateAuthorizationCode(); - - await createAuthorizationCode({ - id: uuid(), - codeHash: hashToken(code), - userId, - clientId: request.client.clientId, - redirectUri: request.redirectUri, - scope: request.scopes.join(' '), - resource: request.resource, - codeChallenge: request.codeChallenge, - codeChallengeMethod: request.codeChallengeMethod, - expiresAt: new Date(Date.now() + AUTHORIZATION_CODE_TTL_SECONDS * 1000), - }); - - return code; -} - -export function buildSuccessRedirect( - request: Pick, - code: string, - issuer: string, -) { - return appendRedirectParams(request.redirectUri, { code, state: request.state, iss: issuer }); -} - -export function buildErrorRedirect( - redirectUri: string, - error: OAuthError, - state: string | undefined, - issuer: string, -) { - return appendRedirectParams(redirectUri, { - error: error.code, - error_description: error.message, - state, - iss: issuer, - }); -} diff --git a/src/lib/oauth/client.test.ts b/src/lib/oauth/client.test.ts deleted file mode 100644 index 971122ed3..000000000 --- a/src/lib/oauth/client.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { getOauthClient } from '@/queries/prisma/oauth'; -import { - clearClientMetadataCache, - fetchClientMetadata, - isClientMetadataUrl, - resolveOAuthClient, - validateClientMetadata, -} from './client'; - -vi.mock('@/lib/redis', () => ({ - default: { enabled: false, client: { get: vi.fn(), set: vi.fn() } }, -})); - -vi.mock('@/queries/prisma/oauth', () => ({ - getOauthClient: vi.fn(), -})); - -const getOauthClientMock = vi.mocked(getOauthClient); -const CLIENT_ID = 'https://app.example.com/oauth/client.json'; - -const validDocument = { - client_id: CLIENT_ID, - client_name: 'Example MCP Client', - client_uri: 'https://app.example.com', - redirect_uris: ['http://127.0.0.1:3000/callback', 'https://app.example.com/callback'], - token_endpoint_auth_method: 'none', -}; - -beforeEach(() => { - clearClientMetadataCache(); - getOauthClientMock.mockReset(); -}); - -describe('isClientMetadataUrl', () => { - test('requires https, a path and a public host', () => { - expect(isClientMetadataUrl(CLIENT_ID)).toBe(true); - expect(isClientMetadataUrl('https://app.example.com')).toBe(false); - expect(isClientMetadataUrl('https://app.example.com/')).toBe(false); - expect(isClientMetadataUrl('http://app.example.com/client.json')).toBe(false); - expect(isClientMetadataUrl('https://localhost/client.json')).toBe(false); - expect(isClientMetadataUrl('https://10.0.0.1/client.json')).toBe(false); - expect(isClientMetadataUrl('https://user:pw@app.example.com/client.json')).toBe(false); - expect(isClientMetadataUrl('not-a-url')).toBe(false); - }); -}); - -describe('validateClientMetadata', () => { - test('accepts a valid document', () => { - expect(validateClientMetadata(CLIENT_ID, validDocument)).toMatchObject({ - clientId: CLIENT_ID, - clientName: 'Example MCP Client', - redirectUris: validDocument.redirect_uris, - source: 'metadata-document', - }); - }); - - test('rejects mismatched client_id', () => { - expect(() => - validateClientMetadata(CLIENT_ID, { ...validDocument, client_id: 'https://other/x.json' }), - ).toThrow(/does not match/); - }); - - test('rejects missing or invalid redirect URIs and names', () => { - expect(() => - validateClientMetadata(CLIENT_ID, { ...validDocument, redirect_uris: [] }), - ).toThrow(/redirect_uris/); - expect(() => - validateClientMetadata(CLIENT_ID, { - ...validDocument, - redirect_uris: ['http://app.example.com/cb'], - }), - ).toThrow(/redirect_uris/); - expect(() => - validateClientMetadata(CLIENT_ID, { ...validDocument, client_name: undefined }), - ).toThrow(/client_name/); - }); - - test('rejects confidential clients', () => { - expect(() => - validateClientMetadata(CLIENT_ID, { - ...validDocument, - token_endpoint_auth_method: 'client_secret_basic', - }), - ).toThrow(/public clients/); - }); -}); - -describe('fetchClientMetadata', () => { - test('fetches and validates the document', async () => { - const fetchImpl = vi.fn(async () => Response.json(validDocument)); - const client = await fetchClientMetadata(CLIENT_ID, fetchImpl); - - expect(client.clientName).toBe('Example MCP Client'); - expect(fetchImpl).toHaveBeenCalledWith( - CLIENT_ID, - expect.objectContaining({ method: 'GET', redirect: 'error' }), - ); - }); - - test('maps fetch failures to invalid_client', async () => { - await expect( - fetchClientMetadata(CLIENT_ID, async () => new Response('nope', { status: 404 })), - ).rejects.toMatchObject({ code: 'invalid_client' }); - await expect( - fetchClientMetadata(CLIENT_ID, async () => new Response('{bad json')), - ).rejects.toMatchObject({ code: 'invalid_client_metadata' }); - await expect( - fetchClientMetadata(CLIENT_ID, async () => { - throw new TypeError('fetch failed'); - }), - ).rejects.toMatchObject({ code: 'invalid_client' }); - }); -}); - -describe('resolveOAuthClient', () => { - test('caches metadata documents', async () => { - const fetchImpl = vi.fn(async () => Response.json(validDocument)); - - await resolveOAuthClient(CLIENT_ID, { fetch: fetchImpl }); - await resolveOAuthClient(CLIENT_ID, { fetch: fetchImpl }); - - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - test('resolves registered clients from the database', async () => { - getOauthClientMock.mockResolvedValue({ - id: 'registered-1', - name: 'Registered', - redirectUris: ['https://a.example/cb'], - metadata: { client_uri: 'https://a.example' }, - createdAt: new Date(), - } as never); - - await expect(resolveOAuthClient('registered-1')).resolves.toMatchObject({ - clientId: 'registered-1', - clientName: 'Registered', - clientUri: 'https://a.example', - source: 'registered', - }); - }); - - test('rejects unknown and malformed client ids', async () => { - getOauthClientMock.mockResolvedValue(null); - - await expect(resolveOAuthClient('missing')).rejects.toMatchObject({ code: 'invalid_client' }); - await expect(resolveOAuthClient('http://insecure.example/x.json')).rejects.toMatchObject({ - code: 'invalid_client', - }); - await expect(resolveOAuthClient('')).rejects.toMatchObject({ code: 'invalid_client' }); - }); -}); diff --git a/src/lib/oauth/client.ts b/src/lib/oauth/client.ts deleted file mode 100644 index e4a6b62c3..000000000 --- a/src/lib/oauth/client.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { lookup } from 'node:dns'; -import { Agent } from 'node:https'; -import debug from 'debug'; -import ipaddr from 'ipaddr.js'; -import nodeFetch from 'node-fetch'; -import { CLIENT_METADATA_CACHE_SECONDS } from '@/lib/oauth/config'; -import { OAuthError } from '@/lib/oauth/errors'; -import { isAcceptableRedirectUri, parseUrl } from '@/lib/oauth/redirect'; -import redis from '@/lib/redis'; -import { getOauthClient } from '@/queries/prisma/oauth'; - -const log = debug('umami:oauth'); - -const METADATA_FETCH_TIMEOUT_MS = 5000; -const METADATA_MAX_BYTES = 64 * 1024; -const MAX_REDIRECT_URIS = 20; - -const metadataAgent = new Agent({ - keepAlive: false, - lookup(hostname, options, callback) { - // Validate the actual socket lookup, then connect only to those addresses. A separate - // preflight lookup would let a rebinding hostname return a private IP on the next lookup. - lookup(hostname, { all: true }, (error, addresses) => { - if (error) { - callback(error, '', 0); - return; - } - - if ( - !addresses.length || - addresses.some(({ address }) => ipaddr.parse(address).range() !== 'unicast') - ) { - callback(new Error('Client metadata must resolve only to public addresses.'), '', 0); - return; - } - - const destinations = options.family - ? addresses.filter(({ family }) => family === options.family) - : addresses; - - if (!destinations.length) { - callback(new Error('No public address found for the requested address family.'), '', 0); - } else if (options.all) { - callback(null, destinations); - } else { - callback(null, destinations[0].address, destinations[0].family); - } - }); - }, -}); - -export interface ResolvedOAuthClient { - clientId: string; - clientName: string; - clientUri?: string; - logoUri?: string; - redirectUris: string[]; - source: 'metadata-document' | 'registered'; -} - -interface ClientMetadataDocument { - client_id?: unknown; - client_name?: unknown; - client_uri?: unknown; - logo_uri?: unknown; - redirect_uris?: unknown; - token_endpoint_auth_method?: unknown; -} - -const memoryCache = new Map(); - -function isPrivateHostname(hostname: string) { - const host = hostname.toLowerCase(); - - if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) { - return true; - } - - // IPv6 literal - if (host.startsWith('[')) { - return true; - } - - // IPv4 literal: block entirely; metadata documents must be served from a DNS name. - if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) { - return true; - } - - return false; -} - -/** - * A Client ID Metadata Document identifier is an https URL with a non-root path. - * (draft-ietf-oauth-client-id-metadata-document) - */ -export function isClientMetadataUrl(clientId: string) { - const url = parseUrl(clientId); - - return ( - !!url && - url.protocol === 'https:' && - url.pathname !== '/' && - url.pathname.length > 1 && - !url.hash && - !url.username && - !url.password && - !isPrivateHostname(url.hostname) - ); -} - -function parseRedirectUris(value: unknown) { - if (!Array.isArray(value) || !value.length || value.length > MAX_REDIRECT_URIS) { - return null; - } - - const uris = value.filter((uri): uri is string => typeof uri === 'string'); - - if (uris.length !== value.length || !uris.every(isAcceptableRedirectUri)) { - return null; - } - - return uris; -} - -function asString(value: unknown, max = 255) { - return typeof value === 'string' && value.length <= max ? value : undefined; -} - -export function validateClientMetadata( - clientId: string, - document: ClientMetadataDocument, -): ResolvedOAuthClient { - if (document.client_id !== clientId) { - throw new OAuthError( - 'invalid_client_metadata', - 'client_id in the metadata document does not match the document URL.', - ); - } - - const redirectUris = parseRedirectUris(document.redirect_uris); - - if (!redirectUris) { - throw new OAuthError( - 'invalid_client_metadata', - 'redirect_uris must be a non-empty list of absolute https, loopback or custom-scheme URLs.', - ); - } - - const clientName = asString(document.client_name); - - if (!clientName) { - throw new OAuthError('invalid_client_metadata', 'client_name is required.'); - } - - if ( - document.token_endpoint_auth_method !== undefined && - document.token_endpoint_auth_method !== 'none' - ) { - throw new OAuthError( - 'invalid_client_metadata', - 'Only public clients (token_endpoint_auth_method "none") are supported.', - ); - } - - return { - clientId, - clientName, - clientUri: asString(document.client_uri, 2048), - logoUri: asString(document.logo_uri, 2048), - redirectUris, - source: 'metadata-document', - }; -} - -async function readCache(clientId: string): Promise { - if (redis.enabled) { - return (await redis.client.get(`oauth:client:${clientId}`)) ?? null; - } - - const entry = memoryCache.get(clientId); - - if (entry && entry.expiresAt > Date.now()) { - return entry.value; - } - - memoryCache.delete(clientId); - - return null; -} - -async function writeCache(clientId: string, value: ResolvedOAuthClient) { - if (redis.enabled) { - await redis.client.set(`oauth:client:${clientId}`, value, CLIENT_METADATA_CACHE_SECONDS); - return; - } - - memoryCache.set(clientId, { - value, - expiresAt: Date.now() + CLIENT_METADATA_CACHE_SECONDS * 1000, - }); -} - -export type MetadataFetch = ( - url: string, - init: RequestInit, -) => Promise>; - -const fetchPublicMetadata: MetadataFetch = (url, init) => - nodeFetch(url, { - method: 'GET', - headers: { accept: 'application/json' }, - redirect: 'error', - signal: init.signal, - agent: metadataAgent, - size: METADATA_MAX_BYTES, - }); - -export async function fetchClientMetadata( - clientId: string, - fetchImpl: MetadataFetch = fetchPublicMetadata, -): Promise { - if (!isClientMetadataUrl(clientId)) { - throw new OAuthError( - 'invalid_client', - 'Client metadata must use a public https URL with a path.', - ); - } - - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), METADATA_FETCH_TIMEOUT_MS); - - try { - const response = await fetchImpl(clientId, { - method: 'GET', - headers: { accept: 'application/json' }, - redirect: 'error', - signal: controller.signal, - }); - - if (!response.ok) { - throw new OAuthError( - 'invalid_client', - `Could not fetch client metadata document (${response.status}).`, - ); - } - - const text = await response.text(); - - if (Buffer.byteLength(text, 'utf8') > METADATA_MAX_BYTES) { - throw new OAuthError('invalid_client_metadata', 'Client metadata document is too large.'); - } - - let document: ClientMetadataDocument; - - try { - document = JSON.parse(text); - } catch { - throw new OAuthError('invalid_client_metadata', 'Client metadata document is not JSON.'); - } - - if (!document || typeof document !== 'object' || Array.isArray(document)) { - throw new OAuthError('invalid_client_metadata', 'Client metadata document is not an object.'); - } - - return validateClientMetadata(clientId, document); - } catch (error) { - if (error instanceof OAuthError) { - throw error; - } - - log('client metadata fetch failed', error); - - throw new OAuthError('invalid_client', 'Could not fetch client metadata document.'); - } finally { - clearTimeout(timer); - controller.abort(); - } -} - -/** - * Resolves a client by ID. URL-shaped IDs are Client ID Metadata Documents (fetched and cached); - * anything else must be a client registered through dynamic registration. - */ -export async function resolveOAuthClient( - clientId: unknown, - options: { fetch?: MetadataFetch } = {}, -): Promise { - if (typeof clientId !== 'string' || !clientId || clientId.length > 2048) { - throw new OAuthError('invalid_client', 'client_id is required.'); - } - - if (isClientMetadataUrl(clientId)) { - const cached = await readCache(clientId); - - if (cached) { - return cached; - } - - const client = await fetchClientMetadata(clientId, options.fetch); - - await writeCache(clientId, client); - - return client; - } - - if (clientId.startsWith('http://') || clientId.startsWith('https://')) { - throw new OAuthError( - 'invalid_client', - 'client_id URLs must use https, include a path and not point at a private host.', - ); - } - - const registered = await getOauthClient(clientId); - - if (!registered) { - throw new OAuthError('invalid_client', 'Unknown client_id.'); - } - - const redirectUris = parseRedirectUris(registered.redirectUris); - - if (!redirectUris) { - throw new OAuthError('invalid_client', 'Registered client has no valid redirect URIs.'); - } - - const metadata = (registered.metadata ?? {}) as Record; - - return { - clientId: registered.id, - clientName: registered.name, - clientUri: asString(metadata.client_uri, 2048), - logoUri: asString(metadata.logo_uri, 2048), - redirectUris, - source: 'registered', - }; -} - -export function clearClientMetadataCache() { - memoryCache.clear(); -} diff --git a/src/lib/oauth/config.ts b/src/lib/oauth/config.ts deleted file mode 100644 index 434c769c3..000000000 --- a/src/lib/oauth/config.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { getBaseUrl } from '@/lib/get-base-url'; - -/** Authorization codes are single-use and short-lived (OAuth 2.1 recommends ≤ 10 minutes). */ -export const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60; -/** Access tokens are short-lived JWTs; refresh tokens carry long-lived sessions. */ -export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; -export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; -/** Cache lifetime for fetched Client ID Metadata Documents. */ -export const CLIENT_METADATA_CACHE_SECONDS = 60 * 60; - -export const OAUTH_ACCESS_TOKEN_TYPE = 'oauth_access'; -export const REFRESH_TOKEN_PREFIX = 'umami_rt_'; -export const MCP_RESOURCE_PATH = '/mcp'; - -export function isOAuthEnabled() { - return process.env.OAUTH_DISABLED !== '1' && !process.env.DISABLE_LOGIN; -} - -export function isDynamicRegistrationEnabled() { - return isOAuthEnabled() && process.env.OAUTH_DISABLE_DCR !== '1'; -} - -function trimTrailingSlash(value: string) { - return value.replace(/\/+$/, ''); -} - -/** - * The OAuth issuer identifier: the public base URL of this Umami instance (including any - * BASE_PATH). Can be pinned with `OAUTH_ISSUER` for deployments behind proxies that do not - * forward host headers. - */ -export function getIssuer(headers?: Pick) { - const configured = process.env.OAUTH_ISSUER?.trim(); - - if (configured) { - return trimTrailingSlash(configured); - } - - const base = trimTrailingSlash(getBaseUrl(headers).toString()); - const basePath = trimTrailingSlash(process.env.BASE_PATH ?? ''); - - return `${base}${basePath}`; -} - -/** Canonical resource identifier for the embedded MCP server (RFC 8707). */ -export function getMcpResource(issuer: string) { - return `${trimTrailingSlash(issuer)}${MCP_RESOURCE_PATH}`; -} - -export interface OAuthEndpoints { - issuer: string; - resource: string; - authorizationEndpoint: string; - tokenEndpoint: string; - revocationEndpoint: string; - registrationEndpoint?: string; - authorizationServerMetadata: string; - protectedResourceMetadata: string; -} - -export function getOAuthEndpoints(headers?: Pick): OAuthEndpoints { - const issuer = getIssuer(headers); - - return { - issuer, - resource: getMcpResource(issuer), - authorizationEndpoint: `${issuer}/oauth/authorize`, - tokenEndpoint: `${issuer}/api/oauth/token`, - revocationEndpoint: `${issuer}/api/oauth/revoke`, - registrationEndpoint: isDynamicRegistrationEnabled() - ? `${issuer}/api/oauth/register` - : undefined, - authorizationServerMetadata: `${issuer}/.well-known/oauth-authorization-server`, - protectedResourceMetadata: `${issuer}/.well-known/oauth-protected-resource${MCP_RESOURCE_PATH}`, - }; -} - -/** - * Normalizes a resource indicator for comparison: lowercase scheme/host, no trailing slash, - * no fragment. - */ -export function normalizeResource(value: string) { - try { - const url = new URL(value); - - if (url.hash) { - return null; - } - - url.hash = ''; - - return trimTrailingSlash(url.toString()); - } catch { - return null; - } -} diff --git a/src/lib/oauth/errors.ts b/src/lib/oauth/errors.ts deleted file mode 100644 index 27dcb196f..000000000 --- a/src/lib/oauth/errors.ts +++ /dev/null @@ -1,88 +0,0 @@ -export type OAuthErrorCode = - | 'invalid_request' - | 'invalid_client' - | 'invalid_grant' - | 'unauthorized_client' - | 'unsupported_grant_type' - | 'unsupported_response_type' - | 'invalid_scope' - | 'invalid_target' - | 'access_denied' - | 'server_error' - | 'temporarily_unavailable' - | 'invalid_client_metadata' - | 'invalid_redirect_uri'; - -export class OAuthError extends Error { - readonly code: OAuthErrorCode; - readonly status: number; - /** When true the error may be delivered to the (validated) redirect URI. */ - readonly redirectable: boolean; - - constructor( - code: OAuthErrorCode, - description: string, - options: { status?: number; redirectable?: boolean } = {}, - ) { - super(description); - this.name = 'OAuthError'; - this.code = code; - this.status = options.status ?? defaultStatus(code); - this.redirectable = options.redirectable ?? false; - } - - toJSON() { - return { error: this.code, error_description: this.message }; - } -} - -function defaultStatus(code: OAuthErrorCode) { - switch (code) { - case 'invalid_client': - return 401; - case 'server_error': - return 500; - case 'temporarily_unavailable': - return 503; - default: - return 400; - } -} - -export function isOAuthError(error: unknown): error is OAuthError { - return error instanceof OAuthError || (error as { name?: string })?.name === 'OAuthError'; -} - -const NO_STORE = { 'cache-control': 'no-store', pragma: 'no-cache' }; - -export function oauthJson(body: unknown, init: ResponseInit = {}) { - return Response.json(body, { - ...init, - headers: { ...NO_STORE, ...(init.headers as Record) }, - }); -} - -export function oauthErrorResponse(error: unknown) { - if (isOAuthError(error)) { - const headers: Record = {}; - - if (error.status === 401) { - headers['www-authenticate'] = `Bearer error="${error.code}"`; - } - - return oauthJson(error.toJSON(), { status: error.status, headers }); - } - - return oauthJson( - { error: 'server_error', error_description: 'Unexpected error.' }, - { status: 500 }, - ); -} - -export function toOAuthError(error: unknown, fallback: OAuthErrorCode = 'server_error') { - if (isOAuthError(error)) { - return error; - } - - return new OAuthError(fallback, 'Unexpected error.'); -} diff --git a/src/lib/oauth/form.ts b/src/lib/oauth/form.ts deleted file mode 100644 index dc93a2776..000000000 --- a/src/lib/oauth/form.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * OAuth token/revocation/registration endpoints accept `application/x-www-form-urlencoded` - * (per RFC 6749) and, for convenience, JSON. - */ -export async function readOAuthBody(request: Request): Promise> { - const contentType = request.headers.get('content-type') ?? ''; - - try { - if (contentType.includes('application/json')) { - const body = await request.json(); - - return body && typeof body === 'object' && !Array.isArray(body) ? body : {}; - } - - const text = await request.text(); - const params = new URLSearchParams(text); - const result: Record = {}; - - for (const [key, value] of params) { - if (!(key in result)) { - result[key] = value; - } - } - - return result; - } catch { - return {}; - } -} diff --git a/src/lib/oauth/index.ts b/src/lib/oauth/index.ts deleted file mode 100644 index 154c93d94..000000000 --- a/src/lib/oauth/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './authorize'; -export * from './client'; -export * from './config'; -export * from './errors'; -export * from './form'; -export * from './metadata'; -export * from './pkce'; -export * from './redirect'; -export * from './scopes'; -export * from './token'; -export * from './tokens'; -export * from './verify'; diff --git a/src/lib/oauth/metadata.ts b/src/lib/oauth/metadata.ts deleted file mode 100644 index 262b213b4..000000000 --- a/src/lib/oauth/metadata.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getOAuthEndpoints } from '@/lib/oauth/config'; -import { OAUTH_SCOPES } from '@/lib/oauth/scopes'; - -/** RFC 8414 Authorization Server Metadata. */ -export function buildAuthorizationServerMetadata(headers?: Pick) { - const endpoints = getOAuthEndpoints(headers); - - return { - issuer: endpoints.issuer, - authorization_endpoint: endpoints.authorizationEndpoint, - token_endpoint: endpoints.tokenEndpoint, - revocation_endpoint: endpoints.revocationEndpoint, - ...(endpoints.registrationEndpoint - ? { registration_endpoint: endpoints.registrationEndpoint } - : {}), - response_types_supported: ['code'], - response_modes_supported: ['query'], - grant_types_supported: ['authorization_code', 'refresh_token'], - code_challenge_methods_supported: ['S256'], - token_endpoint_auth_methods_supported: ['none'], - revocation_endpoint_auth_methods_supported: ['none'], - scopes_supported: [...OAUTH_SCOPES], - authorization_response_iss_parameter_supported: true, - client_id_metadata_document_supported: true, - service_documentation: 'https://umami.is/docs/mcp', - }; -} - -/** RFC 9728 Protected Resource Metadata for the embedded MCP server. */ -export function buildProtectedResourceMetadata(headers?: Pick) { - const endpoints = getOAuthEndpoints(headers); - - return { - resource: endpoints.resource, - resource_name: 'Umami MCP', - authorization_servers: [endpoints.issuer], - scopes_supported: [...OAUTH_SCOPES], - bearer_methods_supported: ['header'], - resource_documentation: 'https://umami.is/docs/mcp', - }; -} - -export function metadataResponse(body: unknown) { - return Response.json(body, { - headers: { - 'cache-control': 'public, max-age=300', - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, OPTIONS', - 'access-control-allow-headers': '*', - }, - }); -} - -export function corsPreflight() { - return new Response(null, { - status: 204, - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET, POST, OPTIONS', - 'access-control-allow-headers': '*', - 'access-control-max-age': '86400', - }, - }); -} diff --git a/src/lib/oauth/pkce.test.ts b/src/lib/oauth/pkce.test.ts deleted file mode 100644 index 2ff7ead88..000000000 --- a/src/lib/oauth/pkce.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - computeCodeChallenge, - generateSecret, - isValidCodeChallenge, - isValidCodeVerifier, - verifyPkce, -} from './pkce'; - -// RFC 7636 Appendix B test vector. -const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; -const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; - -describe('pkce', () => { - test('computes S256 challenges', () => { - expect(computeCodeChallenge(VERIFIER)).toBe(CHALLENGE); - }); - - test('verifies matching verifiers and rejects mismatches', () => { - expect(verifyPkce(VERIFIER, CHALLENGE, 'S256')).toBe(true); - expect(verifyPkce(`${VERIFIER.slice(0, -1)}x`, CHALLENGE, 'S256')).toBe(false); - expect(verifyPkce(VERIFIER, CHALLENGE, 'plain')).toBe(false); - expect(verifyPkce(undefined, CHALLENGE, 'S256')).toBe(false); - }); - - test('validates verifier and challenge formats', () => { - expect(isValidCodeVerifier(VERIFIER)).toBe(true); - expect(isValidCodeVerifier('short')).toBe(false); - expect(isValidCodeChallenge(CHALLENGE)).toBe(true); - expect(isValidCodeChallenge('not base64url!')).toBe(false); - }); - - test('generates URL-safe secrets', () => { - const secret = generateSecret(); - - expect(secret).toMatch(/^[A-Za-z0-9_-]+$/); - expect(secret.length).toBeGreaterThanOrEqual(43); - expect(generateSecret()).not.toBe(secret); - }); -}); diff --git a/src/lib/oauth/pkce.ts b/src/lib/oauth/pkce.ts deleted file mode 100644 index c6d379637..000000000 --- a/src/lib/oauth/pkce.ts +++ /dev/null @@ -1,39 +0,0 @@ -import crypto from 'node:crypto'; - -export const PKCE_METHODS = ['S256'] as const; -export type PkceMethod = (typeof PKCE_METHODS)[number]; - -const VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; -const CHALLENGE_PATTERN = /^[A-Za-z0-9\-_]{43}$/; - -export function isPkceMethod(value: unknown): value is PkceMethod { - return typeof value === 'string' && (PKCE_METHODS as readonly string[]).includes(value); -} - -export function isValidCodeChallenge(value: unknown): value is string { - return typeof value === 'string' && CHALLENGE_PATTERN.test(value); -} - -export function isValidCodeVerifier(value: unknown): value is string { - return typeof value === 'string' && VERIFIER_PATTERN.test(value); -} - -export function computeCodeChallenge(verifier: string) { - return crypto.createHash('sha256').update(verifier, 'ascii').digest('base64url'); -} - -export function verifyPkce(verifier: unknown, challenge: string, method: string) { - if (method !== 'S256' || !isValidCodeVerifier(verifier)) { - return false; - } - - const expected = Buffer.from(challenge); - const actual = Buffer.from(computeCodeChallenge(verifier)); - - return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); -} - -/** URL-safe random secret used for authorization codes and refresh tokens. */ -export function generateSecret(bytes = 32) { - return crypto.randomBytes(bytes).toString('base64url'); -} diff --git a/src/lib/oauth/redirect.test.ts b/src/lib/oauth/redirect.test.ts deleted file mode 100644 index 940e0ed3b..000000000 --- a/src/lib/oauth/redirect.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - appendRedirectParams, - findMatchingRedirectUri, - isAcceptableRedirectUri, - redirectUriMatches, -} from './redirect'; - -describe('redirect URIs', () => { - test('accepts https, loopback http and custom schemes', () => { - expect(isAcceptableRedirectUri('https://app.example/callback')).toBe(true); - expect(isAcceptableRedirectUri('http://127.0.0.1:3000/callback')).toBe(true); - expect(isAcceptableRedirectUri('http://localhost/callback')).toBe(true); - expect(isAcceptableRedirectUri('myapp://callback')).toBe(true); - }); - - test('rejects non-loopback http, fragments and garbage', () => { - expect(isAcceptableRedirectUri('http://app.example/callback')).toBe(false); - expect(isAcceptableRedirectUri('https://app.example/callback#x')).toBe(false); - expect(isAcceptableRedirectUri('not a url')).toBe(false); - expect(isAcceptableRedirectUri(undefined)).toBe(false); - }); - - test('matches exactly, except loopback ports may vary', () => { - expect(redirectUriMatches('https://a.example/cb', 'https://a.example/cb')).toBe(true); - expect(redirectUriMatches('https://a.example/cb/', 'https://a.example/cb')).toBe(false); - expect(redirectUriMatches('https://a.example/cb?x=1', 'https://a.example/cb')).toBe(false); - expect(redirectUriMatches('http://127.0.0.1:51234/cb', 'http://127.0.0.1:3000/cb')).toBe(true); - expect(redirectUriMatches('http://localhost:51234/cb', 'http://localhost/cb')).toBe(true); - expect(redirectUriMatches('http://127.0.0.1:51234/other', 'http://127.0.0.1:3000/cb')).toBe( - false, - ); - expect(redirectUriMatches('http://localhost:1/cb', 'http://127.0.0.1:1/cb')).toBe(false); - }); - - test('finds the registered match', () => { - expect( - findMatchingRedirectUri('http://127.0.0.1:9999/cb', [ - 'https://a.example/cb', - 'http://127.0.0.1:1/cb', - ]), - ).toBe('http://127.0.0.1:1/cb'); - expect(findMatchingRedirectUri('https://evil.example/cb', ['https://a.example/cb'])).toBeNull(); - }); - - test('appends parameters without dropping existing ones', () => { - const url = new URL( - appendRedirectParams('https://a.example/cb?keep=1', { - code: 'c', - state: undefined, - iss: 'i', - }), - ); - - expect(url.searchParams.get('keep')).toBe('1'); - expect(url.searchParams.get('code')).toBe('c'); - expect(url.searchParams.get('iss')).toBe('i'); - expect(url.searchParams.has('state')).toBe(false); - }); -}); diff --git a/src/lib/oauth/redirect.ts b/src/lib/oauth/redirect.ts deleted file mode 100644 index 47ae9ebac..000000000 --- a/src/lib/oauth/redirect.ts +++ /dev/null @@ -1,77 +0,0 @@ -const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']); - -export function parseUrl(value: unknown): URL | null { - if (typeof value !== 'string' || !value) { - return null; - } - - try { - return new URL(value); - } catch { - return null; - } -} - -export function isLoopbackUrl(url: URL) { - return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname); -} - -/** - * Redirect URIs must be valid absolute URLs without fragments. Non-loopback `http` is rejected; - * custom schemes (native apps) and `https` are accepted. - */ -export function isAcceptableRedirectUri(value: unknown) { - const url = parseUrl(value); - - if (!url || url.hash) { - return false; - } - - if (url.protocol === 'http:') { - return isLoopbackUrl(url); - } - - return true; -} - -/** - * Exact string comparison, except that loopback redirect URIs may vary the port - * (RFC 8252 §7.3), since native clients bind an ephemeral port per authorization. - */ -export function redirectUriMatches(candidate: string, registered: string) { - if (candidate === registered) { - return true; - } - - const left = parseUrl(candidate); - const right = parseUrl(registered); - - if (!left || !right || !isLoopbackUrl(left) || !isLoopbackUrl(right)) { - return false; - } - - return ( - left.hostname === right.hostname && - left.pathname === right.pathname && - left.search === right.search - ); -} - -export function findMatchingRedirectUri(candidate: string, registered: readonly string[]) { - return registered.find(uri => redirectUriMatches(candidate, uri)) ?? null; -} - -export function appendRedirectParams( - redirectUri: string, - params: Record, -) { - const url = new URL(redirectUri); - - for (const [key, value] of Object.entries(params)) { - if (value !== undefined) { - url.searchParams.set(key, value); - } - } - - return url.toString(); -} diff --git a/src/lib/oauth/replay.ts b/src/lib/oauth/replay.ts deleted file mode 100644 index 96c72bb23..000000000 --- a/src/lib/oauth/replay.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Replay detection for authorization codes lives in `consumeAuthorizationCode` - * (`src/queries/prisma/oauth.ts`): a second redemption returns the original grant so that every - * refresh token issued from it can be revoked (OAuth 2.1 §4.1.2). - */ -export {}; diff --git a/src/lib/oauth/scopes.test.ts b/src/lib/oauth/scopes.test.ts deleted file mode 100644 index 377a66567..000000000 --- a/src/lib/oauth/scopes.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { - getContractOAuthScope, - getRouteOAuthScope, - hasRequiredScope, - normalizeScopes, - OAUTH_ROUTE_SCOPES, - parseScopes, -} from './scopes'; - -describe('getRouteOAuthScope', () => { - test('matches concrete paths against parameterized routes', () => { - expect( - getRouteOAuthScope('GET', '/api/websites/6f2a7e0e-2b0f-4b3f-9f0a-1234567890ab/stats'), - ).toBe('analytics:read'); - expect(getRouteOAuthScope('get', '/api/websites')).toBe('websites:read'); - expect(getRouteOAuthScope('GET', '/api/websites/abc/sessions/def/activity')).toBe( - 'analytics:read', - ); - }); - - test('ignores a base path prefix', () => { - expect(getRouteOAuthScope('GET', '/umami/api/websites')).toBe('websites:read'); - }); - - test('rejects routes that are not allowlisted', () => { - expect(getRouteOAuthScope('POST', '/api/websites')).toBeNull(); - expect(getRouteOAuthScope('DELETE', '/api/websites/abc')).toBeNull(); - expect(getRouteOAuthScope('GET', '/api/admin/users')).toBeNull(); - expect(getRouteOAuthScope('GET', '/api/me/api-keys')).toBeNull(); - expect(getRouteOAuthScope('POST', '/api/reports')).toBeNull(); - }); - - test('does not match partial segments', () => { - expect(getRouteOAuthScope('GET', '/api/websites/abc/stats/extra')).toBeNull(); - expect(getRouteOAuthScope('GET', '/api/websitesX')).toBeNull(); - }); -}); - -describe('getContractOAuthScope', () => { - test('looks up contract paths exactly', () => { - expect(getContractOAuthScope('get', '/api/websites/{websiteId}/stats')).toBe('analytics:read'); - expect(getContractOAuthScope('post', '/api/websites/{websiteId}')).toBeNull(); - }); -}); - -describe('scope helpers', () => { - test('parses and normalizes scope strings', () => { - expect(parseScopes(' analytics:read websites:read ')).toEqual([ - 'analytics:read', - 'websites:read', - ]); - expect(normalizeScopes(['websites:read', 'bogus', 'analytics:read', 'websites:read'])).toEqual([ - 'analytics:read', - 'websites:read', - ]); - expect(hasRequiredScope(['analytics:read'], 'websites:read')).toBe(false); - }); - - test('every allowlisted route is unique', () => { - const keys = OAUTH_ROUTE_SCOPES.map(route => `${route.method} ${route.path}`); - - expect(new Set(keys).size).toBe(keys.length); - }); -}); diff --git a/src/lib/oauth/scopes.ts b/src/lib/oauth/scopes.ts deleted file mode 100644 index dca5c2a6e..000000000 --- a/src/lib/oauth/scopes.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * OAuth scopes and the allowlist of API routes that accept OAuth access tokens. - * - * This module is the single source of truth for both: - * - the OpenAPI document (`src/openapi`), which advertises the scope per operation, and - * - runtime enforcement (`src/lib/auth.ts`), which rejects OAuth tokens on routes that - * have not explicitly opted in. - * - * It must remain free of environment-dependent side effects so that it can be imported - * from build scripts. - */ - -export const OAUTH_SCOPES = ['websites:read', 'analytics:read'] as const; - -export type OAuthScope = (typeof OAUTH_SCOPES)[number]; - -export const OAUTH_SCOPE_DESCRIPTIONS: Record = { - 'websites:read': 'List and view websites you have access to.', - 'analytics:read': 'Read analytics data (traffic, metrics, events, sessions, reports).', -}; - -export type OAuthHttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; - -export interface OAuthRouteScope { - method: OAuthHttpMethod; - path: `/api/${string}`; - scope: OAuthScope; -} - -/** - * Routes that accept OAuth access tokens, and the scope each one requires. - * Paths use OpenAPI-style `{param}` placeholders and must match a discovered route exactly. - * - * Any route not listed here rejects OAuth authentication. Keep this list limited to what the - * MCP server needs; expanding it is an explicit product decision. - */ -export const OAUTH_ROUTE_SCOPES: readonly OAuthRouteScope[] = [ - // Account - { method: 'get', path: '/api/me', scope: 'websites:read' }, - - // Websites - { method: 'get', path: '/api/websites', scope: 'websites:read' }, - { method: 'get', path: '/api/websites/{websiteId}', scope: 'websites:read' }, - { method: 'get', path: '/api/websites/{websiteId}/active', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/daterange', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/stats', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/pageviews', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/metrics', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/metrics/expanded', scope: 'analytics:read' }, - { method: 'get', path: '/api/realtime/{websiteId}', scope: 'analytics:read' }, - - // Events - { method: 'get', path: '/api/websites/{websiteId}/events', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/events/stats', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/events/series', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/event-data', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/event-data/stats', scope: 'analytics:read' }, - { - method: 'get', - path: '/api/websites/{websiteId}/event-data/properties', - scope: 'analytics:read', - }, - { method: 'get', path: '/api/websites/{websiteId}/event-data/values', scope: 'analytics:read' }, - - // Sessions - { method: 'get', path: '/api/websites/{websiteId}/sessions', scope: 'analytics:read' }, - { method: 'get', path: '/api/websites/{websiteId}/sessions/stats', scope: 'analytics:read' }, - { - method: 'get', - path: '/api/websites/{websiteId}/sessions/{sessionId}', - scope: 'analytics:read', - }, - { - method: 'get', - path: '/api/websites/{websiteId}/sessions/{sessionId}/activity', - scope: 'analytics:read', - }, - { - method: 'get', - path: '/api/websites/{websiteId}/sessions/{sessionId}/properties', - scope: 'analytics:read', - }, - - // Reports (read/query only; saved-report CRUD is intentionally excluded) - { method: 'post', path: '/api/reports/attribution', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/breakdown', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/funnel', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/journey', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/retention', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/revenue', scope: 'analytics:read' }, - { method: 'post', path: '/api/reports/utm', scope: 'analytics:read' }, -]; - -export function isOAuthScope(value: unknown): value is OAuthScope { - return typeof value === 'string' && (OAUTH_SCOPES as readonly string[]).includes(value); -} - -export function parseScopes(value?: string | null): string[] { - return (value ?? '') - .split(/[\s,]+/) - .map(scope => scope.trim()) - .filter(Boolean); -} - -export function normalizeScopes(scopes: string[]): OAuthScope[] { - return [...new Set(scopes.filter(isOAuthScope))].sort() as OAuthScope[]; -} - -function escapeRegExp(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function toPattern(path: string) { - const source = path - .split('/') - .map(segment => (/^\{[^}]+}$/.test(segment) ? '[^/]+' : escapeRegExp(segment))) - .join('/'); - - return new RegExp(`^${source}/?$`); -} - -const routePatterns = OAUTH_ROUTE_SCOPES.map(route => ({ - ...route, - pattern: toPattern(route.path), -})); - -/** - * Returns the scope required to call the given route with an OAuth token, or `null` when the - * route does not accept OAuth authentication at all. - * - * `pathname` is a concrete request path (e.g. `/api/websites/1234/stats`). Any `BASE_PATH` - * or API URL prefix must be stripped by the caller. - */ -export function getRouteOAuthScope(method: string, pathname: string): OAuthScope | null { - const normalizedMethod = method.toLowerCase(); - const apiIndex = pathname.indexOf('/api/'); - const apiPath = apiIndex >= 0 ? pathname.slice(apiIndex) : pathname; - - for (const route of routePatterns) { - if (route.method === normalizedMethod && route.pattern.test(apiPath)) { - return route.scope; - } - } - - return null; -} - -export function getContractOAuthScope(method: string, path: string): OAuthScope | null { - const normalizedMethod = method.toLowerCase(); - - return ( - OAUTH_ROUTE_SCOPES.find(route => route.method === normalizedMethod && route.path === path) - ?.scope ?? null - ); -} - -export function hasRequiredScope(granted: readonly string[], required: OAuthScope) { - return granted.includes(required); -} diff --git a/src/lib/oauth/token.test.ts b/src/lib/oauth/token.test.ts deleted file mode 100644 index b326614ac..000000000 --- a/src/lib/oauth/token.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { - consumeAuthorizationCode, - createRefreshToken, - getRefreshTokenByHash, - revokeRefreshToken, - revokeRefreshTokensForClient, - touchRefreshToken, -} from '@/queries/prisma/oauth'; -import { getUser } from '@/queries/prisma/user'; -import { computeCodeChallenge } from './pkce'; -import { handleRevocationRequest, handleTokenRequest } from './token'; -import { generateRefreshToken, hashToken, verifyAccessToken } from './tokens'; - -const { transactionClient } = vi.hoisted(() => ({ transactionClient: {} })); - -vi.mock('@/lib/prisma', () => ({ - default: { - transaction: vi.fn(async callback => callback(transactionClient)), - }, -})); - -vi.mock('@/queries/prisma/oauth', () => ({ - consumeAuthorizationCode: vi.fn(), - createRefreshToken: vi.fn(), - getRefreshTokenByHash: vi.fn(), - revokeRefreshToken: vi.fn(), - revokeRefreshTokensForClient: vi.fn(), - touchRefreshToken: vi.fn(), -})); - -vi.mock('@/queries/prisma/user', () => ({ - getUser: vi.fn(), -})); - -const consumeAuthorizationCodeMock = vi.mocked(consumeAuthorizationCode); -const createRefreshTokenMock = vi.mocked(createRefreshToken); -const getRefreshTokenByHashMock = vi.mocked(getRefreshTokenByHash); -const revokeRefreshTokenMock = vi.mocked(revokeRefreshToken); -const revokeRefreshTokensForClientMock = vi.mocked(revokeRefreshTokensForClient); -const touchRefreshTokenMock = vi.mocked(touchRefreshToken); -const getUserMock = vi.mocked(getUser); - -const ISSUER = 'https://analytics.example.com'; -const RESOURCE = `${ISSUER}/mcp`; -const CLIENT_ID = 'https://app.example.com/client.json'; -const VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; -const USER = { id: 'user-1', username: 'admin', role: 'admin', password: 'hashed' }; - -function codeRecord(overrides: Record = {}) { - return { - id: 'code-1', - codeHash: 'h', - userId: USER.id, - clientId: CLIENT_ID, - redirectUri: 'https://app.example.com/cb', - scope: 'analytics:read websites:read', - resource: RESOURCE, - codeChallenge: computeCodeChallenge(VERIFIER), - codeChallengeMethod: 'S256', - expiresAt: new Date(Date.now() + 60_000), - usedAt: new Date(), - createdAt: new Date(), - ...overrides, - } as never; -} - -function grant(overrides: Record = {}) { - return { - grant_type: 'authorization_code', - code: 'the-code', - client_id: CLIENT_ID, - redirect_uri: 'https://app.example.com/cb', - code_verifier: VERIFIER, - resource: RESOURCE, - ...overrides, - }; -} - -beforeEach(() => { - process.env.APP_SECRET = 'test'; - vi.mocked(consumeAuthorizationCodeMock).mockReset(); - createRefreshTokenMock.mockReset(); - createRefreshTokenMock.mockResolvedValue({} as never); - getRefreshTokenByHashMock.mockReset(); - revokeRefreshTokenMock.mockReset(); - revokeRefreshTokenMock.mockResolvedValue(1); - revokeRefreshTokensForClientMock.mockReset(); - touchRefreshTokenMock.mockReset(); - touchRefreshTokenMock.mockResolvedValue({} as never); - getUserMock.mockReset(); - getUserMock.mockResolvedValue(USER as never); -}); - -describe('authorization_code grant', () => { - test('issues access and refresh tokens', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: codeRecord(), replayed: null }); - - const tokens = await handleTokenRequest(grant(), ISSUER); - - expect(tokens).toMatchObject({ - token_type: 'Bearer', - expires_in: 3600, - scope: 'analytics:read websites:read', - }); - expect(tokens.refresh_token).toMatch(/^umami_rt_/); - expect(verifyAccessToken(tokens.access_token)).toMatchObject({ - userId: USER.id, - clientId: CLIENT_ID, - resource: RESOURCE, - }); - expect(createRefreshTokenMock).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER.id, - clientId: CLIENT_ID, - tokenHash: hashToken(tokens.refresh_token), - }), - transactionClient, - ); - }); - - test('rejects an invalid PKCE verifier', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: codeRecord(), replayed: null }); - - await expect( - handleTokenRequest(grant({ code_verifier: `${VERIFIER.slice(0, -1)}x` }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_grant', message: /PKCE/ }); - expect(createRefreshTokenMock).not.toHaveBeenCalled(); - }); - - test('rejects client and redirect_uri mismatches', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: codeRecord(), replayed: null }); - await expect( - handleTokenRequest(grant({ client_id: 'https://other.example/c.json' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_grant', message: /client_id/ }); - - consumeAuthorizationCodeMock.mockResolvedValue({ code: codeRecord(), replayed: null }); - await expect( - handleTokenRequest(grant({ redirect_uri: 'https://app.example.com/other' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_grant', message: /redirect_uri/ }); - }); - - test('rejects expired or unknown codes', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: null, replayed: null }); - - await expect(handleTokenRequest(grant(), ISSUER)).rejects.toMatchObject({ - code: 'invalid_grant', - }); - expect(revokeRefreshTokensForClientMock).not.toHaveBeenCalled(); - }); - - test('revokes the grant family when a code is replayed', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: null, replayed: codeRecord() }); - - await expect(handleTokenRequest(grant(), ISSUER)).rejects.toMatchObject({ - code: 'invalid_grant', - }); - expect(revokeRefreshTokensForClientMock).toHaveBeenCalledWith( - USER.id, - CLIENT_ID, - transactionClient, - ); - }); - - test('rejects a mismatched resource indicator', async () => { - consumeAuthorizationCodeMock.mockResolvedValue({ code: codeRecord(), replayed: null }); - - await expect( - handleTokenRequest(grant({ resource: 'https://other.example/mcp' }), ISSUER), - ).rejects.toMatchObject({ code: 'invalid_target' }); - }); - - test('requires the required parameters', async () => { - await expect( - handleTokenRequest({ grant_type: 'authorization_code' }, ISSUER), - ).rejects.toMatchObject({ code: 'invalid_request' }); - await expect(handleTokenRequest({ grant_type: 'password' }, ISSUER)).rejects.toMatchObject({ - code: 'unsupported_grant_type', - }); - }); -}); - -describe('refresh_token grant', () => { - function refreshRecord(overrides: Record = {}) { - return { - id: 'rt-1', - tokenHash: 'h', - userId: USER.id, - clientId: CLIENT_ID, - scope: 'analytics:read websites:read', - resource: RESOURCE, - expiresAt: new Date(Date.now() + 60_000), - revokedAt: null, - lastUsedAt: null, - createdAt: new Date(), - ...overrides, - } as never; - } - - test('rotates the refresh token', async () => { - const presented = generateRefreshToken(); - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord()); - - const tokens = await handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: presented, client_id: CLIENT_ID }, - ISSUER, - ); - - expect(getRefreshTokenByHashMock).toHaveBeenCalledWith(hashToken(presented)); - expect(revokeRefreshTokenMock).toHaveBeenCalledWith('rt-1', transactionClient); - expect(tokens.refresh_token).not.toBe(presented); - expect(tokens.scope).toBe('analytics:read websites:read'); - }); - - test('allows narrowing but not widening scope', async () => { - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord({ scope: 'websites:read' })); - - await expect( - handleTokenRequest( - { - grant_type: 'refresh_token', - refresh_token: generateRefreshToken(), - scope: 'analytics:read', - }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_scope' }); - - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord()); - const narrowed = await handleTokenRequest( - { - grant_type: 'refresh_token', - refresh_token: generateRefreshToken(), - scope: 'websites:read', - }, - ISSUER, - ); - - expect(narrowed.scope).toBe('websites:read'); - }); - - test('rejects revoked, expired, unknown and concurrently-used tokens', async () => { - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord({ revokedAt: new Date() })); - await expect( - handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: generateRefreshToken() }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_grant' }); - - getRefreshTokenByHashMock.mockResolvedValue( - refreshRecord({ expiresAt: new Date(Date.now() - 1) }), - ); - await expect( - handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: generateRefreshToken() }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_grant' }); - - getRefreshTokenByHashMock.mockResolvedValue(null); - await expect( - handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: generateRefreshToken() }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_grant' }); - - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord()); - revokeRefreshTokenMock.mockResolvedValue(0); - await expect( - handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: generateRefreshToken() }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_grant', message: /already been used/ }); - }); - - test('rejects a client mismatch', async () => { - getRefreshTokenByHashMock.mockResolvedValue(refreshRecord()); - - await expect( - handleTokenRequest( - { grant_type: 'refresh_token', refresh_token: generateRefreshToken(), client_id: 'other' }, - ISSUER, - ), - ).rejects.toMatchObject({ code: 'invalid_grant' }); - }); -}); - -describe('revocation', () => { - test('revokes known refresh tokens and ignores everything else', async () => { - getRefreshTokenByHashMock.mockResolvedValue({ id: 'rt-1', revokedAt: null } as never); - await handleRevocationRequest({ token: generateRefreshToken() }); - expect(revokeRefreshTokenMock).toHaveBeenCalledWith('rt-1'); - - revokeRefreshTokenMock.mockClear(); - await handleRevocationRequest({ token: 'not-a-refresh-token' }); - await handleRevocationRequest({ token: undefined }); - expect(revokeRefreshTokenMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/oauth/token.ts b/src/lib/oauth/token.ts deleted file mode 100644 index 98d172b81..000000000 --- a/src/lib/oauth/token.ts +++ /dev/null @@ -1,272 +0,0 @@ -import type { Prisma } from '@/generated/prisma/client'; -import { uuid } from '@/lib/crypto'; -import { getMcpResource, normalizeResource, REFRESH_TOKEN_TTL_SECONDS } from '@/lib/oauth/config'; -import { OAuthError } from '@/lib/oauth/errors'; -import { verifyPkce } from '@/lib/oauth/pkce'; -import { normalizeScopes, parseScopes } from '@/lib/oauth/scopes'; -import { - createAccessToken, - generateRefreshToken, - hashToken, - isRefreshToken, -} from '@/lib/oauth/tokens'; -import prisma from '@/lib/prisma'; -import { - consumeAuthorizationCode, - createRefreshToken, - getRefreshTokenByHash, - revokeRefreshToken, - revokeRefreshTokensForClient, - touchRefreshToken, -} from '@/queries/prisma/oauth'; -import { getUser } from '@/queries/prisma/user'; - -const transaction = prisma.transaction as ( - callback: (tx: Prisma.TransactionClient) => Promise, -) => Promise; - -export interface TokenRequestParams { - grant_type?: unknown; - code?: unknown; - redirect_uri?: unknown; - client_id?: unknown; - code_verifier?: unknown; - refresh_token?: unknown; - scope?: unknown; - resource?: unknown; -} - -export interface TokenResponse { - access_token: string; - token_type: 'Bearer'; - expires_in: number; - refresh_token: string; - scope: string; -} - -function requireString(value: unknown, name: string, max = 4096) { - if (typeof value !== 'string' || !value || value.length > max) { - throw new OAuthError('invalid_request', `${name} is required.`); - } - - return value; -} - -async function loadUser(userId: string) { - const user = (await getUser(userId, { includePassword: true })) as { - id: string; - password?: string; - deletedAt?: Date | null; - } | null; - - if (!user?.id || user.deletedAt) { - throw new OAuthError('invalid_grant', 'The user account is no longer available.'); - } - - return user; -} - -async function issueTokens( - options: { - userId: string; - clientId: string; - scopes: string[]; - resource: string; - issuer: string; - passwordHash?: string; - }, - tx: Prisma.TransactionClient, -): Promise { - const scopes = normalizeScopes(options.scopes); - const access = createAccessToken({ - userId: options.userId, - clientId: options.clientId, - scopes, - issuer: options.issuer, - resource: options.resource, - passwordHash: options.passwordHash, - }); - const refreshToken = generateRefreshToken(); - - await createRefreshToken( - { - id: uuid(), - tokenHash: hashToken(refreshToken), - userId: options.userId, - clientId: options.clientId, - scope: scopes.join(' '), - resource: options.resource, - expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_SECONDS * 1000), - }, - tx, - ); - - return { - access_token: access.token, - token_type: 'Bearer', - expires_in: access.expiresIn, - refresh_token: refreshToken, - scope: scopes.join(' '), - }; -} - -export async function handleAuthorizationCodeGrant( - params: TokenRequestParams, - issuer: string, -): Promise { - const code = requireString(params.code, 'code', 512); - const clientId = requireString(params.client_id, 'client_id', 2048); - const codeVerifier = requireString(params.code_verifier, 'code_verifier', 128); - const result = await transaction(async tx => { - const { code: record, replayed } = await consumeAuthorizationCode(hashToken(code), tx); - - if (!record) { - if (replayed) { - // Replay of a redeemed code: revoke everything that code produced (OAuth 2.1 §4.1.2). - await revokeRefreshTokensForClient(replayed.userId, replayed.clientId, tx); - } - - // Return the error so replay revocations commit before the request is rejected. - return new OAuthError( - 'invalid_grant', - 'Authorization code is invalid, expired or already used.', - ); - } - - if (record.clientId !== clientId) { - throw new OAuthError('invalid_grant', 'client_id does not match the authorization code.'); - } - - const redirectUri = typeof params.redirect_uri === 'string' ? params.redirect_uri : undefined; - - if (redirectUri !== undefined && redirectUri !== record.redirectUri) { - throw new OAuthError( - 'invalid_grant', - 'redirect_uri does not match the authorization request.', - ); - } - - if (!verifyPkce(codeVerifier, record.codeChallenge, record.codeChallengeMethod)) { - throw new OAuthError('invalid_grant', 'PKCE verification failed.'); - } - - const expectedResource = record.resource ?? getMcpResource(issuer); - const resourceParam = - typeof params.resource === 'string' ? normalizeResource(params.resource) : null; - - if (params.resource !== undefined && resourceParam !== expectedResource) { - throw new OAuthError('invalid_target', `resource must be ${expectedResource}.`); - } - - const user = await loadUser(record.userId); - - return issueTokens( - { - userId: user.id, - clientId: record.clientId, - scopes: parseScopes(record.scope), - resource: expectedResource, - issuer, - passwordHash: user.password, - }, - tx, - ); - }); - - if (result instanceof OAuthError) { - throw result; - } - - return result; -} - -export async function handleRefreshTokenGrant( - params: TokenRequestParams, - issuer: string, -): Promise { - const refreshToken = requireString(params.refresh_token, 'refresh_token', 512); - - if (!isRefreshToken(refreshToken)) { - throw new OAuthError('invalid_grant', 'Refresh token is invalid.'); - } - - const record = await getRefreshTokenByHash(hashToken(refreshToken)); - - if (!record || record.revokedAt || record.expiresAt.getTime() <= Date.now()) { - throw new OAuthError('invalid_grant', 'Refresh token is invalid, expired or revoked.'); - } - - if (params.client_id !== undefined && params.client_id !== record.clientId) { - throw new OAuthError('invalid_grant', 'client_id does not match the refresh token.'); - } - - const grantedScopes = normalizeScopes(parseScopes(record.scope)); - const requestedScopes = typeof params.scope === 'string' ? parseScopes(params.scope) : []; - const narrowed = requestedScopes.length ? normalizeScopes(requestedScopes) : grantedScopes; - - if (narrowed.some(scope => !grantedScopes.includes(scope))) { - throw new OAuthError('invalid_scope', 'Requested scope exceeds the granted scope.'); - } - - const expectedResource = record.resource ?? getMcpResource(issuer); - - if (params.resource !== undefined) { - const resourceParam = - typeof params.resource === 'string' ? normalizeResource(params.resource) : null; - - if (resourceParam !== expectedResource) { - throw new OAuthError('invalid_target', `resource must be ${expectedResource}.`); - } - } - - const user = await loadUser(record.userId); - - // Rotate: the presented refresh token is retired and a new one issued (OAuth 2.1 §4.3.1). - return transaction(async tx => { - const revoked = await revokeRefreshToken(record.id, tx); - - if (!revoked) { - throw new OAuthError('invalid_grant', 'Refresh token has already been used.'); - } - - await touchRefreshToken(record.id, tx); - - return issueTokens( - { - userId: user.id, - clientId: record.clientId, - scopes: narrowed, - resource: expectedResource, - issuer, - passwordHash: user.password, - }, - tx, - ); - }); -} - -export async function handleTokenRequest(params: TokenRequestParams, issuer: string) { - switch (params.grant_type) { - case 'authorization_code': - return handleAuthorizationCodeGrant(params, issuer); - case 'refresh_token': - return handleRefreshTokenGrant(params, issuer); - default: - throw new OAuthError( - 'unsupported_grant_type', - 'grant_type must be "authorization_code" or "refresh_token".', - ); - } -} - -export async function handleRevocationRequest(params: { token?: unknown }) { - // RFC 7009: unknown tokens are silently accepted. Only refresh tokens are revocable; - // access tokens are short-lived and expire on their own. - if (isRefreshToken(params.token)) { - const record = await getRefreshTokenByHash(hashToken(params.token)); - - if (record && !record.revokedAt) { - await revokeRefreshToken(record.id); - } - } -} diff --git a/src/lib/oauth/tokens.test.ts b/src/lib/oauth/tokens.test.ts deleted file mode 100644 index 3becd6a90..000000000 --- a/src/lib/oauth/tokens.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { beforeAll, describe, expect, test } from 'vitest'; -import { - createAccessToken, - generateRefreshToken, - hashToken, - isRefreshToken, - looksLikeAccessToken, - verifyAccessToken, -} from './tokens'; - -const ISSUER = 'https://analytics.example.com'; -const RESOURCE = `${ISSUER}/mcp`; - -beforeAll(() => { - process.env.APP_SECRET = 'oauth-test-secret'; -}); - -describe('access tokens', () => { - test('round-trips claims and binds the audience to the issuer', () => { - const { token, jti, expiresIn } = createAccessToken({ - userId: 'user-1', - clientId: 'https://client.example/metadata.json', - scopes: ['websites:read', 'analytics:read', 'bogus'], - issuer: ISSUER, - resource: RESOURCE, - passwordHash: 'pw', - }); - - expect(expiresIn).toBe(3600); - expect(looksLikeAccessToken(token)).toBe(true); - - const verified = verifyAccessToken(token); - - expect(verified).toMatchObject({ - userId: 'user-1', - clientId: 'https://client.example/metadata.json', - scopes: ['analytics:read', 'websites:read'], - issuer: ISSUER, - resource: RESOURCE, - tokenId: jti, - }); - expect(verified?.passwordFingerprint).toBeDefined(); - expect(verified?.expiresAt).toBeGreaterThan(Date.now()); - }); - - test('rejects tokens whose audience is not the issuer MCP resource', () => { - const { token } = createAccessToken({ - userId: 'user-1', - clientId: 'c', - scopes: ['websites:read'], - issuer: ISSUER, - resource: 'https://other.example/mcp', - }); - - expect(verifyAccessToken(token)).toBeNull(); - }); - - test('rejects expired tokens', () => { - const { token } = createAccessToken({ - userId: 'user-1', - clientId: 'c', - scopes: ['websites:read'], - issuer: ISSUER, - resource: RESOURCE, - expiresInSeconds: -10, - }); - - expect(verifyAccessToken(token)).toBeNull(); - }); - - test('rejects tampered and foreign tokens', () => { - const { token } = createAccessToken({ - userId: 'user-1', - clientId: 'c', - scopes: ['websites:read'], - issuer: ISSUER, - resource: RESOURCE, - }); - const [header, payload] = token.split('.'); - - expect(verifyAccessToken(`${header}.${payload}.invalid`)).toBeNull(); - expect(looksLikeAccessToken('umami_abc')).toBe(false); - expect(looksLikeAccessToken('a.b')).toBe(false); - }); -}); - -describe('refresh tokens', () => { - test('generates prefixed tokens and stable hashes', () => { - const token = generateRefreshToken(); - - expect(isRefreshToken(token)).toBe(true); - expect(isRefreshToken('nope')).toBe(false); - expect(hashToken(token)).toBe(hashToken(token)); - expect(hashToken(token)).not.toContain(token); - }); -}); diff --git a/src/lib/oauth/tokens.ts b/src/lib/oauth/tokens.ts deleted file mode 100644 index 0ca0457b4..000000000 --- a/src/lib/oauth/tokens.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { hash, secret, uuid } from '@/lib/crypto'; -import { createToken, parseToken } from '@/lib/jwt'; -import { - ACCESS_TOKEN_TTL_SECONDS, - getMcpResource, - OAUTH_ACCESS_TOKEN_TYPE, - REFRESH_TOKEN_PREFIX, -} from '@/lib/oauth/config'; -import { generateSecret } from '@/lib/oauth/pkce'; -import { normalizeScopes, type OAuthScope } from '@/lib/oauth/scopes'; - -export interface AccessTokenClaims { - /** User ID. */ - sub: string; - iss: string; - aud: string; - client_id: string; - scope: string; - type: typeof OAUTH_ACCESS_TOKEN_TYPE; - jti: string; - /** Password fingerprint so that a password change invalidates outstanding tokens. */ - pwd?: string; - iat: number; - exp: number; -} - -export interface CreateAccessTokenOptions { - userId: string; - clientId: string; - scopes: readonly string[]; - issuer: string; - resource: string; - passwordHash?: string; - expiresInSeconds?: number; -} - -export function createAccessToken(options: CreateAccessTokenOptions) { - const expiresIn = options.expiresInSeconds ?? ACCESS_TOKEN_TTL_SECONDS; - const jti = uuid(); - const token = createToken( - { - sub: options.userId, - iss: options.issuer, - aud: options.resource, - client_id: options.clientId, - scope: normalizeScopes([...options.scopes]).join(' '), - type: OAUTH_ACCESS_TOKEN_TYPE, - jti, - ...(options.passwordHash ? { pwd: hash(options.passwordHash) } : {}), - }, - secret(), - { expiresIn }, - ) as string; - - return { token, jti, expiresIn }; -} - -export interface VerifiedAccessToken { - userId: string; - clientId: string; - scopes: OAuthScope[]; - issuer: string; - resource: string; - tokenId: string; - passwordFingerprint?: string; - expiresAt: number; -} - -/** - * Cheap structural check so that ordinary session tokens (encrypted, not plain JWTs) and API - * keys can skip OAuth verification entirely. - */ -export function looksLikeAccessToken(token?: string | null): token is string { - if (!token || token.split('.').length !== 3) { - return false; - } - - try { - const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')); - - return payload?.type === OAUTH_ACCESS_TOKEN_TYPE; - } catch { - return false; - } -} - -/** - * Verifies signature, expiry, token type and audience binding. The audience must be the MCP - * resource of the issuer that minted the token, so a token can never be replayed against a - * different Umami deployment sharing the same secret. - */ -export function verifyAccessToken(token: string): VerifiedAccessToken | null { - const payload = parseToken(token, secret()) as Partial | null; - - if ( - !payload || - payload.type !== OAUTH_ACCESS_TOKEN_TYPE || - typeof payload.sub !== 'string' || - typeof payload.client_id !== 'string' || - typeof payload.iss !== 'string' || - typeof payload.aud !== 'string' || - typeof payload.jti !== 'string' || - typeof payload.exp !== 'number' - ) { - return null; - } - - if (payload.aud !== getMcpResource(payload.iss)) { - return null; - } - - return { - userId: payload.sub, - clientId: payload.client_id, - scopes: normalizeScopes((payload.scope ?? '').split(' ')), - issuer: payload.iss, - resource: payload.aud, - tokenId: payload.jti, - passwordFingerprint: payload.pwd, - expiresAt: payload.exp * 1000, - }; -} - -export function generateRefreshToken() { - return `${REFRESH_TOKEN_PREFIX}${generateSecret(32)}`; -} - -export function isRefreshToken(value: unknown): value is string { - return typeof value === 'string' && value.startsWith(REFRESH_TOKEN_PREFIX); -} - -export function hashToken(value: string) { - return hash(value); -} - -export function generateAuthorizationCode() { - return generateSecret(32); -} diff --git a/src/lib/oauth/verify.test.ts b/src/lib/oauth/verify.test.ts deleted file mode 100644 index ebcb5847b..000000000 --- a/src/lib/oauth/verify.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { hash } from '@/lib/crypto'; -import { getUser } from '@/queries/prisma/user'; -import { createAccessToken } from './tokens'; -import { verifyOAuthRequest } from './verify'; - -vi.mock('@/queries/prisma/user', () => ({ - getUser: vi.fn(), -})); - -const getUserMock = vi.mocked(getUser); -const ISSUER = 'https://analytics.example.com'; -const USER = { id: 'user-1', username: 'admin', role: 'user', password: 'pw-hash' }; - -function token(scopes: string[], overrides: Record = {}) { - return createAccessToken({ - userId: USER.id, - clientId: 'https://app.example.com/client.json', - scopes, - issuer: ISSUER, - resource: `${ISSUER}/mcp`, - passwordHash: USER.password, - ...overrides, - }).token; -} - -beforeEach(() => { - process.env.APP_SECRET = 'test'; - delete process.env.OAUTH_DISABLED; - getUserMock.mockReset(); - getUserMock.mockResolvedValue({ ...USER } as never); -}); - -describe('verifyOAuthRequest', () => { - test('ignores non-OAuth tokens', async () => { - await expect(verifyOAuthRequest('umami_key', 'GET', '/api/websites')).resolves.toEqual({ - status: 'not-oauth', - }); - await expect(verifyOAuthRequest(undefined, 'GET', '/api/websites')).resolves.toEqual({ - status: 'not-oauth', - }); - }); - - test('builds an OAuth auth context on an allowlisted route', async () => { - const result = await verifyOAuthRequest( - token(['websites:read', 'analytics:read']), - 'GET', - '/api/websites/abc/stats', - ); - - expect(result.status).toBe('ok'); - - if (result.status === 'ok') { - expect(result.auth.user).toMatchObject({ id: USER.id, isAdmin: false }); - expect(result.auth.user).not.toHaveProperty('password'); - expect(result.auth.authType).toBe('oauth'); - expect(result.auth.oauth).toMatchObject({ - clientId: 'https://app.example.com/client.json', - scopes: ['analytics:read', 'websites:read'], - }); - } - }); - - test('rejects routes that did not opt in to OAuth', async () => { - await expect( - verifyOAuthRequest(token(['websites:read', 'analytics:read']), 'POST', '/api/websites'), - ).resolves.toMatchObject({ status: 'forbidden', reason: 'route-not-allowed' }); - await expect( - verifyOAuthRequest(token(['websites:read', 'analytics:read']), 'GET', '/api/me/api-keys'), - ).resolves.toMatchObject({ status: 'forbidden', reason: 'route-not-allowed' }); - await expect( - verifyOAuthRequest(token(['websites:read', 'analytics:read']), 'DELETE', '/api/websites/abc'), - ).resolves.toMatchObject({ status: 'forbidden' }); - }); - - test('rejects insufficient scope', async () => { - await expect( - verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites/abc/stats'), - ).resolves.toMatchObject({ - status: 'forbidden', - reason: 'insufficient-scope', - requiredScope: 'analytics:read', - }); - }); - - test('rejects tokens after a password change or when the user is gone', async () => { - getUserMock.mockResolvedValue({ ...USER, password: 'changed' } as never); - await expect( - verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites'), - ).resolves.toEqual({ status: 'invalid' }); - - getUserMock.mockResolvedValue(null as never); - await expect( - verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites'), - ).resolves.toEqual({ status: 'invalid' }); - }); - - test('rejects everything when OAuth is disabled', async () => { - process.env.OAUTH_DISABLED = '1'; - - await expect( - verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites'), - ).resolves.toEqual({ status: 'invalid' }); - }); - - test('password fingerprint uses the hashed password', () => { - expect(hash('pw-hash')).not.toBe('pw-hash'); - }); -}); diff --git a/src/lib/oauth/verify.ts b/src/lib/oauth/verify.ts deleted file mode 100644 index a0f82602d..000000000 --- a/src/lib/oauth/verify.ts +++ /dev/null @@ -1,95 +0,0 @@ -import debug from 'debug'; -import { ROLES } from '@/lib/constants'; -import { hash } from '@/lib/crypto'; -import { isOAuthEnabled } from '@/lib/oauth/config'; -import { getRouteOAuthScope, hasRequiredScope } from '@/lib/oauth/scopes'; -import { - looksLikeAccessToken, - type VerifiedAccessToken, - verifyAccessToken, -} from '@/lib/oauth/tokens'; -import type { Auth } from '@/lib/types'; -import { getUser } from '@/queries/prisma/user'; - -const log = debug('umami:oauth'); - -export type OAuthAuthResult = - | { status: 'not-oauth' } - | { status: 'invalid' } - | { - status: 'forbidden'; - reason: 'route-not-allowed' | 'insufficient-scope'; - requiredScope?: string; - } - | { status: 'ok'; auth: Auth & { token: string } }; - -/** - * Resolves an OAuth access token into an ordinary Umami `Auth` context. - * - * OAuth tokens are only accepted on routes listed in `OAUTH_ROUTE_SCOPES` and only when the - * token carries the scope that route requires. The resulting user context is subject to all - * normal Umami permission checks; scopes never widen access. - */ -export async function verifyOAuthRequest( - token: string | undefined, - method: string, - pathname: string, -): Promise { - if (!looksLikeAccessToken(token)) { - return { status: 'not-oauth' }; - } - - if (!isOAuthEnabled()) { - return { status: 'invalid' }; - } - - const verified = verifyAccessToken(token); - - if (!verified) { - log('invalid OAuth access token'); - return { status: 'invalid' }; - } - - const requiredScope = getRouteOAuthScope(method, pathname); - - if (!requiredScope) { - log('OAuth token used on non-allowlisted route', method, pathname); - return { status: 'forbidden', reason: 'route-not-allowed' }; - } - - if (!hasRequiredScope(verified.scopes, requiredScope)) { - return { status: 'forbidden', reason: 'insufficient-scope', requiredScope }; - } - - const auth = await buildOAuthAuth(token, verified); - - return auth ? { status: 'ok', auth } : { status: 'invalid' }; -} - -export async function buildOAuthAuth(token: string, verified: VerifiedAccessToken) { - const user: any = await getUser(verified.userId, { includePassword: true }); - - if (!user?.id || user.deletedAt) { - log('OAuth token user not found'); - return null; - } - - if (verified.passwordFingerprint && hash(user.password) !== verified.passwordFingerprint) { - log('OAuth token issued before password change'); - return null; - } - - delete user.password; - user.isAdmin = user.role === ROLES.admin; - - return { - token, - user, - authType: 'oauth' as const, - oauth: { - clientId: verified.clientId, - scopes: verified.scopes, - tokenId: verified.tokenId, - }, - }; -} diff --git a/src/lib/types.ts b/src/lib/types.ts index be155c7b5..93c97fbe0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,7 +12,7 @@ export type Role = ObjectValues; export type DynamicDataType = ObjectValues; export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS]; -export type AuthType = 'session' | 'api-key' | 'oauth' | 'share'; +export type AuthType = 'session' | 'api-key' | 'share'; export interface Auth { user?: { @@ -27,12 +27,6 @@ export interface Auth { }; /** How the request was authenticated. Undefined for legacy callers that build Auth manually. */ authType?: AuthType; - /** OAuth details when `authType` is `oauth`. Scopes never expand Umami permissions. */ - oauth?: { - clientId: string; - scopes: string[]; - tokenId?: string; - }; shareToken?: { shareType?: number; websiteId?: string; diff --git a/src/openapi/README.md b/src/openapi/README.md index 0fd613703..5c7bc605f 100644 --- a/src/openapi/README.md +++ b/src/openapi/README.md @@ -86,14 +86,6 @@ Operation IDs are public: they become method names in `@umami/api-client`. Route overrides the mechanical path-based name (`getWebsitesWebsiteIdStats` -> `getWebsiteStats`). Never rename an operation ID without a migration note for the client. -## OAuth scopes - -`src/lib/oauth/scopes.ts` is the single allowlist of routes that accept OAuth access tokens and -the scope each requires. The document builder adds `x-umami-oauth-scope` and an `oauth2` security -requirement to those operations, and fails when an allowlist entry does not match a real route. -The same list is enforced at runtime by `checkAuth`, and mirrored by the in-process MCP dispatch -table (`src/lib/mcp/dispatch.ts`). - ## Generated API client `pnpm generate:api` regenerates the OpenAPI document and `packages/api-client/src/generated`. diff --git a/src/openapi/document.ts b/src/openapi/document.ts index 94da40cb8..f048faf59 100644 --- a/src/openapi/document.ts +++ b/src/openapi/document.ts @@ -1,7 +1,6 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { createDocument, type ZodOpenApiPathsObject } from 'zod-openapi'; -import { getContractOAuthScope, OAUTH_ROUTE_SCOPES } from '@/lib/oauth/scopes'; import { loadApiContracts } from '@/openapi/contracts'; import { analyzeContractCoverage, @@ -11,7 +10,7 @@ import { import { discoverApiOperations } from '@/openapi/discover'; import { applyFieldDescriptions } from '@/openapi/field-descriptions'; import { inferApiContracts } from '@/openapi/infer'; -import { type ApiAudience, type ApiOperationContract, getOperationKey } from '@/openapi/operation'; +import { type ApiAudience, getOperationKey } from '@/openapi/operation'; import { operationDescriptions } from '@/openapi/operation-descriptions'; import { errorResponseComponents } from '@/openapi/schemas'; import { getSecurityRequirements, securitySchemes } from '@/openapi/security'; @@ -31,19 +30,6 @@ async function getPackageVersion(projectRoot: string) { return packageJson.version; } -/** - * Every OAuth-allowlisted route must correspond to a real API route, otherwise the runtime - * allowlist and the published contract silently drift apart. - */ -function getOAuthRouteErrors(discovered: Pick[]) { - const discoveredKeys = new Set(discovered.map(getOperationKey)); - - return OAUTH_ROUTE_SCOPES.filter(route => !discoveredKeys.has(getOperationKey(route))).map( - route => - `OAuth scope allowlist entry does not match a route: ${getOperationKey(route)} (src/lib/oauth/scopes.ts)`, - ); -} - function getDocumentTags(paths: ZodOpenApiPathsObject) { const names = new Set(); @@ -79,11 +65,7 @@ export async function buildOpenApiDocument( key => `Operation description does not match a route: ${key} (src/openapi/operation-descriptions.ts)`, ); - const errors = [ - ...getCoverageErrors(coverage), - ...getOAuthRouteErrors(discovered), - ...descriptionErrors, - ]; + const errors = [...getCoverageErrors(coverage), ...descriptionErrors]; if (errors.length) { throw new Error(`Invalid OpenAPI contracts:\n${errors.map(error => `- ${error}`).join('\n')}`); @@ -104,15 +86,13 @@ export async function buildOpenApiDocument( paths[contract.path] = pathItem; } - const scope = getContractOAuthScope(contract.method, contract.path); const operation = { ...contract.operation, ...operationDescriptions[getOperationKey(contract)], - security: getSecurityRequirements(contract.auth, scope), + security: getSecurityRequirements(contract.auth), 'x-umami-audience': contract.audience, 'x-umami-contract': contract.origin, 'x-umami-source': contract.source, - ...(scope ? { 'x-umami-oauth-scope': scope } : {}), }; Object.assign(pathItem, { [contract.method]: operation }); diff --git a/src/openapi/field-descriptions.ts b/src/openapi/field-descriptions.ts index 782a4d7d7..c390a166a 100644 --- a/src/openapi/field-descriptions.ts +++ b/src/openapi/field-descriptions.ts @@ -167,29 +167,6 @@ const fieldDescriptions: Record = { error: 'Error details returned when the operation fails.', message: 'Human-readable explanation of the result.', ok: 'Whether the operation succeeded.', - client_id: 'OAuth client identifier.', - client_name: 'Human-readable name of the OAuth client.', - client_uri: 'URL of the OAuth client homepage.', - logo_uri: 'URL of the OAuth client logo.', - redirect_uri: 'Registered redirect URI to receive the authorization response.', - redirect_uris: 'Redirect URIs registered for the OAuth client.', - response_type: 'OAuth authorization response type; use code.', - response_types: 'Authorization response types supported by the client.', - code_challenge: 'PKCE challenge derived from the code verifier.', - code_challenge_method: 'Method used to derive the PKCE challenge; use S256.', - code_verifier: 'Original PKCE verifier used to obtain the authorization code.', - scope: 'Space-separated OAuth permissions.', - resource: 'Resource URI identifying the API targeted by the OAuth request.', - state: 'Client-provided value returned unchanged to correlate the authorization response.', - access_token: 'OAuth access token used to authorize API requests.', - refresh_token: 'OAuth refresh token used to obtain a new access token.', - token_type: 'Authorization scheme for the access token.', - expires_in: 'Access token lifetime in seconds.', - grant_type: 'OAuth grant used to obtain or refresh an access token.', - grant_types: 'OAuth grant types supported by the client.', - token_endpoint_auth_method: 'Client authentication method used at the token endpoint.', - token_type_hint: 'Hint indicating whether the revoked token is an access or refresh token.', - error_description: 'Human-readable explanation of the OAuth error.', shareType: 'Type of resource made available by the share.', }; diff --git a/src/openapi/operation-descriptions.ts b/src/openapi/operation-descriptions.ts index 6bbde724b..d9c25b924 100644 --- a/src/openapi/operation-descriptions.ts +++ b/src/openapi/operation-descriptions.ts @@ -232,31 +232,6 @@ export const operationDescriptions: Record = { description: "Returns a paginated list of the current user's websites, optionally including websites accessible through team membership.", }, - 'GET /api/oauth/authorize': { - summary: 'Get OAuth authorization request details', - description: - 'Validates an OAuth authorization request and returns the client and requested permissions for the consent screen. Requires an interactive user session.', - }, - 'POST /api/oauth/authorize': { - summary: 'Approve or deny OAuth access', - description: - "Records the user's consent decision and returns a redirect URL. Approval includes an authorization code, the request state, and the issuer.", - }, - 'POST /api/oauth/register': { - summary: 'Register an OAuth client', - description: - 'Registers a public OAuth client for clients that do not support Client ID Metadata Documents. Clients that support them should use an HTTPS metadata URL as their client ID.', - }, - 'POST /api/oauth/revoke': { - summary: 'Revoke an OAuth refresh token', - description: - 'Revokes the supplied refresh token. Unknown tokens are accepted without revealing whether they existed.', - }, - 'POST /api/oauth/token': { - summary: 'Get or refresh an OAuth access token', - description: - 'Exchanges an authorization code and PKCE verifier, or a refresh token, for an access token. Accepts form-encoded or JSON requests and rotates refresh tokens on use.', - }, 'GET /api/pixels': { summary: 'List my tracking pixels', description: diff --git a/src/openapi/security.ts b/src/openapi/security.ts index 2a2d67741..02e473e59 100644 --- a/src/openapi/security.ts +++ b/src/openapi/security.ts @@ -1,4 +1,3 @@ -import { OAUTH_SCOPE_DESCRIPTIONS, type OAuthScope } from '@/lib/oauth/scopes'; import type { ApiAuth } from '@/openapi/operation'; export const securitySchemes = { @@ -9,19 +8,6 @@ export const securitySchemes = { description: 'Token returned by POST /api/auth/login, or an API key (`umami_…`) created under Settings → API keys (self-hosted only).', }, - oauth2: { - type: 'oauth2' as const, - description: - 'OAuth 2.1 access token (authorization code + PKCE). Only operations that declare an OAuth scope accept these tokens.', - flows: { - authorizationCode: { - authorizationUrl: '/oauth/authorize', - tokenUrl: '/api/oauth/token', - refreshUrl: '/api/oauth/token', - scopes: OAUTH_SCOPE_DESCRIPTIONS, - }, - }, - }, shareToken: { type: 'apiKey' as const, in: 'header' as const, @@ -36,17 +22,13 @@ export const securitySchemes = { }, }; -export function getSecurityRequirements(auth: ApiAuth, scope?: OAuthScope | null) { +export function getSecurityRequirements(auth: ApiAuth) { if (auth === 'none') { return []; } const requirements: Record[] = [{ bearerAuth: [] }]; - if (scope) { - requirements.push({ oauth2: [scope] }); - } - if (auth === 'bearer-or-share') { requirements.push({ shareToken: [], shareContext: [] }); } diff --git a/src/queries/prisma/oauth.ts b/src/queries/prisma/oauth.ts deleted file mode 100644 index 6f379c37c..000000000 --- a/src/queries/prisma/oauth.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Prisma } from '@/generated/prisma/client'; -import prisma from '@/lib/prisma'; - -type OAuthDatabase = Pick; -// The read-replica extension preserves these delegates, but changes their generic signatures. -const oauthDatabase = prisma.client as OAuthDatabase; - -export async function createOauthClient(data: { - id: string; - name: string; - redirectUris: string[]; - metadata?: Record; -}) { - return prisma.client.oauthClient.create({ - data: { - id: data.id, - name: data.name, - redirectUris: data.redirectUris, - metadata: data.metadata as any, - }, - }); -} - -export async function getOauthClient(id: string) { - return prisma.client.oauthClient.findUnique({ where: { id } }); -} - -export async function createAuthorizationCode(data: { - id: string; - codeHash: string; - userId: string; - clientId: string; - redirectUri: string; - scope: string; - resource?: string | null; - codeChallenge: string; - codeChallengeMethod: string; - expiresAt: Date; -}) { - return prisma.client.oauthAuthorizationCode.create({ data }); -} - -/** - * Atomically marks a code as used and returns it, or `null` when the code is unknown, expired or - * has already been redeemed. Reuse of a code is a replay signal, handled by the caller. - */ -export async function consumeAuthorizationCode( - codeHash: string, - db: OAuthDatabase = oauthDatabase, -) { - const now = new Date(); - const { count } = await db.oauthAuthorizationCode.updateMany({ - where: { codeHash, usedAt: null, expiresAt: { gt: now } }, - data: { usedAt: now }, - }); - - const code = await db.oauthAuthorizationCode.findUnique({ where: { codeHash } }); - - if (!code) { - return { code: null, replayed: null }; - } - - if (count === 1) { - return { code, replayed: null }; - } - - // Code exists but was not consumable: either expired or already redeemed (replay). - return { code: null, replayed: code.usedAt && code.usedAt < new Date() ? code : null }; -} - -export async function createRefreshToken( - data: { - id: string; - tokenHash: string; - userId: string; - clientId: string; - scope: string; - resource?: string | null; - expiresAt: Date; - }, - db: OAuthDatabase = oauthDatabase, -) { - return db.oauthRefreshToken.create({ data }); -} - -export async function getRefreshTokenByHash(tokenHash: string) { - return prisma.client.oauthRefreshToken.findUnique({ where: { tokenHash } }); -} - -export async function revokeRefreshToken(id: string, db: OAuthDatabase = oauthDatabase) { - const { count } = await db.oauthRefreshToken.updateMany({ - where: { id, revokedAt: null }, - data: { revokedAt: new Date() }, - }); - - return count; -} - -export async function revokeRefreshTokensForClient( - userId: string, - clientId: string, - db: OAuthDatabase = oauthDatabase, -) { - const { count } = await db.oauthRefreshToken.updateMany({ - where: { userId, clientId, revokedAt: null }, - data: { revokedAt: new Date() }, - }); - - return count; -} - -export async function touchRefreshToken(id: string, db: OAuthDatabase = oauthDatabase) { - return db.oauthRefreshToken.update({ - where: { id }, - data: { lastUsedAt: new Date() }, - }); -} - -export async function deleteExpiredOauthRecords() { - const now = new Date(); - const [codes, tokens] = await Promise.all([ - prisma.client.oauthAuthorizationCode.deleteMany({ where: { expiresAt: { lt: now } } }), - prisma.client.oauthRefreshToken.deleteMany({ where: { expiresAt: { lt: now } } }), - ]); - - return { codes: codes.count, tokens: tokens.count }; -} diff --git a/tests/api/mcp.spec.ts b/tests/api/mcp.spec.ts new file mode 100644 index 000000000..12006d9b4 --- /dev/null +++ b/tests/api/mcp.spec.ts @@ -0,0 +1,139 @@ +import type { ApiClient } from './client'; +import { expect, test } from './fixtures'; +import { dateRange } from './helpers/dates'; + +const headers = { + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', +}; + +async function rpc(client: ApiClient, method: string, params: Record = {}) { + const legacy = method === 'initialize'; + const response = await client + .with({ + ...headers, + 'mcp-method': method, + ...(typeof params.name === 'string' ? { 'mcp-name': params.name } : {}), + 'mcp-protocol-version': legacy ? '2025-11-25' : '2026-07-28', + }) + .post('/mcp', { + jsonrpc: '2.0', + id: 1, + method, + params: legacy + ? params + : { + ...params, + _meta: { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { + name: 'umami-integration-test', + version: '1.0.0', + }, + 'io.modelcontextprotocol/clientCapabilities': {}, + }, + }, + }); + expect(response.status, response.text).toBe(200); + // The transport may return JSON or an SSE message containing the JSON-RPC response. + const body = + response.body ?? + response.text + .split('\n') + .filter(line => line.startsWith('data:')) + .map(line => JSON.parse(line.slice(5))) + .find(message => message.id === 1); + expect(body?.error).toBeUndefined(); + expect(body?.id).toBe(1); + return body.result; +} + +test.describe('MCP API-key authentication', () => { + test('rejects anonymous, invalid-key and browser-session callers without OAuth discovery', async ({ + api, + tokens, + }) => { + for (const client of [api, api.bearer('umami_invalid'), api.bearer(tokens.admin)]) { + const response = await client.with(headers).post('/mcp', { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }); + expect(response.status).toBe(401); + expect(response.headers['www-authenticate']).toContain('Bearer realm="Umami MCP"'); + expect(response.headers['www-authenticate']).not.toContain('resource_metadata'); + } + }); + + test('initializes, lists read-only tools and queries seeded analytics with an API key', async ({ + apiKey, + seed, + }) => { + const initialized = await rpc(apiKey, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'umami-integration-test', version: '1.0.0' }, + }); + expect(initialized.serverInfo.name).toBeTruthy(); + const { tools } = await rpc(apiKey, 'tools/list'); + expect(tools.map(tool => tool.name)).toContain('list_websites'); + expect(tools.map(tool => tool.name)).toContain('get_website_stats'); + expect(tools.every(tool => tool.annotations?.readOnlyHint === true)).toBe(true); + + const websites = await rpc(apiKey, 'tools/call', { name: 'list_websites', arguments: {} }); + expect(websites.isError).not.toBe(true); + expect(websites.structuredContent.websites.map(website => website.id)).toContain( + seed.website.id, + ); + + const stats = await rpc(apiKey, 'tools/call', { + name: 'get_website_stats', + arguments: { + websiteId: seed.website.id, + startAt: new Date(seed.range.startAt).toISOString(), + endAt: new Date(dateRange(seed).endAt).toISOString(), + }, + }); + expect(stats.isError).not.toBe(true); + expect(stats.structuredContent.current.pageviews).toBeGreaterThanOrEqual( + seed.data.expectedPageviews, + ); + }); + + test('enforces key-owner permissions and rejects a revoked key', async ({ api, user, seed }) => { + const created = await user.post('/api/me/api-keys', { name: 'mcp-integration-permissions' }); + expect(created.status).toBe(200); + const keyClient = api.bearer(created.body.key); + try { + const argumentsFor = (websiteId: string) => ({ + websiteId, + startAt: new Date(seed.range.startAt).toISOString(), + endAt: new Date(dateRange(seed).endAt).toISOString(), + }); + const own = await rpc(keyClient, 'tools/call', { + name: 'get_website_stats', + arguments: argumentsFor(seed.website2.id), + }); + expect(own.isError).not.toBe(true); + expect(own.structuredContent.current.pageviews).toBeGreaterThanOrEqual( + seed.data.expectedWebsite2Pageviews, + ); + const denied = await rpc(keyClient, 'tools/call', { + name: 'get_website_stats', + arguments: argumentsFor(seed.website.id), + }); + expect(denied.isError).toBe(true); + expect(denied.structuredContent.error).toMatchObject({ code: 'access_denied', status: 401 }); + } finally { + expect((await user.del(`/api/me/api-keys/${created.body.id}`)).status).toBe(200); + } + const revoked = await keyClient.with(headers).post('/mcp', { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }); + expect(revoked.status).toBe(401); + }); +});