Skip to content

Commit

Permalink
Introduce auth.ts and shared secret for external callers
Browse files Browse the repository at this point in the history
  • Loading branch information
mareklibra committed Dec 22, 2023
1 parent aa6a7df commit be56d48
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 211 deletions.
36 changes: 25 additions & 11 deletions plugins/notifications-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ export default async function createPlugin(
): Promise<Router> {
const catalogClient = new CatalogClient({ discoveryApi: env.discovery });
const dbConfig = env.config.getConfig('backend.database');
const notificationsServiceToServiceAuthEnabled =
!!env.config.getOptionalBoolean('notifications.authorizeExternalCallers');
// Following is optional
const externalCallerSecret = env.config.getOptionalString(
'notifications.externalCallerSecret',
);
return await createRouter({
identity: env.identity,
Expand All @@ -64,7 +66,7 @@ export default async function createPlugin(
tokenManager: env.tokenManager,
dbConfig,
catalogClient,
notificationsServiceToServiceAuthEnabled,
externalCallerSecret,
});
}
```
Expand Down Expand Up @@ -112,11 +114,30 @@ In the `app-config.yaml` or `app-config.local.yaml`:

#### Other configuration (optional):

If you have issues to create valid JWT tokens by an external caller, use following option to bypass the service-to-service configuration for them:

```
notifications:
authorizeExternalCallers: false
# Workaround for issues with external caller JWT token creation.
# When following config option is not provided and the request "authentication" header is missing, the request is ALLOWED by default
# When following option is present, the request must contain either a valid JWT token or that provided shared secret in the "notifications-secret" header
externalCallerSecret: your-secret-token-shared-with-external-services
```

Mind using HTTPS to help preventing leaking the shared secret.

Example of the request then:

```
curl -X POST http://localhost:7007/api/notifications/notifications -H "Content-Type: application/json" -H "notifications-secret: your-secret-token-shared-with-external-services" -d '{"title":"my-title","origin":"my-origin","message":"message one","topic":"my-topic"}'
```

Notes:

- The `externalCallerSecret` is an workaround and will be probably replaced by proper use of JWT tokens.
- Sharing the same shared secret with the "auth.secret" option is not recommended

#### Authentication

Please refer https://backstage.io/docs/auth/ to set-up authentication.
Expand All @@ -143,13 +164,6 @@ To configure those two flows, refer
- https://backstage.io/docs/auth/service-to-service-auth.
- https://backstage.io/docs/auth/service-to-service-auth#usage-in-external-callers

**Note:** Recently we have difficulties to get authorization via custom JWT tokens of external services working. For this reason and to allow simple deployments, the service-to-service token authorization is skipped by default. Can be enabled via the backstage configuration (like `app-config.yaml`) by adding at the top-level:

```
notifications:
authorizeExternalCallers: true
```

#### Catalog

The notifications require target users or groups (as receivers) to be listed in the Catalog.
Expand Down
110 changes: 110 additions & 0 deletions plugins/notifications-backend/src/service/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { AuthenticationError, NotAllowedError } from '@backstage/errors';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import {
AuthorizeResult,
BasicPermission,
PermissionEvaluator,
} from '@backstage/plugin-permission-common';

import express from 'express';

import { DefaultServiceUser } from './constants';
import { RouterOptions } from './types';

export type GetLoggedInUserOptions = Pick<
RouterOptions,
'identity' | 'tokenManager' | 'externalCallerSecret'
>;
export type CheckUserPermission = GetLoggedInUserOptions &
Pick<RouterOptions, 'permissions'>;

/*
* User's entity must be present in the catalog.
*/
export const getLoggedInUser = async (
request: express.Request,
{ identity, tokenManager, externalCallerSecret }: GetLoggedInUserOptions,
): Promise<string> => {
const identityResponse = await identity.getIdentity({ request });

// To properly set identity, see packages/backend/src/plugins/auth.ts or https://backstage.io/docs/auth/identity-resolver
if (identityResponse) {
// The auth token contains user's identity, most probably originated in the FE
// Example: user:default/guest
let author = identityResponse?.identity.userEntityRef;
if (author) {
if (author.startsWith('user:')) {
author = author.slice('user:'.length);
}
} else {
throw new AuthenticationError(
'Missing valid authentication data or the user is not in the Catalog.',
);
}
return author;
}

const token = getBearerTokenFromAuthorizationHeader(
request.header('authorization'),
);
if (token) {
// backend service-to-service flow
await tokenManager.authenticate(token);
}

// External call - workaround
// Following shared-secret is a workaround till we make the creation of valid JWT tokens by external callers simple.
// In such case, the flow would be identical with the service-to-service.
// https://github.com/backstage/backstage/issues/18622
// https://github.com/backstage/backstage/issues/9374
if (externalCallerSecret) {
if (request.header('notifications-secret') === externalCallerSecret) {
return DefaultServiceUser;
}
throw new AuthenticationError('Provided shared secret does not match.');
}

// Since the shared secret has not been requested in the configuration, we ALLOW the request
return DefaultServiceUser;
};

export const checkPermission = async (
request: express.Request,
permissions: PermissionEvaluator,
permission: BasicPermission,
loggedInUser: string,
) => {
const token = getBearerTokenFromAuthorizationHeader(
request.header('authorization'),
);
const decision = (
await permissions.authorize([{ permission }], {
token,
})
)[0];

if (decision.result === AuthorizeResult.DENY) {
throw new NotAllowedError(
`The user ${loggedInUser} is not authorized to ${permission.name}`,
);
}
};

/**
* Checks if the logged-in user has the required permission
* and returns the username.
*/
export const checkUserPermission = async (
request: express.Request,
options: CheckUserPermission,
requiredPermission: BasicPermission,
): Promise<string> => {
const loggedInUser = await getLoggedInUser(request, options);
await checkPermission(
request,
options.permissions,
requiredPermission,
loggedInUser,
);
return loggedInUser;
};
6 changes: 6 additions & 0 deletions plugins/notifications-backend/src/service/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const DefaultServiceUser = 'default/externalServiceNotificationsUser';
export const DefaultMessageScope = 'user';
export const DefaultPageNumber = 1;
export const DefaultPageSize = 20;
export const DefaultOrderBy = 'created';
export const DefaultOrderDirection = 'desc';
4 changes: 3 additions & 1 deletion plugins/notifications-backend/src/service/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { CatalogClient } from '@backstage/catalog-client';
import { Knex } from 'knex';

import { Components, Paths } from '../openapi';
import { ActionsInsert, MessagesInsert } from './db';
import {
DefaultMessageScope,
DefaultOrderBy,
DefaultOrderDirection,
DefaultPageNumber,
DefaultPageSize,
} from './constants';
import { ActionsInsert, MessagesInsert } from './db';
import {
MessageScopes,
NotificationsFilterRequest,
NotificationsOrderByDirections,
Expand Down
Loading

0 comments on commit be56d48

Please sign in to comment.