Skip to content

Commit

Permalink
Merge pull request #23719 from backstage/rugvip/app-auth
Browse files Browse the repository at this point in the history
[Auth] Migrate `app-backend` to use new auth services
  • Loading branch information
Rugvip committed Apr 9, 2024
2 parents 5bee707 + 5ce40c4 commit df279de
Show file tree
Hide file tree
Showing 46 changed files with 1,238 additions and 117 deletions.
5 changes: 5 additions & 0 deletions .changeset/fair-onions-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---

Fix the bundle public subpath configuration.
7 changes: 7 additions & 0 deletions .changeset/fifty-cameras-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@backstage/plugin-auth-react': minor
---

**BREAKING**: Removed the path option from `CookieAuthRefreshProvider` and `useCookieAuthRefresh`.

A new `CookieAuthRedirect` component has been added to redirect a public app bundle to the protected one when using the `app-backend` with a separate public entry point.
5 changes: 5 additions & 0 deletions .changeset/forty-elephants-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---

Use the default cookie endpoints added automatically when a cookie policy is set.
6 changes: 6 additions & 0 deletions .changeset/nine-rabbits-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': patch
'@backstage/frontend-app-api': patch
---

The app is now aware of if it is being served from the `app-backend` with a separate public and protected bundles. When in protected mode the app will now continuously refresh the session cookie, as well as clear the cookie if the user signs out.
5 changes: 5 additions & 0 deletions .changeset/smooth-squids-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---

Track assets namespace in the cache store, implement a cookie authentication for when the public entry is enabled and used with the new auth services.
5 changes: 5 additions & 0 deletions .changeset/tall-rats-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---

When building the frontend app public assets are now also copied to the public dist directory when in use.
5 changes: 5 additions & 0 deletions .changeset/weak-planets-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/backend-plugin-api': patch
---

The credentials passed to the `issueUserCookie` method of the `HttpAuthService` are no longer required to represent a user principal.
5 changes: 5 additions & 0 deletions .changeset/wet-swans-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---

Automatically creates a get and delete cookie endpoint when a `user-cookie` policy is added.
102 changes: 102 additions & 0 deletions docs/tutorials/enable-public-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
id: enable-public-entry
title: Enabling a public entry point
description: A guide for how to experiment with public and protected Backstage app bundles
---

# Enable Public Entry (Experimental)

In this tutorial, you will learn how to restrict access to your main Backstage app bundle to authenticated users only.

It is expected that the protected bundle feature will be refined in future development iterations, but for now, here is a simplified explanation of how it works:

Your Backstage app bundle is split into two code entries:

- Public entry point containing login pages;
- There is also a protected main entry point that contains the code for what you see after signing in.

With that, Backstage's cli and backend will detect public entry point and serve it to unauthenticated users, while serving the main, protected entry point only to authenticated users.

## Requirements

- The app needs to be served by the `app-backend` plugin, or this won't work;
- Also it will only work for those using `backstage-cli` to build and serve their Backstage app.

## Step-by-step

1. Create a `index-public-experimental.tsx` in your app `src` folder.
:::note
The filename is a convention, so it is not currently configurable.
:::

2. This file is the public entry point for your application, and it should only contain what unauthenticated users should see:

```tsx title="in packages/app/src/index-public-experimental.tsx"
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createApp } from '@backstage/app-defaults';
import { AppRouter } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import {
configApiRef,
discoveryApiRef,
createApiFactory,
} from '@backstage/core-plugin-api';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';

// Notice that this is only setting up what is needed by the sign-in pages
const app = createApp({
// If you have any custom APIs that your sign-in page depends on, you need to add them here
apis: [],
components: {
SignInPage: props => {
return (
<SignInPage
{...props}
providers={['guest']}
title="Select a sign-in method"
/>
);
},
},
});

const App = app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
{/* This component triggers an authenticated redirect to the main app, while staying on the same URL */}
<CookieAuthRedirect />
</AppRouter>
</>,
);

ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
```

:::note
The frontend will handle cookie refreshing automatically, so you don't have to worry about it.
:::

3. Let's verify that everything is working locally. From your project root folder, run the following commands to build the app and start the backend:

```sh
# building the app package
yarn workspace app start
# starting the backend api
yarn start-backend
```

4. Visit http://localhost:7007 to see the public app and validate that the _index.html_ response only contains a minimal application.
:::note
Regular app serving will always serve protected apps without authenticating.
:::

5. Finally, as soon as you log in, you will be redirected to the main app home page (inspect the page and see that the protected bundle was served from the app backend after the redirect).

