Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Auth] Migrate app-backend to use new auth services #23719

Merged
merged 36 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
3b674b8
app-backend: add static asset store namespacing
Rugvip Jan 13, 2024
97b9d2b
app-backend: internal refactor to separate out app router
Rugvip Jan 13, 2024
a8c7b0d
big improvements
Rugvip Mar 4, 2024
7b77bef
feat: create default get cookie endpoint
camilaibs Mar 18, 2024
f17da85
refactor: extract a create cookie middleware
camilaibs Mar 18, 2024
60167a3
refactor: move redirect to root
camilaibs Mar 19, 2024
641a068
feat: create app mode provider
camilaibs Mar 20, 2024
a1950ad
feat: remove cookie on sign out
camilaibs Mar 20, 2024
fc15c4a
refactor: move app mode provider to auth react
camilaibs Mar 21, 2024
14c9f68
docs: start drafting a public entrypoint tutorial
camilaibs Mar 21, 2024
32a208a
test: try fixing namespace length
camilaibs Mar 22, 2024
c884b9a
docs: add changeset files
camilaibs Mar 22, 2024
ffd7110
refactor: apply review suggestions
camilaibs Apr 3, 2024
b01e709
refactor: more review refinements
camilaibs Apr 5, 2024
feab471
docs: update enable public entry tutorial
camilaibs Apr 5, 2024
b602171
refactor: move app auth proviter to core api
camilaibs Apr 5, 2024
dde4707
fix: remove auth node changeset
camilaibs Apr 5, 2024
721359b
fix: injecting app mode
camilaibs Apr 5, 2024
837071a
cli: copy public files to public dist dir when used
Rugvip Apr 6, 2024
d3344ee
backend-app-api: use promise router in cookie auth middleware
Rugvip Apr 9, 2024
cd8587d
cli: copy public files to both protected and public dist dirs
Rugvip Apr 9, 2024
2ff147b
app-backend: log when running in protected mode
Rugvip Apr 9, 2024
f9ad268
app-backend: log users out if session is invalid
Rugvip Apr 9, 2024
52d948d
app-backend: update meta tag injection to be able to update existing tag
Rugvip Apr 9, 2024
2b57eac
core-app-api: add startCookieAuthRefresh + test
Rugvip Apr 9, 2024
d11767c
core-app-api: replace cookie auth provider with new implementation in…
Rugvip Apr 9, 2024
e2e0c99
docs/tutorials/enable-public-entry: remove AuthProxyDiscoveryApi from…
Rugvip Apr 9, 2024
1bfaadb
frontend-app-api: update app protection wiring
Rugvip Apr 9, 2024
4fecffc
changesets: updated and added changesets for app auth
Rugvip Apr 9, 2024
33223c7
auth-react: remove CompatAppProgress since it's no longer needed
Rugvip Apr 9, 2024
30c7ed4
backend-app-api: fix cookie delete response
Rugvip Apr 9, 2024
d1adc89
app-backend auth review fixes
Rugvip Apr 9, 2024
37973c2
app-backend: fix config injection
Rugvip Apr 9, 2024
ed4e394
app-backend: separate config injection handling for protected and pub…
Rugvip Apr 9, 2024
633d30e
auth-react: completely remove path option
Rugvip Apr 9, 2024
5ce40c4
core-app-api: cookie auth review fixes
Rugvip Apr 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
camilaibs marked this conversation as resolved.
Show resolved Hide resolved

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;
}