diff --git a/.changeset/cuddly-stingrays-smell.md b/.changeset/cuddly-stingrays-smell.md new file mode 100644 index 0000000000..5a8a8ac236 --- /dev/null +++ b/.changeset/cuddly-stingrays-smell.md @@ -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. diff --git a/.changeset/flat-seals-type.md b/.changeset/flat-seals-type.md new file mode 100644 index 0000000000..9574c74fc9 --- /dev/null +++ b/.changeset/flat-seals-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Added a new `allowedDomains` option for the common `emailLocalPartMatchingUserEntityName` sign-in resolver. diff --git a/.changeset/purple-toys-heal.md b/.changeset/purple-toys-heal.md new file mode 100644 index 0000000000..4a57dca64f --- /dev/null +++ b/.changeset/purple-toys-heal.md @@ -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. diff --git a/docs/auth/atlassian/provider.md b/docs/auth/atlassian/provider.md index 96271f04a0..f4793d4f51 100644 --- a/docs/auth/atlassian/provider.md +++ b/docs/auth/atlassian/provider.md @@ -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 ``` diff --git a/docs/auth/aws-alb/provider.md b/docs/auth/aws-alb/provider.md index a3974c895e..8ce1eba5ab 100644 --- a/docs/auth/aws-alb/provider.md +++ b/docs/auth/aws-alb/provider.md @@ -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. diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index 1ca35437ba..e58ad622fc 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -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: diff --git a/docs/auth/cloudflare/provider.md b/docs/auth/cloudflare/provider.md index a1946580a7..224a64f056 100644 --- a/docs/auth/cloudflare/provider.md +++ b/docs/auth/cloudflare/provider.md @@ -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. diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index 76b63c6617..3b61815017 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -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 ``` diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index 4f5339f5b9..5241481463 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -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 ``` diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index 298f212399..6fe8b56d9e 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -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 ``` diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md index b19d21d63f..109c2ddd9f 100644 --- a/docs/auth/google/provider.md +++ b/docs/auth/google/provider.md @@ -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 ``` diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index d6b377d990..293300f429 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -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 diff --git a/docs/auth/microsoft/azure-easyauth.md b/docs/auth/microsoft/azure-easyauth.md index c2a6eab218..52c61a0d86 100644 --- a/docs/auth/microsoft/azure-easyauth.md +++ b/docs/auth/microsoft/azure-easyauth.md @@ -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 ``` diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index f20e17ea6c..74416f853e 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -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 ``` diff --git a/docs/auth/oauth2-proxy/provider.md b/docs/auth/oauth2-proxy/provider.md index 84e2b307e6..2fa027e0ad 100644 --- a/docs/auth/oauth2-proxy/provider.md +++ b/docs/auth/oauth2-proxy/provider.md @@ -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 ``` diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index 4040a272b4..6d2a937320 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -156,8 +156,6 @@ auth: # ... signIn: resolvers: - # typically you would pick one of these - - resolver: emailLocalPartMatchingUserEntityName - resolver: emailMatchingUserEntityProfileEmail ``` diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index 83352aaa0d..abc6231839 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -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 ``` diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md index 002445a9d1..edceb1de62 100644 --- a/docs/auth/onelogin/provider.md +++ b/docs/auth/onelogin/provider.md @@ -40,9 +40,7 @@ auth: issuer: https://.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 ``` diff --git a/docs/auth/vmware-cloud/provider.md b/docs/auth/vmware-cloud/provider.md index ac2cf3ee92..d654331178 100644 --- a/docs/auth/vmware-cloud/provider.md +++ b/docs/auth/vmware-cloud/provider.md @@ -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 diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md index 188cd4f26f..2bc111b316 100644 --- a/docs/backend-system/building-backends/08-migrating.md +++ b/docs/backend-system/building-backends/08-migrating.md @@ -884,8 +884,6 @@ auth: signIn: resolvers: - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName - - resolver: emailMatchingUserEntityAnnotation ``` :::note Note diff --git a/docs/overview/threat-model.md b/docs/overview/threat-model.md index 2b78d022f9..2461f002c8 100644 --- a/docs/overview/threat-model.md +++ b/docs/overview/threat-model.md @@ -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. It’s 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. diff --git a/plugins/auth-backend-module-atlassian-provider/config.d.ts b/plugins/auth-backend-module-atlassian-provider/config.d.ts index b6ca96d62f..b1edea3d6c 100644 --- a/plugins/auth-backend-module-atlassian-provider/config.d.ts +++ b/plugins/auth-backend-module-atlassian-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-aws-alb-provider/config.d.ts b/plugins/auth-backend-module-aws-alb-provider/config.d.ts index d76ae5bada..2e38490b28 100644 --- a/plugins/auth-backend-module-aws-alb-provider/config.d.ts +++ b/plugins/auth-backend-module-aws-alb-provider/config.d.ts @@ -41,7 +41,10 @@ export interface Config { region: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-bitbucket-provider/config.d.ts b/plugins/auth-backend-module-bitbucket-provider/config.d.ts index df68209469..552a66d700 100644 --- a/plugins/auth-backend-module-bitbucket-provider/config.d.ts +++ b/plugins/auth-backend-module-bitbucket-provider/config.d.ts @@ -29,7 +29,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'userIdMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index 21b839b8d7..72dc599cea 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { authorizationCookieName?: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts index 4ca426d10e..d4fe015b7c 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts @@ -36,7 +36,10 @@ export interface Config { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } | { resolver: 'idMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-github-provider/config.d.ts b/plugins/auth-backend-module-github-provider/config.d.ts index 2ca17998f0..b1bc0dc477 100644 --- a/plugins/auth-backend-module-github-provider/config.d.ts +++ b/plugins/auth-backend-module-github-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-gitlab-provider/config.d.ts b/plugins/auth-backend-module-gitlab-provider/config.d.ts index d21c2bd62a..cbb9f01e02 100644 --- a/plugins/auth-backend-module-gitlab-provider/config.d.ts +++ b/plugins/auth-backend-module-gitlab-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-google-provider/config.d.ts b/plugins/auth-backend-module-google-provider/config.d.ts index 3abab05cfe..e788860fa8 100644 --- a/plugins/auth-backend-module-google-provider/config.d.ts +++ b/plugins/auth-backend-module-google-provider/config.d.ts @@ -30,7 +30,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index f8b5f0355d..070b6300f7 100644 --- a/plugins/auth-backend-module-microsoft-provider/config.d.ts +++ b/plugins/auth-backend-module-microsoft-provider/config.d.ts @@ -33,7 +33,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-oauth2-provider/config.d.ts b/plugins/auth-backend-module-oauth2-provider/config.d.ts index fa9dec7ce1..5cee3a5407 100644 --- a/plugins/auth-backend-module-oauth2-provider/config.d.ts +++ b/plugins/auth-backend-module-oauth2-provider/config.d.ts @@ -35,7 +35,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index dbc06e51bb..d59c40697a 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -33,7 +33,10 @@ export interface Config { prompt?: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-oidc-provider/report.api.md b/plugins/auth-backend-module-oidc-provider/report.api.md index df717a9e1e..bee9a2fdd9 100644 --- a/plugins/auth-backend-module-oidc-provider/report.api.md +++ b/plugins/auth-backend-module-oidc-provider/report.api.md @@ -39,7 +39,10 @@ export type OidcAuthResult = { export namespace oidcSignInResolvers { const emailLocalPartMatchingUserEntityName: SignInResolverFactory< unknown, - unknown + | { + allowedDomains?: string[] | undefined; + } + | undefined >; const emailMatchingUserEntityProfileEmail: SignInResolverFactory< unknown, diff --git a/plugins/auth-backend-module-okta-provider/config.d.ts b/plugins/auth-backend-module-okta-provider/config.d.ts index 0689171153..d2b0c4aa02 100644 --- a/plugins/auth-backend-module-okta-provider/config.d.ts +++ b/plugins/auth-backend-module-okta-provider/config.d.ts @@ -33,7 +33,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-onelogin-provider/config.d.ts b/plugins/auth-backend-module-onelogin-provider/config.d.ts index 8257e2ac71..da7c312dd3 100644 --- a/plugins/auth-backend-module-onelogin-provider/config.d.ts +++ b/plugins/auth-backend-module-onelogin-provider/config.d.ts @@ -30,7 +30,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts index a08451cff3..67db735713 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts @@ -27,8 +27,10 @@ export interface Config { additionalScopes?: string | string[]; signIn?: { resolvers: Array< - | { resolver: 'profileEmailMatchingUserEntityEmail' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/report.api.md b/plugins/auth-backend-module-vmware-cloud-provider/report.api.md index c970b237a9..1bd10c7eae 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/report.api.md +++ b/plugins/auth-backend-module-vmware-cloud-provider/report.api.md @@ -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, - unknown - >; -} - // @public (undocumented) export type VMwarePassportProfile = PassportProfile & { organizationId?: string; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts index 1d7bb14b63..4f42092c21 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts @@ -26,4 +26,3 @@ export { type VMwarePassportProfile, } from './authenticator'; export { authModuleVmwareCloudProvider as default } from './module'; -export { vmwareCloudSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts index 5ef79654a3..4d9ca700a3 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts @@ -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, }, }), diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts deleted file mode 100644 index 250fd430c7..0000000000 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts +++ /dev/null @@ -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; - let signInInfo: SignInInfo>; - let signInResolver: SignInResolver>; - - 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, - ); - }); -}); diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts deleted file mode 100644 index 2b3a78842b..0000000000 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts +++ /dev/null @@ -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>, - 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], - }, - }); - } - }; - }, - }); -} diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 6925fc82b6..b27a5fd942 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -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:^", diff --git a/plugins/auth-node/report.api.md b/plugins/auth-node/report.api.md index 4cf9d5fd4b..5d3fce8f23 100644 --- a/plugins/auth-node/report.api.md +++ b/plugins/auth-node/report.api.md @@ -148,7 +148,10 @@ export namespace commonSignInResolvers { >; const emailLocalPartMatchingUserEntityName: SignInResolverFactory< unknown, - unknown + | { + allowedDomains?: string[] | undefined; + } + | undefined >; } @@ -178,10 +181,7 @@ export function createOAuthProviderFactory(options: { profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; signInResolverFactories?: { - [name in string]: SignInResolverFactory< - OAuthAuthenticatorResult, - unknown - >; + [name in string]: SignInResolverFactory; }; }): AuthProviderFactory; @@ -200,10 +200,7 @@ export function createProxyAuthProviderFactory(options: { authenticator: ProxyAuthenticator; profileTransform?: ProfileTransform; signInResolver?: SignInResolver; - signInResolverFactories?: Record< - string, - SignInResolverFactory - >; + signInResolverFactories?: Record; }): AuthProviderFactory; // @public (undocumented) @@ -648,7 +645,7 @@ export type SignInResolver = ( ) => Promise; // @public (undocumented) -export interface SignInResolverFactory { +export interface SignInResolverFactory { // (undocumented) ( ...options: undefined extends TOptions diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index fccfcb94de..f7f56371a3 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -34,10 +34,7 @@ export function createOAuthProviderFactory(options: { profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; signInResolverFactories?: { - [name in string]: SignInResolverFactory< - OAuthAuthenticatorResult, - unknown - >; + [name in string]: SignInResolverFactory; }; }): AuthProviderFactory { return ctx => { diff --git a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts index 36e7b04e1e..0ce0102444 100644 --- a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts +++ b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts @@ -31,10 +31,7 @@ export function createProxyAuthProviderFactory(options: { authenticator: ProxyAuthenticator; profileTransform?: ProfileTransform; signInResolver?: SignInResolver; - signInResolverFactories?: Record< - string, - SignInResolverFactory - >; + signInResolverFactories?: Record; }): AuthProviderFactory { return ctx => { const signInResolver = diff --git a/plugins/auth-node/src/sign-in/commonSignInResolvers.ts b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts index f664e7e33b..6f0fc7fdb2 100644 --- a/plugins/auth-node/src/sign-in/commonSignInResolvers.ts +++ b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts @@ -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 }, diff --git a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts index 0632b704c3..e857a0fe3f 100644 --- a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts +++ b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts @@ -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 { +export interface SignInResolverFactory { ( ...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); }; diff --git a/yarn.lock b/yarn.lock index 9825f6ba82..8d39268875 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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