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

🐛 Bug Report: [Microsoft Entra ID / Azure DevOps] Can't register an existing component on Azure DevOps #28002

Open
2 tasks done
bolebon opened this issue Dec 5, 2024 · 5 comments
Labels
bug Something isn't working

Comments

@bolebon
Copy link

bolebon commented Dec 5, 2024

📜 Description

When I try to use the "Register an existing component" feature, I provide the URL of the repository and it manages to get the information of the project but when I click on the "Create PR" button it asks me to login again and, when I do, I get the error: Cannot read properties of undefined (reading 'id')

👍 Expected behavior

I don't think it should be necessary to login again as I'm already logged in with my Microsoft credentials and even so, it should not return an error and create the PR.

👎 Actual Behavior with Screenshots

When I click on "Create PR" I can see this error appearing in the network logs

Screenshot 2024-12-05 at 16 49 08 Screenshot 2024-12-05 at 16 31 47

Then, this popup appears
Screenshot 2024-12-05 at 16 51 54

And when I click on login, I get this:
Screenshot 2024-12-05 at 16 29 27

👟 Reproduction steps

  1. Go on Backstage
  2. Click on "Create" in the left panel
  3. Click on "Register an existing component"
  4. Provide the URL of a repository on AzureDevops and click on "Analyze"
  5. Click on "Create PR"

📃 Provide the context for the Bug.

I'm using AzureDevOps and Microsoft Entra ID integration.
Everything seems to work well, Backstage is capable of getting the list of users and groups coming from Entra ID and is already capable to discover repos having a catalog-info.yaml.
All the required permissions seems to be granted on the app registration as you can see in the screenshot below.

Screenshot 2024-12-05 at 16 32 55

🖥️ Your Environment

My backstage instance is currently running in an Azure App Service.

👀 Have you spent some time to check if this bug has been raised before?

  • I checked and didn't find similar issue

🏢 Have you read the Code of Conduct?

Are you willing to submit PR?

None

@bolebon bolebon added the bug Something isn't working label Dec 5, 2024
@camilaibs
Copy link
Contributor

camilaibs commented Dec 9, 2024

Hi @bolebon 👋🏻 ,
Could you please share the full error stack with us?
We would like to read the entire stack error. The image you shared cuts it off:

image

Also, is it possible for you to run yarn tsc:full and let us know if there are any errors?

@bolebon
Copy link
Author

bolebon commented Dec 9, 2024

Hi @camilaibs!

Sure, here's the frontend stack trace.
image

Here's the backend stack trace:

TypeError: Cannot read properties of undefined (reading 'id')
    at /app/node_modules/@backstage/plugin-auth-backend-module-microsoft-provider/dist/resolvers.cjs.js:27:41
    at /app/node_modules/@backstage/plugin-auth-node/dist/sign-in/readDeclarativeSignInResolver.cjs.js:23:22
    at Object.refresh (/app/node_modules/@backstage/plugin-auth-node/dist/oauth/createOAuthRouteHandlers.cjs.js:200:34)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async OAuthEnvironmentHandler.refresh (/app/node_modules/@backstage/plugin-auth-node/dist/oauth/OAuthEnvironmentHandler.cjs.js:30:5)

I ran yarn tsc:full but I didn't get any output, so I guess it went fine.

@bolebon
Copy link
Author

bolebon commented Dec 9, 2024

Hello again @camilaibs,

I investigated a bit and found this code which seems to be where the issue happens:
https://github.com/backstage/backstage/blob/e22c946a7664d11b0955b73ff99deb3c71108baa/plugins/auth-backend-module-microsoft-provider/src/resolvers.ts#L56C1-L78C7

Actually, when the API call is made, the fullProfile property is empty (I don't know if it's normal or not) but, is the response returned by the API, there are also other properties:

{
  "fullProfile": undefined,
  "session": {
      "accessToken": "...",
      "tokenType": "Bearer",
      "scope": "...",
      "expiresInSeconds": XXX,
      "IdToken": "...",
      "refreshToken": "..."
   }
}

The access token is a JWT, and when decoded you can find a "oid" claim in the payload which is actually the user id in Entra ID so this could be used as a workaround for when the "fullProfile" is not available.
What do you think ?

@bolebon
Copy link
Author

bolebon commented Dec 10, 2024

Hi everyone,

I'm coming back with some update regarding this issue.
Following this documentation, I decided to create my own resolver (based on the analysis I did earlier) which looks like this:

import { jwtDecode } from 'jwt-decode';
import { createBackendModule } from '@backstage/backend-plugin-api';
import { microsoftAuthenticator } from '@backstage/plugin-auth-backend-module-microsoft-provider';
import {
  authProvidersExtensionPoint,
  createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';

export const customMicrosoftAuth = createBackendModule({
  // This ID must be exactly "auth" because that's the plugin it targets
  pluginId: 'auth',
  // This ID must be unique, but can be anything
  moduleId: 'custom-microsoft-provider',
  register(reg) {
    reg.registerInit({
      deps: { providers: authProvidersExtensionPoint },
      async init({ providers }) {
        providers.registerProvider({
          // This ID must match the actual provider config, e.g. addressing
          // auth.providers.github means that this must be "github".
          providerId: 'microsoft',
          // Use createProxyAuthProviderFactory instead if it's one of the proxy
          // based providers rather than an OAuth based one
          factory: createOAuthProviderFactory({
            authenticator: microsoftAuthenticator,
            async signInResolver(info, ctx) {
              const { result } = info;
              const { fullProfile, session } = result;
              // We take either the full profile ID or the OID from the session
              const id = fullProfile?.id ?? jwtDecode<{ oid?: string }>(session.accessToken)?.oid;
              if (typeof id !== 'string') {
                throw new Error("Microsoft profile contained no valid id");
              }
              return ctx.signInWithCatalogUser({
                annotations: {
                  "graph.microsoft.com/user-id": id
                }
              });
            },
          }),
        });
      },
    });
  },
});

And this works perfectly !
I might create a PR later to fix it in the backend plugin directly.

@Phiph
Copy link
Contributor

Phiph commented Dec 10, 2024

Hey @bolebon,

Thanks for raising this issue!

So we use this integration and use it daily, and my team member @andrei-ivanovici contributed the change 👍

We have our Entra App set up like so:

image

We ingest the Users from the groups in Entra, but we allow anyone in our organisation to sign in even if they are not in the catalog.

We use a module extension point like you've posted above largely taken from the docs here

import { createBackendModule } from '@backstage/backend-plugin-api';
import { microsoftAuthenticator } from '@backstage/plugin-auth-backend-module-microsoft-provider';
import {
  authProvidersExtensionPoint,
  createOAuthProviderFactory
} from '@backstage/plugin-auth-node';
import {
  stringifyEntityRef,
  DEFAULT_NAMESPACE
} from '@backstage/catalog-model';

export const customAuth = createBackendModule({
  // This ID must be exactly "auth" because that's the plugin it targets
  pluginId: 'auth',
  // This ID must be unique, but can be anything
  moduleId: 'custom-auth-provider',
  register(reg) {
    reg.registerInit({
      deps: { providers: authProvidersExtensionPoint },
      async init({ providers }) {
        providers.registerProvider({
          // This ID must match the actual provider config, e.g. addressing
          // auth.providers.github means that this must be "github".
          providerId: 'microsoft',
          // Use createProxyAuthProviderFactory instead if it's one of the proxy
          // based providers rather than an OAuth based one
          factory: createOAuthProviderFactory({
            authenticator: microsoftAuthenticator,
            async signInResolver(info, ctx) {
              const {
                profile: { email },
              } = info;

              // Profiles are not always guaranteed to to have an email address.
              // You can also find more provider-specific information in `info.result`.
              // It typically contains a `fullProfile` object as well as ID and/or access
              // tokens that you can use for additional lookups.
              if (!email) {
                throw new Error('User profile contained no email');
              }

              // This example resolver simply uses the local part of the email as the name.
              const [name, domain] = email.split('@');

              // This helper function handles sign-in by looking up a user in the catalog.
              // The lookup can be done either by reference, annotations, or custom filters.
              // Next we verify the email domain. It is recommended to include this
              // kind of check if you don't look up the user in an external service.
              if (domain !== 'mycompanydomain.com') {
                throw new Error(
                  `Login failed, this email ${email} does not belong to the expected domain`
                );
              }
              // The helper also issues a token for the user, using the standard group
              // membership logic to determine the ownership references of the user.
              //
              // There are a number of other methods on the ctx, feel free to explore them!
              return ctx
                .signInWithCatalogUser({
                  entityRef: { name },
                })
                .catch(error => {
                  if (error.name === 'NotFoundError') {
                    // By using `stringifyEntityRef` we ensure that the reference is formatted correctly
                    const userEntity = stringifyEntityRef({
                      kind: 'User',
                      name: name,
                      namespace: DEFAULT_NAMESPACE,
                    });
                    return ctx.issueToken({
                      claims: {
                        sub: userEntity,
                        ent: [userEntity],
                      },
                    });
                  }

                  throw error; // re-throw the error if it's not a UserNotFound error
                });
            },
          }),
        });
      },
    });
  },
});

Our setup using this feature works, so if there are any changes please let us know and we'd be happy to collaborate!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

3 participants