${icon}
${sanitisedHeading}
@@ -202,15 +229,132 @@ function generateOAuthCallbackPage({
${sanitisedDetails ? `
${sanitisedDetails}
` : ''}
${
autoClose
- ? '
This window will close automatically, or you can close it manually.
'
+ ? `
${escape(
+ i18n.translate('xpack.actions.oauthCallback.page.manualCloseMessage', {
+ defaultMessage: 'You can close this window manually.',
+ })
+ )}
`
: ''
}
+
`;
}
+const GENERIC_OAUTH_ERROR = i18n.translate('xpack.actions.oauthCallback.error.generic', {
+ defaultMessage: 'OAuth authorization failed',
+});
+
+const buildOAuthReturnUrl = (
+ kibanaReturnUrl: string,
+ connectorId: string,
+ status: OAuthAuthorizationStatus,
+ errorMessage?: string
+): string => {
+ const returnUrl = new URL(kibanaReturnUrl);
+ returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.AUTHORIZATION_STATUS, status);
+ returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.CONNECTOR_ID, connectorId);
+ if (errorMessage) {
+ returnUrl.searchParams.set(OAUTH_CALLBACK_QUERY_PARAMS.ERROR, errorMessage);
+ }
+ return returnUrl.toString();
+};
+
+/**
+ * Returns an OAuth error response. Redirects when `returnUrl` and `connectorId`
+ * are set; otherwise renders an HTML callback page.
+ *
+ * @param res - Kibana response factory
+ * @param options.details - Error details
+ * @param options.connectorId - Connector ID; enables auto-close
+ * @param options.returnUrl - When set (with connectorId), triggers a redirect instead
+ */
+const respondWithError = (
+ res: KibanaResponseFactory,
+ { details, connectorId, returnUrl }: RespondWithErrorOptions
+) => {
+ if (returnUrl) {
+ return res.redirected({
+ headers: {
+ location: buildOAuthReturnUrl(
+ returnUrl,
+ connectorId,
+ OAuthAuthorizationStatus.Error,
+ details
+ ),
+ },
+ });
+ }
+ return res.ok({
+ headers: { 'content-type': 'text/html' },
+ body: generateOAuthCallbackPage({
+ title: i18n.translate('xpack.actions.oauthCallback.page.errorTitle', {
+ defaultMessage: 'OAuth Authorization Failed',
+ }),
+ heading: i18n.translate('xpack.actions.oauthCallback.page.errorHeading', {
+ defaultMessage: 'Authorization Failed',
+ }),
+ message: i18n.translate('xpack.actions.oauthCallback.page.errorMessage', {
+ defaultMessage: 'You can close this window and try again.',
+ }),
+ details,
+ isSuccess: false,
+ broadcast: connectorId
+ ? {
+ connectorId,
+ status: OAuthAuthorizationStatus.Error,
+ error: details,
+ }
+ : undefined,
+ }),
+ });
+};
+
+/**
+ * Returns an OAuth success response. Same redirect-vs-page branching as
+ * {@link respondWithError}.
+ *
+ * @param res - Kibana response factory
+ * @param options.connectorId - Connector ID
+ * @param options.returnUrl - When set, triggers a redirect instead of rendering the page
+ */
+const respondWithSuccess = (
+ res: KibanaResponseFactory,
+ { connectorId, returnUrl }: RespondWithSuccessOptions
+) => {
+ if (returnUrl) {
+ return res.redirected({
+ headers: {
+ location: buildOAuthReturnUrl(returnUrl, connectorId, OAuthAuthorizationStatus.Success),
+ },
+ });
+ }
+ return res.ok({
+ headers: { 'content-type': 'text/html' },
+ body: generateOAuthCallbackPage({
+ title: i18n.translate('xpack.actions.oauthCallback.page.successTitle', {
+ defaultMessage: 'OAuth Authorization Successful',
+ }),
+ heading: i18n.translate('xpack.actions.oauthCallback.page.successHeading', {
+ defaultMessage: 'Authorization Successful',
+ }),
+ message: i18n.translate('xpack.actions.oauthCallback.page.autoCloseMessage', {
+ defaultMessage:
+ 'This window will close in {seconds, plural, one {# second} other {# seconds}}.',
+ values: { seconds: AUTO_CLOSE_DELAY_SECONDS },
+ }),
+ isSuccess: true,
+ autoClose: true,
+ broadcast: {
+ connectorId,
+ status: OAuthAuthorizationStatus.Success,
+ },
+ }),
+ });
+};
+
/**
* OAuth2 callback endpoint - handles authorization code exchange
*/
@@ -246,12 +390,13 @@ export const oauthCallbackRoute = (
response: {
302: {
description: i18n.translate('xpack.actions.oauthCallback.response302Description', {
- defaultMessage: 'Redirects to Kibana on successful authorization.',
+ defaultMessage:
+ 'Redirects to the return URL with authorization result query parameters.',
}),
},
200: {
description: i18n.translate('xpack.actions.oauthCallback.response200Description', {
- defaultMessage: 'Returns an HTML page with error details if authorization fails.',
+ defaultMessage: 'Returns an HTML callback page.',
}),
},
401: {
@@ -267,85 +412,99 @@ export const oauthCallbackRoute = (
const core = await context.core;
const routeLogger = logger.get('oauth_callback');
- // Check rate limit
const currentUser = core.security.authc.getCurrentUser();
if (!currentUser) {
return res.unauthorized({
headers: { 'content-type': 'text/html' },
body: generateOAuthCallbackPage({
- title: 'Authorization Failed',
- heading: 'Authentication Required',
- message: 'User should be authenticated to complete OAuth callback.',
- details: 'Please log in and try again.',
+ title: i18n.translate('xpack.actions.oauthCallback.page.authRequiredTitle', {
+ defaultMessage: 'Authorization Failed',
+ }),
+ heading: i18n.translate('xpack.actions.oauthCallback.page.authRequiredHeading', {
+ defaultMessage: 'Authentication Required',
+ }),
+ message: i18n.translate('xpack.actions.oauthCallback.page.authRequiredMessage', {
+ defaultMessage: 'You must be logged in to complete the OAuth authorization.',
+ }),
+ details: i18n.translate('xpack.actions.oauthCallback.page.authRequiredDetails', {
+ defaultMessage: 'Please log in and try again.',
+ }),
isSuccess: false,
}),
});
}
- const username = currentUser.username;
- oauthRateLimiter.log(username, 'callback');
- if (oauthRateLimiter.isRateLimited(username, 'callback')) {
- routeLogger.warn(`OAuth callback rate limit exceeded for user: ${username}`);
- return res.ok({
- headers: { 'content-type': 'text/html' },
- body: generateOAuthCallbackPage({
- title: 'OAuth Authorization Failed',
- heading: 'Too Many Requests',
- message: 'You have made too many authorization attempts.',
- details: 'Please wait before trying again.',
- isSuccess: false,
+
+ const { profile_uid: profileUid } = currentUser;
+
+ if (!profileUid) {
+ return respondWithError(res, {
+ details: i18n.translate('xpack.actions.oauthCallback.error.missingProfileUid', {
+ defaultMessage: 'Unable to retrieve Kibana user profile ID.',
+ }),
+ });
+ }
+
+ oauthRateLimiter.log(profileUid, 'callback');
+ if (oauthRateLimiter.isRateLimited(profileUid, 'callback')) {
+ routeLogger.warn(`OAuth callback rate limit exceeded for user: ${profileUid}`);
+ return respondWithError(res, {
+ details: i18n.translate('xpack.actions.oauthCallback.error.rateLimited', {
+ defaultMessage: 'Too many authorization attempts. Please wait before trying again.',
}),
});
}
- // Handle OAuth errors or missing parameters
const { code, state: stateParam, error, error_description: errorDescription } = req.query;
- if (error || !code || !stateParam) {
- const errorMessage = error || 'Missing required OAuth parameters (code or state)';
- const details = errorDescription
- ? `${errorMessage}\n\n${errorDescription}`
- : errorMessage;
- return res.ok({
- headers: { 'content-type': 'text/html' },
- body: generateOAuthCallbackPage({
- title: 'OAuth Authorization Failed',
- heading: 'Authorization Failed',
- message: 'You can close this window and try again.',
- details,
- isSuccess: false,
+ if (!stateParam) {
+ return respondWithError(res, {
+ details: i18n.translate('xpack.actions.oauthCallback.error.missingState', {
+ defaultMessage: 'Missing required OAuth state parameter.',
}),
});
}
- try {
- const [coreStart, { encryptedSavedObjects, spaces }] = await coreSetup.getStartServices();
+ const [coreStart, { encryptedSavedObjects, spaces }] = await coreSetup.getStartServices();
- // Retrieve and validate state
- const oauthStateClient = new OAuthStateClient({
- encryptedSavedObjectsClient: encryptedSavedObjects.getClient({
- includedHiddenTypes: ['oauth_state'],
+ const oauthStateClient = new OAuthStateClient({
+ encryptedSavedObjectsClient: encryptedSavedObjects.getClient({
+ includedHiddenTypes: ['oauth_state'],
+ }),
+ unsecuredSavedObjectsClient: core.savedObjects.getClient({
+ includedHiddenTypes: ['oauth_state'],
+ }),
+ logger: routeLogger,
+ });
+ const oauthState = await oauthStateClient.get(stateParam);
+ if (!oauthState) {
+ return respondWithError(res, {
+ details: i18n.translate('xpack.actions.oauthCallback.error.invalidState', {
+ defaultMessage:
+ 'Invalid or expired state parameter. The authorization session may have timed out.',
}),
- unsecuredSavedObjectsClient: core.savedObjects.getClient({
- includedHiddenTypes: ['oauth_state'],
- }),
- logger: routeLogger,
});
- const oauthState = await oauthStateClient.get(stateParam);
- if (!oauthState) {
- return res.ok({
- headers: { 'content-type': 'text/html' },
- body: generateOAuthCallbackPage({
- title: 'OAuth Authorization Failed',
- heading: 'Authorization Failed',
- message: 'You can close this window and try again.',
- details:
- 'Invalid or expired state parameter. The authorization session may have timed out.',
- isSuccess: false,
- }),
+ }
+
+ const { connectorId: stateConnectorId, kibanaReturnUrl } = oauthState;
+
+ if (error || !code) {
+ const providerError =
+ error ||
+ i18n.translate('xpack.actions.oauthCallback.error.missingCode', {
+ defaultMessage: 'Missing required OAuth authorization code',
});
- }
+ const details = errorDescription
+ ? `${providerError}\n\n${errorDescription}`
+ : providerError;
+ routeLogger.error(`OAuth provider error for connector ${stateConnectorId}: ${details}`);
+ return respondWithError(res, {
+ details,
+ connectorId: stateConnectorId,
+ returnUrl: kibanaReturnUrl,
+ });
+ }
- // Get connector with decrypted secrets using the spaceId from the OAuth state
+ try {
const connectorEncryptedClient = encryptedSavedObjects.getClient({
includedHiddenTypes: ['action'],
});
@@ -358,27 +517,25 @@ export const oauthCallbackRoute = (
name: string;
config: OAuthConnectorConfig;
secrets: OAuthConnectorSecrets;
- }>('action', oauthState.connectorId, { namespace });
+ }>('action', stateConnectorId, { namespace });
const config = rawAction.attributes.config;
const secrets = rawAction.attributes.secrets;
- // Extract OAuth config - for connector specs, secrets are stored directly
const clientId = secrets.clientId || config?.clientId;
const clientSecret = secrets.clientSecret;
const tokenUrl = secrets.tokenUrl || config?.tokenUrl;
- const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true; // Default to true (OAuth 2.0 recommended practice)
+ const useBasicAuth = secrets.useBasicAuth ?? config?.useBasicAuth ?? true;
+
if (!clientId || !clientSecret || !tokenUrl) {
throw new Error(
'Connector missing required OAuth configuration (clientId, clientSecret, tokenUrl)'
);
}
- // Build the redirect URI (must match the one sent to the authorization endpoint)
const redirectUri = OAuthAuthorizationService.getRedirectUri(
coreStart.http.basePath.publicBaseUrl
);
- // Exchange authorization code for tokens
const tokenResult = await requestOAuthAuthorizationCodeToken(
tokenUrl,
logger,
@@ -393,43 +550,40 @@ export const oauthCallbackRoute = (
useBasicAuth
);
routeLogger.debug(
- `Successfully exchanged authorization code for access token for connectorId: ${oauthState.connectorId}`
+ `Successfully exchanged authorization code for access token for connectorId: ${stateConnectorId}`
);
- // Store tokens - first delete any existing tokens for this connector then create a new token record
- const connectorTokenClient = new ConnectorTokenClient({
+ const userConnectorTokenClient = new UserConnectorTokenClient({
encryptedSavedObjectsClient: encryptedSavedObjects.getClient({
- includedHiddenTypes: ['connector_token', 'user_connector_token'],
+ includedHiddenTypes: ['user_connector_token'],
}),
unsecuredSavedObjectsClient: core.savedObjects.getClient({
- includedHiddenTypes: ['connector_token', 'user_connector_token'],
+ includedHiddenTypes: ['user_connector_token'],
}),
logger: routeLogger,
});
- await connectorTokenClient.deleteConnectorTokens({
- connectorId: oauthState.connectorId,
+
+ await userConnectorTokenClient.deleteConnectorTokens({
+ connectorId: stateConnectorId,
tokenType: 'access_token',
+ profileUid,
});
- const formattedToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`;
- await connectorTokenClient.createWithRefreshToken({
- connectorId: oauthState.connectorId,
+ const formattedToken = `${capitalize(tokenResult.tokenType)} ${tokenResult.accessToken}`;
+ await userConnectorTokenClient.createWithRefreshToken({
+ connectorId: stateConnectorId,
accessToken: formattedToken,
refreshToken: tokenResult.refreshToken,
expiresIn: tokenResult.expiresIn,
refreshTokenExpiresIn: tokenResult.refreshTokenExpiresIn,
tokenType: 'access_token',
+ profileUid,
});
- // Clean up state
await oauthStateClient.delete(oauthState.id);
- // Redirect to Kibana with success indicator
- const returnUrl = new URL(oauthState.kibanaReturnUrl);
- returnUrl.searchParams.set('oauth_authorization', 'success');
- return res.redirected({
- headers: {
- location: returnUrl.toString(),
- },
+ return respondWithSuccess(res, {
+ connectorId: stateConnectorId,
+ returnUrl: kibanaReturnUrl,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -437,18 +591,70 @@ export const oauthCallbackRoute = (
if (err instanceof Error && err.stack) {
routeLogger.debug(`OAuth callback error stack: ${err.stack}`);
}
- return res.ok({
- headers: { 'content-type': 'text/html' },
- body: generateOAuthCallbackPage({
- title: 'OAuth Authorization Failed',
- heading: 'Authorization Failed',
- message: 'You can close this window and try again.',
- details: errorMessage,
- isSuccess: false,
- }),
+ return respondWithError(res, {
+ details: GENERIC_OAUTH_ERROR,
+ connectorId: stateConnectorId,
+ returnUrl: kibanaReturnUrl,
});
}
})
)
);
};
+
+/**
+ * Companion JS for the OAuth callback HTML page.
+ *
+ * Reads `data-broadcast` and `data-auto-close` attributes from `` and
+ * executes BroadcastChannel messaging and auto-close logic. Served as a
+ * separate route so it satisfies Kibana's `script-src 'self'` CSP.
+ */
+const OAUTH_CALLBACK_SCRIPT_BODY = `(() => {
+ const { broadcast, autoClose } = document.body.dataset;
+
+ if (broadcast) {
+ try {
+ const message = JSON.parse(broadcast);
+ const channel = new BroadcastChannel(${JSON.stringify(OAUTH_BROADCAST_CHANNEL_NAME)});
+ channel.postMessage(message);
+ channel.close();
+ } catch (_) {
+ // BroadcastChannel may not be supported in all browsers
+ }
+ }
+
+ if (autoClose === 'true') {
+ setTimeout(() => {
+ window.close();
+ setTimeout(() => {
+ const fallback = document.querySelector('.auto-close-message');
+ if (fallback) {
+ fallback.style.display = 'block';
+ }
+ }, 100);
+ }, ${AUTO_CLOSE_DELAY_SECONDS * 1000});
+ }
+})();
+`;
+
+export const oauthCallbackScriptRoute = (router: IRouter