That's it!
1 change: 1 addition & 0 deletions microsite/sidebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@
"tutorials/yarn-migration",
"tutorials/migrate-to-mui5",
"tutorials/auth-service-migration",
"tutorials/enable-public-entry",
"tutorials/setup-opentelemetry"
],
"Architecture Decision Records (ADRs)": [
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@backstage/plugin-airbrake": "workspace:^",
"@backstage/plugin-apache-airflow": "workspace:^",
"@backstage/plugin-api-docs": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/plugin-azure-devops": "workspace:^",
"@backstage/plugin-azure-sites": "workspace:^",
"@backstage/plugin-badges": "workspace:^",
Expand Down
19 changes: 2 additions & 17 deletions packages/app/src/index-public-experimental.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,17 @@ import {
OAuthRequestDialog,
SignInPage,
} from '@backstage/core-components';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { providers } from '../src/identityProviders';
import {
configApiRef,
createApiFactory,
discoveryApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { AuthProxyDiscoveryApi } from '../src/AuthProxyDiscoveryApi';

// TODO(Rugvip): make this available via some util, or maybe Utility API?
function readBasePath(configApi: typeof configApiRef.T) {
let { pathname } = new URL(
configApi.getOptionalString('app.baseUrl') ?? '/',
'http://sample.dev', // baseUrl can be specified as just a path
);
pathname = pathname.replace(/\/*$/, '');
return pathname;
}

const app = createApp({
apis: [
createApiFactory({
Expand All @@ -64,17 +54,12 @@ const app = createApp({
},
});

function RedirectToRoot() {
window.location.pathname = readBasePath(useApi(configApiRef));
return <div />;
}

const App = app.createRoot(
<>
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<RedirectToRoot />
<CookieAuthRedirect />
</AppRouter>
</>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ class DefaultHttpAuthService implements HttpAuthService {

let credentials: BackstageCredentials<BackstageUserPrincipal>;
if (options?.credentials) {
if (this.#auth.isPrincipal(options.credentials, 'none')) {
res.clearCookie(
BACKSTAGE_AUTH_COOKIE,
await this.#getCookieOptions(res.req),
);
return { expiresAt: new Date() };
}
if (!this.#auth.isPrincipal(options.credentials, 'user')) {
throw new AuthenticationError(
'Refused to issue cookie for non-user principal',
Expand All @@ -196,37 +203,48 @@ class DefaultHttpAuthService implements HttpAuthService {
return { expiresAt: existingExpiresAt };
}

const originHeader = res.req.headers.origin;
const { token, expiresAt } = await this.#auth.getLimitedUserToken(
credentials,
);
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}

res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
...(await this.#getCookieOptions(res.req)),
expires: expiresAt,
});

return { expiresAt };
}

async #getCookieOptions(req: Request): Promise<{
domain: string;
httpOnly: true;
secure: boolean;
priority: 'high';
sameSite: 'none' | 'lax';
}> {
const originHeader = req.headers.origin;
const origin =
!originHeader || originHeader === 'null' ? undefined : originHeader;

// https://backstage.example.com/api/catalog
const externalBaseUrlStr = await this.#discovery.getExternalBaseUrl(
this.#pluginId,
);
const externalBaseUrl = new URL(origin ?? externalBaseUrlStr);

const { token, expiresAt } = await this.#auth.getLimitedUserToken(
credentials,
);
if (!token) {
throw new Error('User credentials is unexpectedly missing token');
}

const secure =
externalBaseUrl.protocol === 'https:' ||
externalBaseUrl.hostname === 'localhost';

res.cookie(BACKSTAGE_AUTH_COOKIE, token, {
return {
domain: externalBaseUrl.hostname,
httpOnly: true,
expires: expiresAt,
secure,
priority: 'high',
sameSite: secure ? 'none' : 'lax',
});

return { expiresAt };
};
}

async #existingCookieExpiration(req: Request): Promise<Date | undefined> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import express from 'express';
import request from 'supertest';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { createCookieAuthRefreshMiddleware } from './createCookieAuthRefreshMiddleware';

describe('createCookieAuthRefreshMiddleware', () => {
let app: express.Express;

beforeAll(async () => {
const auth = mockServices.auth();
const httpAuth = mockServices.httpAuth();
const router = createCookieAuthRefreshMiddleware({ auth, httpAuth });
app = express().use(router);
});

beforeEach(() => {
jest.resetAllMocks();
});

it('should issue the user cookie', async () => {
const response = await request(app).get('/.backstage/auth/v1/cookie');
expect(response.status).toBe(200);
expect(response.header['set-cookie'][0]).toMatch(
`backstage-auth=${mockCredentials.limitedUser.token()}`,
);
});

it('should remove the user cookie', async () => {
const response = await request(app).delete('/.backstage/auth/v1/cookie');
expect(response.status).toBe(204);
expect(response.header['set-cookie'][0]).toMatch('backstage-auth=');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api';
import Router from 'express-promise-router';

const WELL_KNOWN_COOKIE_PATH_V1 = '/.backstage/auth/v1/cookie';

/**
* @public
* Creates a middleware that can be used to refresh the cookie for the user.
*/
export function createCookieAuthRefreshMiddleware(options: {
auth: AuthService;
httpAuth: HttpAuthService;
}) {
const { auth, httpAuth } = options;
const router = Router();

// Endpoint that sets the cookie for the user
router.get(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
const { expiresAt } = await httpAuth.issueUserCookie(res);
res.json({ expiresAt: expiresAt.toISOString() });
});

// Endpoint that removes the cookie for the user
router.delete(WELL_KNOWN_COOKIE_PATH_V1, async (_, res) => {
const credentials = await auth.getNoneCredentials();
await httpAuth.issueUserCookie(res, { credentials });
res.status(204).end();
});

return router;
}

0 comments on commit df279de

Please sign in to comment.