From e2101e8892f3521644a3eef9f2d7491d2a92671a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 31 Jan 2024 18:02:48 +0100 Subject: [PATCH 1/2] beps/0003: updated implementation proposal, removing httpAuth.middleware Signed-off-by: Patrik Oldsberg --- .../README.md | 207 ++++++++++++------ 1 file changed, 142 insertions(+), 65 deletions(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index 7fc43d22f5..4089211fb9 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -62,17 +62,19 @@ The following goals are the primary focus of this BEP: ## Proposal -Two new backend service interfaces are introduced to support these new features. The new `AuthService` is a low-level service that encapsulates authentication and creation of bearer tokens for all types of Backstage identities, including user, service, and services making requests on behalf of users. The new `HttpAuthService` is a higher level service that lets your create [express middleware](https://expressjs.com/en/guide/using-middleware.html) to protect endpoints, as well as access the credentials and identity of incoming requests. These new services replace the existing `IdentityService` and `TokenManagerService` +Two new backend service interfaces are introduced to support these new features. The new `AuthService` is a low-level service that encapsulates authentication and creation of bearer tokens for all types of Backstage identities, including user, service, and services making requests on behalf of users. The new `HttpAuthService` is a higher level service that lets you access the credentials and identity of incoming requests, and issue credentials for outgoing requests. These new services replace the existing `IdentityService` and `TokenManagerService` The proposed design leaves the decision for how different endpoints are protected to the implementation of the plugin backends themselves. This includes whether particular routes should allow anonymous access, access from users authenticated via a cookie, or perhaps only allow access from other plugin backends and external services. This means that integrators do not need to - and do not have the ability to - configure access controls of individual endpoints, except for what the permission system already provides, and what is made available through static configuration or extension points. In order to allow for cookie-based authentication of incoming user requests, the `auth` plugin backend is extended to be able to issue user tokens with reduced scope, which in turn integrate with the new `AuthService` and `HttpAuthService`. The ability to use cookie auth for requests is an opt-in per route and is only be permitted for read methods (`GET`, `HEAD`, `OPTIONS`). The actual implementation of cookie-based flows will be up to each plugin, but with significant help from the new auth service interfaces. +In order to allow either unauthenticated access or cookie-based access, a plugin must explicitly opt-in the specific path prefixes that these should be available at. This is done through a new method that is added to the `HttpRouterService` interface. + For service-to-service communication we will move away from reusing user tokens in upstream requests. We will instead implement an "On-Behalf-Of" flow where incoming user credentials are encapsulated in a service token for the upstream request. In line with this the new auth service interfaces will aim to make it difficult to directly forward credentials from incoming requests, and instead encourage that plugin backends issue new service credentials for upstream requests. ## Design Details -### `AuthService` (WIP) +### `AuthService` Interface The new `AuthService` interface is defined as follows: @@ -97,25 +99,105 @@ export interface AuthService { } ``` -#### Usage Patterns +### `AuthService` Usage Patterns TODO -### `HttpAuthService` (WIP) +### `HttpRouterService` Interface + +> Open question: Should this instead be added to the `HttpAuthService`? It may fit a bit better there, but on the other hand it might make sense to add additional policies unrelated to authentication too, such as rate limiting. + +The `HttpRouterService` interface will be extended with the ability to opt-out of the default protection of endpoints, enabling either cookie auth or unauthenticated access. + +```ts +export interface HttpRouterService { + // All routes only allow authenticated users and services by default. + use(handler: Handler): void; + + // Exact option structure is TBD, just highlighting the general idea for now + configure(options: { + allowCookieAuthOnPaths?: string[]; + allowUnauthenticatedAccessPaths?: string[]; + }): void; +} +``` + +### `HttpRouterService` Usage Patterns + +All of these usages patterns are from the perspective of a plugin backend. + +#### Standard plugin that only allows access from authenticated users and services + +```ts +export default createBackendPlugin({ + pluginId: 'todo', + register(env) { + env.registerInit({ + deps: { + http: coreServices.httpRouter, + }, + async init({ http }) { + http.use(await createRouter(/* ... */)); + }, + }); + }, +}); +``` + +This is expected to be the pattern for the vast majority of plugins. + +#### A plugin with an endpoint that only allows cookie auth + +```ts +export default createBackendPlugin({ + pluginId: 'techdocs', + register(env) { + env.registerInit({ + deps: { + http: coreServices.httpRouter, + }, + async init({ http }) { + // The order of these two calls does not matter + http.use(await createRouter(/* ... */)); + http.configure({ + allowCookieAuthOnPaths: ['/static'], + }); + }, + }); + }, +}); +``` + +#### A plugin that allows both public access and cookie auth + +```ts +export default createBackendPlugin({ + pluginId: 'app', + register(env) { + env.registerInit({ + deps: { + http: coreServices.httpRouter, + }, + async init({ http }) { + http.use(await createRouter(/* ... */)); + http.configure({ + allowCookieAuthOnPaths: ['/'], + // Unauthenticated access takes precedence, the /public endpoint does not require cookie auth + allowUnauthenticatedAccessPaths: ['/public'], + }); + }, + }); + }, +}); +``` + +### `HttpAuthService` Interface The new `HttpAuthService` interface is defined as follows: ```ts -type AuthTypes = 'user' | 'user-cookie' | 'service' | 'unauthorized'; - -export interface HttpAuthServiceMiddlewareOptions { - allow: AuthTypes[]; -} - export interface HttpAuthService { - createHttpPluginRouterMiddleware(): Handler; - - middleware(options?: HttpAuthServiceMiddlewareOptions): Handler; + createHttpPluginRouterMiddleware(options: OptionsTBD): Handler; credentials( req: Request, @@ -130,25 +212,22 @@ export interface HttpAuthService { } ``` -#### Opt-out behavior - -The `HttpAuthService` must be implemented in such a way that any requests that are handled by the plugin must always touch at least one `httpAuth.middleware(...)`, or it is rejected. This is implemented by overriding the necessary response methods on the `res` object to make it an error to call any of them, but to then restore the original methods in the `httpAuth.middleware(...)`. - -#### Usage Patterns +### `HttpAuthService` Usage Patterns All of these usages patterns are from the perspective of a plugin backend. -**Authenticate incoming request that requires user authentication** +#### Authenticate incoming request that requires user authentication ```ts -// Adding the middleware separately like this makes it apply to ALL -// routes added below it. You can also add it between the path and the -// handler below, to make it apply only for that particular path. -router.use(httpAuth.middleware({ allow: ['user'] })); - +// All routes only allow authenticated users and services by default. router.get('/read-data', (req, res) => { // TODO: user can currently be undefined, figure out best pattern to avoid that const { user } = httpAuth.credentials(req); + if (!user) { + throw new NotAllowedError( + 'Service requests are not allowed on this endpoint', + ); + } console.log( `User ref=${user.userEntityRef} ownership=${user.ownershipEntityRefs}`, ); @@ -156,70 +235,68 @@ router.get('/read-data', (req, res) => { }); ``` -**Forward the user credentials from an incoming requests to upstream plugin backend** +#### Forward the user credentials from an incoming requests to upstream plugin backend ```ts -router.get( - '/read-data', - // Here, the middleware applies to the current path only - httpAuth.middleware({ allow: ['user'] }), - (req, res) => { - const entity = await catalogClient.getEntityByRef(req.params.entityRef, { - credentials: httpAuth.credentials(req), - }); - // ... - }, -); +router.get('/read-data', (req, res) => { + // The catalogClient will have a reference to the (plugin scoped) HttpAuthService, + // which it uses to create the credential headers for the upstream request. + const entity = await catalogClient.getEntityByRef(req.params.entityRef, { + credentials: httpAuth.credentials(req), + }); + // ... +}); ``` -**Allow both user and service request** +#### Allow both user and service request ```ts -router.get( - '/read-data', - httpAuth.middleware({ allow: ['user', 'service'] }), - (req, res) => { - const credentials = httpAuth.credentials(req); - if (credentials.user) { - res.json( - // Silly example just to highlight separate code paths for user and - // service requests - todoStore.listOwnedTodos({ owner: credentials.user.userEntityRef }), - ); - } else { - res.json(todoStore.listTodos()); - } - }, -); +router.get('/read-data', (req, res) => { + const credentials = httpAuth.credentials(req); + if (credentials.user) { + res.json( + // Silly example just to highlight separate code paths for user and + // service requests + todoStore.listOwnedTodos({ owner: credentials.user.userEntityRef }), + ); + } else { + res.json(todoStore.listTodos()); + } +}); ``` -**Issuing a cookie and allowing user cookie auth on a separate endpoint** +#### Issuing a cookie and allowing user cookie auth on a separate endpoint ```ts -router.get('/cookie', httpAuth.middleware({ allow: ['user'] }), (req, res) => { - await httpAuth.issueUserCookie(res); +router.get('/cookie', (req, res) => { + await httpAuth.issueUserCookie(res); // If this is a service call it'll throw res.json({ ok: true }); }); +// Allowing cookie auth is a separate step where you call the configure method +// of the httpRouter API in your plugin setup code. +httpRouter.configure({ + // In practice we can make this configuration a lot more capable, this is just a minimal example + allowCookieAuthOnPaths: ['/static'], // router.use('/static', cookieAuthMiddleware()) under the hood +}); + // Separate endpoint that serves static content, allowing user cookie auth as -// well as regular user auth -router.use( - '/static', - httpAuth.middleware({ allow: ['user', 'user-cookie'] }), - express.static(staticContentDir), -); +// well as the default user and service auth methods +router.use('/static', express.static(staticContentDir)); ``` -**Passing along user identity from a cookie in an upstream request** +#### Passing along user identity from a cookie in an upstream request ```ts router.get( '/read-data', httpAuth.middleware({ allow: ['user-cookie'] }), (req, res) => { - const { cookieUser } = httpAuth.credentials(req); + // These credentials don't actually contain an underlying user token for cookie-authed requests + // You you try to pass them to the AuthService, it'll throw. + const { user } = httpAuth.credentials(req); console.log( - `User ref=${cookieUser.userEntityRef} ownership=${cookieUser.ownershipEntityRefs}`, + `User ref=${user.userEntityRef} ownership=${user.ownershipEntityRefs}`, ); // ... }, From c4473a314bc3c97ef74eae76517d1cf2473edd2d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 1 Feb 2024 18:13:37 +0100 Subject: [PATCH 2/2] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- beps/0003-auth-architecture-evolution/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beps/0003-auth-architecture-evolution/README.md b/beps/0003-auth-architecture-evolution/README.md index 4089211fb9..60d5eb74a5 100644 --- a/beps/0003-auth-architecture-evolution/README.md +++ b/beps/0003-auth-architecture-evolution/README.md @@ -268,7 +268,7 @@ router.get('/read-data', (req, res) => { #### Issuing a cookie and allowing user cookie auth on a separate endpoint ```ts -router.get('/cookie', (req, res) => { +router.get('/cookie', async (req, res) => { await httpAuth.issueUserCookie(res); // If this is a service call it'll throw res.json({ ok: true }); }); @@ -293,7 +293,7 @@ router.get( httpAuth.middleware({ allow: ['user-cookie'] }), (req, res) => { // These credentials don't actually contain an underlying user token for cookie-authed requests - // You you try to pass them to the AuthService, it'll throw. + // If you try to pass them to the AuthService, it'll throw. const { user } = httpAuth.credentials(req); console.log( `User ref=${user.userEntityRef} ownership=${user.ownershipEntityRefs}`,