Merge pull request #26969 from backstage/rugvip/harden

Update docs to be more strict around sign-in resolvers + remove insecure sign-in resolver
This commit is contained in:
Patrik Oldsberg
2024-10-08 11:53:37 +02:00
committed by GitHub
48 changed files with 186 additions and 276 deletions
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/plugin-auth-backend-module-cloudflare-access-provider': patch
'@backstage/plugin-auth-backend-module-vmware-cloud-provider': patch
'@backstage/plugin-auth-backend-module-atlassian-provider': patch
'@backstage/plugin-auth-backend-module-bitbucket-provider': patch
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
'@backstage/plugin-auth-backend-module-onelogin-provider': patch
'@backstage/plugin-auth-backend-module-aws-alb-provider': patch
'@backstage/plugin-auth-backend-module-gcp-iap-provider': patch
'@backstage/plugin-auth-backend-module-github-provider': patch
'@backstage/plugin-auth-backend-module-gitlab-provider': patch
'@backstage/plugin-auth-backend-module-google-provider': patch
'@backstage/plugin-auth-backend-module-oauth2-provider': patch
'@backstage/plugin-auth-backend-module-oidc-provider': patch
'@backstage/plugin-auth-backend-module-okta-provider': patch
---
Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-node': patch
---
Added a new `allowedDomains` option for the common `emailLocalPartMatchingUserEntityName` sign-in resolver.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor
---
**BREAKING**: The `profileEmailMatchingUserEntityEmail` sign-in resolver has been removed as it was using an insecure fallback for resolving user identities. See https://backstage.io/docs/auth/identity-resolver#sign-in-without-users-in-the-catalog for how to create a custom sign-in resolver if needed as a replacement.
+1 -3
View File
@@ -49,9 +49,7 @@ auth:
scope: ${AUTH_ATLASSIAN_SCOPES}
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/atlassian/provider#resolvers for more resolvers
- resolver: usernameMatchingUserEntityName
```
+1 -2
View File
@@ -25,9 +25,8 @@ auth:
region: 'us-west-2'
signIn:
resolvers:
# typically you would pick one of these
# See https://backstage.io/docs/auth/aws-alb/provider#resolvers for more resolvers
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
```
Ensure that you have set the signer correctly. It is also recommended that you restrict your target groups' security policy to only accept connections from that ALB.
+1 -4
View File
@@ -39,11 +39,8 @@ auth:
clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET}
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/bitbucket/provider#resolvers for more resolvers
- resolver: userIdMatchingUserEntityAnnotation
- resolver: usernameMatchingUserEntityAnnotation
```
The Bitbucket provider is a structure with two configuration keys:
+1 -1
View File
@@ -41,8 +41,8 @@ auth:
# This picks what sign in resolver(s) you want to use.
signIn:
resolvers:
# See https://backstage.io/docs/auth/cloudflare/provider#resolvers for more resolvers
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
```
This config section must be in place for the provider to load at all.
+1 -2
View File
@@ -51,8 +51,7 @@ auth:
# enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL}
signIn:
resolvers:
# Matches the GitHub username with the Backstage user entity name.
# See https://backstage.io/docs/auth/github/provider#resolvers for more resolvers.
# See https://backstage.io/docs/auth/github/provider#resolvers for more resolvers
- resolver: usernameMatchingUserEntityName
```
+1 -3
View File
@@ -46,9 +46,7 @@ auth:
# callbackUrl: https://${BASE_URL}/api/auth/gitlab/handler/frame
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/gitlab/provider#resolvers for more resolvers
- resolver: usernameMatchingUserEntityName
```
+1 -3
View File
@@ -29,9 +29,7 @@ auth:
jwtHeader: x-custom-header # Optional: Only if you are using a custom header for the IAP JWT
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/google/gcp-iap-auth#resolvers for more resolvers
- resolver: emailMatchingUserEntityAnnotation
```
+1 -3
View File
@@ -44,9 +44,7 @@ auth:
clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET}
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/google/provider#resolvers for more resolvers
- resolver: emailMatchingUserEntityAnnotation
```
+40 -9
View File
@@ -70,6 +70,14 @@ always be full entity references, as opposed to shorthands like just `jane` or
## Sign-in Resolvers
:::warning
Be careful when configuring Sign-in resolvers, as they are part of determining who
has access to your Backstage instance, and with what identity. Always only configure
**a single sign-in resolver for one of your auth providers**. The only reason to have
more sign-in resolvers is if you want to allow your users to sign in to Backstage in
multiple ways, but it increases the risk of account hijacking.
:::
Signing in a user into Backstage requires a mapping of the user identity from the
third-party auth provider to a Backstage user identity. This mapping can vary quite
a lot between different organizations and auth providers, and because of that there's
@@ -112,19 +120,38 @@ auth:
signIn:
resolvers:
- resolver: usernameMatchingUserEntityName
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
```
Note that in this instance it lists several resolvers, which means that the
framework will try them one by one until one succeeds. If none of them do, the
sign in attempt is rejected.
The list of available resolvers is different for each provider, since they often
depend on the information model returned from the upstream provider service.
Consult the documentation of the respective provider to find the list.
In the example above `emailMatchingUserEntityProfileEmail` and `emailLocalPartMatchingUserEntityName` are common to all auth providers and `usernameMatchingUserEntityName` is specific to GitHub.
In the example above, the `usernameMatchingUserEntityName` is specific to the
GitHub provider, but you could also choose to use the
`emailMatchingUserEntityProfileEmail` or `emailLocalPartMatchingUserEntityName`
resolvers, which are common to all auth providers.
:::warning
When using the `emailLocalPartMatchingUserEntityName` resolver it is strongly
recommended to set the `allowedDomains` option to ensure that only authorized users
are able to sign-in.
:::
If you are using the `emailLocalPartMatchingUserEntityName` resolver, it is
recommended to also set the `allowedDomains` option, for example:
```yaml title="Within the provider configuration"
auth:
providers:
github:
development:
...
signIn:
resolvers:
- resolver: emailLocalPartMatchingUserEntityName
allowedDomains:
- acme.org
```
### Building Custom Resolvers
@@ -160,8 +187,6 @@ auth:
signIn:
resolvers:
- resolver: usernameMatchingUserEntityName
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
/* highlight-remove-end */
```
@@ -318,6 +343,12 @@ async signInResolver({ profile: { email} }, ctx) {
### Sign-In without Users in the Catalog
:::warning
Signing in users without verifying that they exist in the catalog can be
dangerous. Take care to ensure that your custom resolvers only allow expected
users to sign in, for example by checking email domains.
:::
While populating the catalog with organizational data unlocks more powerful ways
to browse your software ecosystem, it might not always be a viable or prioritized
option. However, even if you do not have user entities populated in your catalog, you
+1 -3
View File
@@ -66,9 +66,7 @@ auth:
azureEasyAuth:
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/microsoft/easy-auth#resolvers for more resolvers
- resolver: idMatchingUserEntityAnnotation
```
+1 -4
View File
@@ -69,10 +69,7 @@ auth:
domainHint: ${AZURE_TENANT_ID}
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
- resolver: emailMatchingUserEntityAnnotation
# See https://backstage.io/docs/auth/microsoft/provider#resolvers for more resolvers
- resolver: userIdMatchingUserEntityAnnotation
```
+1 -3
View File
@@ -31,9 +31,7 @@ auth:
oauth2Proxy:
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/oauth2-proxy/provider#resolvers for more resolvers
- resolver: forwardedUserMatchingUserEntityName
```
-2
View File
@@ -156,8 +156,6 @@ auth:
# ...
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailLocalPartMatchingUserEntityName
- resolver: emailMatchingUserEntityProfileEmail
```
+1 -3
View File
@@ -49,9 +49,7 @@ auth:
additionalScopes: ${AUTH_OKTA_ADDITIONAL_SCOPES} # Optional
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/okta/provider#resolvers for more resolvers
- resolver: emailMatchingUserEntityAnnotation
```
+1 -3
View File
@@ -40,9 +40,7 @@ auth:
issuer: https://<company>.onelogin.com/oidc/2
signIn:
resolvers:
# typically you would pick one of these
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
# See https://backstage.io/docs/auth/onelogin/provider#resolvers for more resolvers
- resolver: usernameMatchingUserEntityName
```
+1 -4
View File
@@ -49,10 +49,8 @@ auth:
organizationId: ${ORG_ID}
signIn:
resolvers:
# typically you would pick one of these
# See https://backstage.io/docs/auth/vmware-cloud/provider#resolvers for more resolvers
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
- resolver: vmwareCloudSignInResolvers
```
Where `APP_ID` refers to the ID retrieved when creating the OAuth App, and
@@ -76,7 +74,6 @@ This provider includes several resolvers out of the box that you can use:
- `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will throw a `NotFoundError`.
- `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found it will throw a `NotFoundError`.
- `vmwareCloudSignInResolvers`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will sign in the user without associating with a catalog user.
:::note Note
@@ -884,8 +884,6 @@ auth:
signIn:
resolvers:
- resolver: emailMatchingUserEntityProfileEmail
- resolver: emailLocalPartMatchingUserEntityName
- resolver: emailMatchingUserEntityAnnotation
```
:::note Note
+2 -2
View File
@@ -53,11 +53,11 @@ Note that the `UrlReaderService` system operates with a service context and is n
Backstage provides authentication of users through the `auth` plugin, which primarily acts as an authorization server for different OAuth 2.0 provider integrations. These integrations can both serve the purpose of signing users into Backstage, as well as providing delegated access to external resources, and are all subject to the common concerns of implementing secure OAuth 2.0 authorization servers. All auth provider integrations are disabled by default, and need to be enabled through configuration in order to be used. For each Backstage installation it is recommended to only enable the minimal set of providers that are in use by that instance.
In order to use an auth provider to sign in users into Backstage, it needs to be configured with an [Identity resolver](https://backstage.io/docs/auth/identity-resolver), which is a custom callback implemented in code. The identity resolver is a sensitive part of configuring Backstage and it is important that it always resolves user identities correctly, based on information provided by the authentication provider. There are a number of built-in identity resolvers that can simplify configuration, and it is important that these all resolve users in a secure way, regardless of how they are used.
In order to use an auth provider to sign in users into Backstage, it needs to be configured with a [sign-in resolver](https://backstage.io/docs/auth/identity-resolver). The sign-in resolver is a sensitive part of configuring Backstage and it is important that it always resolves user identities correctly, and rejects unauthorized users. There are a number of built-in sign-in resolvers that can simplify configuration, or you can implement your own custom sign-in resolver in code, either way it is very important that these resolvers map user identities correctly. You should **always use the minimum number of sign-in resolvers necessary** to avoid risk of account hijacking.
Backstage also supports authentication through an authenticating reverse proxy such as [AWS ALB](https://aws.amazon.com/elasticloadbalancing/application-load-balancer/), where the user identity is read from the incoming proxied decorated request. The following proxy auth providers verify the signature of incoming requests, and are therefore safe to deploy with direct access by users: `awsAlb`, `cfAccess`, and `gcpIap`. Providers like `oauth2Proxy` do not verify the incoming request and can therefore be spoofed by a malicious internal user to supply the `auth` backend with forged identity information. Its therefore highly recommended to restrict access to the `oauth2Proxy` endpoints, or use a different provider.
As part of signing in with an identity resolver, a Backstage Token is issued containing the resolved user identity. The tokens are asymmetrically signed JSON Web Tokens, with the public keys available to any service that wishes to verify a token. The signing keys are rotated continuously and are unique to each installation of Backstage, meaning that Backstage Tokens are not shared across installations. The token contains claims for the user identity and ownership information, which can be used to determine what Backstage resources are owned by that user or group. It is important that this token can not be forged outside of the `auth` plugin, with the exception of other plugins deployed in the same backend service or sharing the same database. For a high-security deployment, the `auth` backend should therefore be deployed in a separate service with its own database.
As part of signing in with a sign-in resolver, a Backstage Token is issued containing the resolved user identity. The tokens are asymmetrically signed JSON Web Tokens, with the public keys available to any service that wishes to verify a token. The signing keys are rotated continuously and are unique to each installation of Backstage, meaning that Backstage Tokens are not shared across installations. The token contains claims for the user identity and ownership information, which can be used to determine what Backstage resources are owned by that user or group. It is important that this token can not be forged outside of the `auth` plugin, with the exception of other plugins deployed in the same backend service or sharing the same database. For a high-security deployment, the `auth` backend should therefore be deployed in a separate service with its own database.
The token is used to prove the identity of the user within the Backstage system, and is used throughout Backstage plugins to control access. It is important that the ownership resolution logic is consistent across the entire Backstage ecosystem, with no possibility of misinterpreting the ownership information.
+4 -1
View File
@@ -31,7 +31,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'usernameMatchingUserEntityName' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -41,7 +41,10 @@ export interface Config {
region: string;
signIn?: {
resolvers: Array<
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -29,7 +29,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'userIdMatchingUserEntityAnnotation' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
@@ -31,7 +31,10 @@ export interface Config {
authorizationCookieName?: string;
signIn?: {
resolvers: Array<
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -36,7 +36,10 @@ export interface Config {
resolvers: Array<
| { resolver: 'emailMatchingUserEntityAnnotation' }
| { resolver: 'idMatchingUserEntityAnnotation' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -31,7 +31,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'usernameMatchingUserEntityName' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -31,7 +31,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'usernameMatchingUserEntityName' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -30,7 +30,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'emailMatchingUserEntityAnnotation' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -33,7 +33,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'emailMatchingUserEntityAnnotation' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -35,7 +35,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'usernameMatchingUserEntityName' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -33,7 +33,10 @@ export interface Config {
prompt?: string;
signIn?: {
resolvers: Array<
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
@@ -39,7 +39,10 @@ export type OidcAuthResult = {
export namespace oidcSignInResolvers {
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
unknown,
unknown
| {
allowedDomains?: string[] | undefined;
}
| undefined
>;
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
unknown,
+4 -1
View File
@@ -33,7 +33,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'emailMatchingUserEntityAnnotation' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
+4 -1
View File
@@ -30,7 +30,10 @@ export interface Config {
signIn?: {
resolvers: Array<
| { resolver: 'usernameMatchingUserEntityName' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
@@ -27,8 +27,10 @@ export interface Config {
additionalScopes?: string | string[];
signIn?: {
resolvers: Array<
| { resolver: 'profileEmailMatchingUserEntityEmail' }
| { resolver: 'emailLocalPartMatchingUserEntityName' }
| {
resolver: 'emailLocalPartMatchingUserEntityName';
allowedDomains?: string[];
}
| { resolver: 'emailMatchingUserEntityProfileEmail' }
>;
};
@@ -5,10 +5,8 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
import { Strategy } from 'passport-oauth2';
// @public
@@ -31,14 +29,6 @@ export interface VMwareCloudAuthenticatorContext {
providerStrategy: Strategy;
}
// @public
export namespace vmwareCloudSignInResolvers {
const profileEmailMatchingUserEntityEmail: SignInResolverFactory<
OAuthAuthenticatorResult<PassportProfile>,
unknown
>;
}
// @public (undocumented)
export type VMwarePassportProfile = PassportProfile & {
organizationId?: string;
@@ -26,4 +26,3 @@ export {
type VMwarePassportProfile,
} from './authenticator';
export { authModuleVmwareCloudProvider as default } from './module';
export { vmwareCloudSignInResolvers } from './resolvers';
@@ -21,7 +21,6 @@ import {
} from '@backstage/plugin-auth-node';
import { vmwareCloudAuthenticator } from './authenticator';
import { vmwareCloudSignInResolvers } from './resolvers';
/**
* VMware Cloud Provider backend module for the auth plugin
@@ -40,7 +39,6 @@ export const authModuleVmwareCloudProvider = createBackendModule({
factory: createOAuthProviderFactory({
authenticator: vmwareCloudAuthenticator,
signInResolverFactories: {
...vmwareCloudSignInResolvers,
...commonSignInResolvers,
},
}),
@@ -1,90 +0,0 @@
/*
* Copyright 2023 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 { NotFoundError } from '@backstage/errors';
import {
AuthResolverContext,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
SignInResolver,
} from '@backstage/plugin-auth-node';
import { vmwareCloudSignInResolvers } from './resolvers';
describe('vmwareCloudResolver', () => {
let resolverContext: jest.Mocked<AuthResolverContext>;
let signInInfo: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>;
let signInResolver: SignInResolver<OAuthAuthenticatorResult<PassportProfile>>;
beforeEach(() => {
resolverContext = {
issueToken: jest.fn().mockResolvedValue({
token: 'defaultBackstageToken',
}),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn().mockResolvedValue({
token: 'backstageToken',
}),
};
signInInfo = {
result: {} as any, // Resolver doesn't care about the result object
profile: {
displayName: 'TestName',
email: 'user@example.com',
},
};
signInResolver =
vmwareCloudSignInResolvers.profileEmailMatchingUserEntityEmail();
});
it('looks up backstage identity by email', async () => {
const backstageIdentity = await signInResolver(signInInfo, resolverContext);
expect(backstageIdentity.token).toBe('backstageToken');
expect(resolverContext.signInWithCatalogUser).toHaveBeenCalledWith({
filter: {
'spec.profile.email': 'user@example.com',
},
});
});
it('returns "fake" backstage identity when no entity matches', async () => {
resolverContext.signInWithCatalogUser.mockRejectedValue(
new NotFoundError('User not found'),
);
const backstageIdentity = await signInResolver(signInInfo, resolverContext);
expect(backstageIdentity.token).toBe('defaultBackstageToken');
expect(resolverContext.issueToken).toHaveBeenCalledWith({
claims: {
sub: 'user:default/user@example.com',
ent: ['user:default/user@example.com'],
},
});
});
it('fails when resolver context throws other error', () => {
const error = new Error('bizarre');
resolverContext.signInWithCatalogUser.mockRejectedValue(error);
return expect(signInResolver(signInInfo, resolverContext)).rejects.toThrow(
error,
);
});
});
@@ -1,75 +0,0 @@
/*
* Copyright 2023 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 { stringifyEntityRef } from '@backstage/catalog-model';
import {
createSignInResolverFactory,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the VMware Cloud auth provider.
*
* @public
*/
export namespace vmwareCloudSignInResolvers {
/**
* Looks up the user by matching their profile email to the entity's profile email.
* If that fails, sign in the user without associating with a catalog user.
*/
export const profileEmailMatchingUserEntityEmail =
createSignInResolverFactory({
create() {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
ctx,
) => {
const email = info.profile.email;
if (!email) {
throw new Error(
'VMware login failed, user profile does not contain an email',
);
}
const userEntityRef = stringifyEntityRef({
kind: 'User',
name: email,
});
try {
// we await here so that signInWithCatalogUser throws in the current `try`
return await ctx.signInWithCatalogUser({
filter: {
'spec.profile.email': email,
},
});
} catch (e) {
if (e.name !== 'NotFoundError') {
throw e;
}
return ctx.issueToken({
claims: {
sub: userEntityRef,
ent: [userEntityRef],
},
});
}
};
},
});
}
+2 -1
View File
@@ -53,7 +53,8 @@
"passport": "^0.7.0",
"winston": "^3.2.1",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.21.4"
"zod-to-json-schema": "^3.21.4",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
+7 -10
View File
@@ -148,7 +148,10 @@ export namespace commonSignInResolvers {
>;
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
unknown,
unknown
| {
allowedDomains?: string[] | undefined;
}
| undefined
>;
}
@@ -178,10 +181,7 @@ export function createOAuthProviderFactory<TProfile>(options: {
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
signInResolverFactories?: {
[name in string]: SignInResolverFactory<
OAuthAuthenticatorResult<TProfile>,
unknown
>;
[name in string]: SignInResolverFactory;
};
}): AuthProviderFactory;
@@ -200,10 +200,7 @@ export function createProxyAuthProviderFactory<TResult>(options: {
authenticator: ProxyAuthenticator<unknown, TResult, unknown>;
profileTransform?: ProfileTransform<TResult>;
signInResolver?: SignInResolver<TResult>;
signInResolverFactories?: Record<
string,
SignInResolverFactory<TResult, unknown>
>;
signInResolverFactories?: Record<string, SignInResolverFactory>;
}): AuthProviderFactory;
// @public (undocumented)
@@ -648,7 +645,7 @@ export type SignInResolver<TAuthResult> = (
) => Promise<BackstageSignInResult>;
// @public (undocumented)
export interface SignInResolverFactory<TAuthResult, TOptions> {
export interface SignInResolverFactory<TAuthResult = any, TOptions = any> {
// (undocumented)
(
...options: undefined extends TOptions
@@ -34,10 +34,7 @@ export function createOAuthProviderFactory<TProfile>(options: {
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
signInResolverFactories?: {
[name in string]: SignInResolverFactory<
OAuthAuthenticatorResult<TProfile>,
unknown
>;
[name in string]: SignInResolverFactory;
};
}): AuthProviderFactory {
return ctx => {
@@ -31,10 +31,7 @@ export function createProxyAuthProviderFactory<TResult>(options: {
authenticator: ProxyAuthenticator<unknown, TResult, unknown>;
profileTransform?: ProfileTransform<TResult>;
signInResolver?: SignInResolver<TResult>;
signInResolverFactories?: Record<
string,
SignInResolverFactory<TResult, unknown>
>;
signInResolverFactories?: Record<string, SignInResolverFactory>;
}): AuthProviderFactory {
return ctx => {
const signInResolver =
@@ -14,7 +14,9 @@
* limitations under the License.
*/
import { z } from 'zod';
import { createSignInResolverFactory } from './createSignInResolverFactory';
import { NotAllowedError } from '@backstage/errors';
// This splits an email "joe+work@acme.com" into ["joe", "+work", "@acme.com"]
// so that we can remove the plus addressing. May output a shorter array:
@@ -77,7 +79,13 @@ export namespace commonSignInResolvers {
*/
export const emailLocalPartMatchingUserEntityName =
createSignInResolverFactory({
create() {
optionsSchema: z
.object({
allowedDomains: z.array(z.string()).optional(),
})
.optional(),
create(options = {}) {
const { allowedDomains } = options;
return async (info, ctx) => {
const { profile } = info;
@@ -87,6 +95,13 @@ export namespace commonSignInResolvers {
);
}
const [localPart] = profile.email.split('@');
const domain = profile.email.slice(localPart.length + 1);
if (allowedDomains && !allowedDomains.includes(domain)) {
throw new NotAllowedError(
'Sign-in user email is not from an allowed domain',
);
}
return ctx.signInWithCatalogUser({
entityRef: { name: localPart },
@@ -18,10 +18,11 @@ import { ZodSchema, ZodTypeDef } from 'zod';
import { SignInResolver } from '../types';
import zodToJsonSchema from 'zod-to-json-schema';
import { JsonObject } from '@backstage/types';
import { fromError } from 'zod-validation-error';
import { InputError } from '@backstage/errors';
/** @public */
export interface SignInResolverFactory<TAuthResult, TOptions> {
export interface SignInResolverFactory<TAuthResult = any, TOptions = any> {
(
...options: undefined extends TOptions
? [options?: TOptions]
@@ -66,7 +67,14 @@ export function createSignInResolverFactory<
? [options?: TOptionsInput]
: [options: TOptionsInput]
) => {
const parsedOptions = optionsSchema.parse(resolverOptions);
let parsedOptions;
try {
parsedOptions = optionsSchema.parse(resolverOptions);
} catch (error) {
throw new InputError(
`Invalid sign-in resolver options, ${fromError(error)}`,
);
}
return options.create(parsedOptions);
};
+5 -4
View File
@@ -5469,6 +5469,7 @@ __metadata:
winston: ^3.2.1
zod: ^3.22.4
zod-to-json-schema: ^3.21.4
zod-validation-error: ^3.4.0
languageName: unknown
linkType: soft
@@ -45234,12 +45235,12 @@ __metadata:
languageName: node
linkType: hard
"zod-validation-error@npm:^3.0.3":
version: 3.1.0
resolution: "zod-validation-error@npm:3.1.0"
"zod-validation-error@npm:^3.0.3, zod-validation-error@npm:^3.4.0":
version: 3.4.0
resolution: "zod-validation-error@npm:3.4.0"
peerDependencies:
zod: ^3.18.0
checksum: 84df01c91d594701eaf7f5f007be881e47f7adef2e3f3765f7be031cb78033f9be0924273106cb81b586d8020da9885dbb81b3da363f00a51df00f26274f2b23
checksum: b07fbfc39582dbdf6972f5f5f0c3bac9e6b5e6d2e55ef3dd891fd08f1966ebf1023a4bc270e9b569eaa48ed1684ac2252c9f260b0bd07b167671596e6e4d0fa8
languageName: node
linkType: hard